From e7261cabed4502e0343b45c6b671765c7c4c8add Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Fri, 20 Mar 2026 10:22:11 +0100 Subject: [PATCH 1/4] feat: add OpenFeature track() API for event tracking Implement the OpenFeature track() API across all providers (JS, Java, Go) to enable recording business events for A/B test analytics. Events are buffered in the WASM core via a lock-free EventLogger, flushed alongside flag logs, and sent to EventsService via gRPC (Java/Go) or HTTP+protobuf (JS). Co-Authored-By: Claude Opus 4.6 (1M context) --- confidence-resolver/src/event_logger.rs | 117 ++++++++ confidence-resolver/src/lib.rs | 1 + .../go/confidence/event_sender.go | 11 + .../go/confidence/flag_logs_test.go | 2 +- .../go/confidence/grpc_event_sender.go | 71 +++++ .../go/confidence/integration_test.go | 10 +- .../assets/confidence_resolver.wasm | Bin 472102 -> 476230 bytes .../internal/local_resolver/local_resolver.go | 2 + .../internal/local_resolver/pool.go | 26 ++ .../internal/local_resolver/recover.go | 14 + .../internal/local_resolver/wasm.go | 10 + .../internal/proto/events/api.pb.go | 200 +++++++++++++ .../internal/proto/events/api_grpc.pb.go | 121 ++++++++ .../internal/proto/events/types.pb.go | 272 ++++++++++++++++++ .../internal/proto/wasm/messages.pb.go | 142 ++++++++- .../confidence/internal/testutil/helpers.go | 4 + .../go/confidence/materialization.go | 8 + .../go/confidence/provider.go | 46 +++ .../go/confidence/provider_builder.go | 10 +- .../go/confidence/provider_resolve_test.go | 10 +- .../go/confidence/provider_test.go | 22 +- .../go/scripts/generate_proto.sh | 5 +- openfeature-provider/java/pom.xml | 1 + .../confidence/sdk/GrpcEventSender.java | 121 ++++++++ .../spotify/confidence/sdk/LocalResolver.java | 11 + .../confidence/sdk/MaterializingResolver.java | 11 + .../sdk/OpenFeatureLocalResolveProvider.java | 65 ++++- .../confidence/sdk/PooledResolver.java | 11 + .../confidence/sdk/RecoveringResolver.java | 20 ++ .../confidence/sdk/WasmLocalResolver.java | 53 +++- .../confidence/sdk/ChannelFactoryTest.java | 6 +- .../spotify/confidence/sdk/ResolveTest.java | 2 +- .../sdk/WasmResolveApiFlushCloseRaceTest.java | 2 +- openfeature-provider/js/Makefile | 4 +- openfeature-provider/js/package.json | 2 +- .../src/ConfidenceServerProviderLocal.test.ts | 2 + .../js/src/ConfidenceServerProviderLocal.ts | 58 +++- openfeature-provider/js/src/LocalResolver.ts | 4 +- openfeature-provider/js/src/WasmResolver.ts | 42 ++- .../proto/confidence/events/v1/api.proto | 24 ++ .../proto/confidence/events/v1/types.proto | 28 ++ .../proto/confidence/wasm/messages.proto | 12 + wasm/proto/messages.proto | 11 + wasm/rust-guest/src/lib.rs | 23 ++ 44 files changed, 1572 insertions(+), 45 deletions(-) create mode 100644 confidence-resolver/src/event_logger.rs create mode 100644 openfeature-provider/go/confidence/event_sender.go create mode 100644 openfeature-provider/go/confidence/grpc_event_sender.go create mode 100644 openfeature-provider/go/confidence/internal/proto/events/api.pb.go create mode 100644 openfeature-provider/go/confidence/internal/proto/events/api_grpc.pb.go create mode 100644 openfeature-provider/go/confidence/internal/proto/events/types.pb.go create mode 100644 openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/GrpcEventSender.java create mode 100644 openfeature-provider/proto/confidence/events/v1/api.proto create mode 100644 openfeature-provider/proto/confidence/events/v1/types.proto diff --git a/confidence-resolver/src/event_logger.rs b/confidence-resolver/src/event_logger.rs new file mode 100644 index 00000000..deee4240 --- /dev/null +++ b/confidence-resolver/src/event_logger.rs @@ -0,0 +1,117 @@ +use crossbeam_queue::SegQueue; + +use crate::proto::google::{Struct, Timestamp}; + +pub struct TrackedEvent { + pub event_definition: String, + pub payload: Struct, + pub event_time: Timestamp, +} + +#[derive(Default)] +pub struct EventLogger { + events: SegQueue, +} + +impl EventLogger { + pub fn new() -> Self { + Self::default() + } + + pub fn track(&self, event: TrackedEvent) { + self.events.push(event); + } + + // TODO: Only drop events from memory after the provider confirms successful delivery. + // Currently events are drained unconditionally — if the HTTP/gRPC send fails, they are lost. + pub fn flush(&self) -> Vec { + let mut result = Vec::new(); + while let Some(event) = self.events.pop() { + result.push(event); + } + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_event(name: &str) -> TrackedEvent { + TrackedEvent { + event_definition: format!("eventDefinitions/{}", name), + payload: Struct::default(), + event_time: Timestamp { + seconds: 1234, + nanos: 0, + }, + } + } + + #[test] + fn track_and_flush_single_event() { + let logger = EventLogger::new(); + logger.track(make_event("purchase")); + let events = logger.flush(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].event_definition, "eventDefinitions/purchase"); + } + + #[test] + fn flush_drains_all_events() { + let logger = EventLogger::new(); + logger.track(make_event("a")); + logger.track(make_event("b")); + logger.track(make_event("c")); + let events = logger.flush(); + assert_eq!(events.len(), 3); + // second flush returns empty + assert!(logger.flush().is_empty()); + } + + #[test] + fn empty_flush_returns_empty_vec() { + let logger = EventLogger::new(); + assert!(logger.flush().is_empty()); + } + + #[test] + fn concurrent_track_and_flush() { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::Arc; + use std::thread; + + let logger = Arc::new(EventLogger::new()); + let done = Arc::new(AtomicBool::new(false)); + let total_tracked = Arc::new(AtomicUsize::new(0)); + + let mut handles = Vec::new(); + for _ in 0..3 { + let lg = logger.clone(); + let done_cl = done.clone(); + let tracked = total_tracked.clone(); + handles.push(thread::spawn(move || { + let mut count = 0; + while !done_cl.load(Ordering::Relaxed) { + lg.track(make_event("concurrent")); + count += 1; + } + tracked.fetch_add(count, Ordering::Relaxed); + })); + } + + let lg = logger.clone(); + let mut total_flushed = 0; + for _ in 0..10 { + thread::sleep(std::time::Duration::from_millis(10)); + total_flushed += lg.flush().len(); + } + done.store(true, Ordering::Relaxed); + for h in handles { + h.join().unwrap(); + } + total_flushed += logger.flush().len(); + + assert_eq!(total_flushed, total_tracked.load(Ordering::Relaxed)); + } +} diff --git a/confidence-resolver/src/lib.rs b/confidence-resolver/src/lib.rs index 38cbe5a8..72c861a4 100644 --- a/confidence-resolver/src/lib.rs +++ b/confidence-resolver/src/lib.rs @@ -44,6 +44,7 @@ use err::Fallible; pub mod assign_logger; mod bounded_set; mod err; +pub mod event_logger; pub mod flag_logger; mod gzip; pub mod proto; diff --git a/openfeature-provider/go/confidence/event_sender.go b/openfeature-provider/go/confidence/event_sender.go new file mode 100644 index 00000000..beff8d84 --- /dev/null +++ b/openfeature-provider/go/confidence/event_sender.go @@ -0,0 +1,11 @@ +package confidence + +import ( + "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/wasm" +) + +// EventSender sends tracked events to the Confidence events backend. +type EventSender interface { + Send(response *wasm.FlushEventsResponse, clientSecret string) + Shutdown() +} diff --git a/openfeature-provider/go/confidence/flag_logs_test.go b/openfeature-provider/go/confidence/flag_logs_test.go index 8d3dfddb..9106b2ef 100644 --- a/openfeature-provider/go/confidence/flag_logs_test.go +++ b/openfeature-provider/go/confidence/flag_logs_test.go @@ -52,7 +52,7 @@ func setupFlagLogsUnitTest(t *testing.T) (*fl.CapturingFlagLogger, openfeature.I resolverSupplier := wrapResolverSupplierWithMaterializations(func(ctx context.Context, logSink lr.LogSink) lr.LocalResolver { return lr.NewLocalResolverWithPoolSize(ctx, logSink, 2) }, unsupportedMatStore) - provider := NewLocalResolverProvider(resolverSupplier, stateProvider, capturingLogger, unitTestClientSecret, logger) + provider := NewLocalResolverProvider(resolverSupplier, stateProvider, capturingLogger, nil, unitTestClientSecret, logger) // Set provider and wait for ready err := openfeature.SetProviderAndWait(provider) diff --git a/openfeature-provider/go/confidence/grpc_event_sender.go b/openfeature-provider/go/confidence/grpc_event_sender.go new file mode 100644 index 00000000..c7eadc64 --- /dev/null +++ b/openfeature-provider/go/confidence/grpc_event_sender.go @@ -0,0 +1,71 @@ +package confidence + +import ( + "context" + "log/slog" + "time" + + events "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/events" + "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/wasm" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// GrpcEventSender sends events to the Confidence events backend via gRPC. +type GrpcEventSender struct { + conn *grpc.ClientConn + client events.EventsServiceClient + logger *slog.Logger +} + +var _ EventSender = (*GrpcEventSender)(nil) + +// NewGrpcEventSender creates a new gRPC event sender connecting to the given target. +func NewGrpcEventSender(target string, logger *slog.Logger, opts ...grpc.DialOption) (*GrpcEventSender, error) { + if len(opts) == 0 { + opts = []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())} + } + conn, err := grpc.NewClient(target, opts...) + if err != nil { + return nil, err + } + return &GrpcEventSender{ + conn: conn, + client: events.NewEventsServiceClient(conn), + logger: logger, + }, nil +} + +func (s *GrpcEventSender) Send(response *wasm.FlushEventsResponse, clientSecret string) { + req := &events.PublishEventsRequest{ + ClientSecret: clientSecret, + SendTime: timestamppb.Now(), + } + for _, e := range response.Events { + req.Events = append(req.Events, &events.Event{ + EventDefinition: e.EventDefinition, + Payload: e.Payload, + EventTime: e.EventTime, + }) + } + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + md := metadata.Pairs("authorization", "ClientSecret "+clientSecret) + ctx = metadata.NewOutgoingContext(ctx, md) + if _, err := s.client.PublishEvents(ctx, req); err != nil { + s.logger.Error("Failed to publish events", "error", err) + } else { + s.logger.Debug("Successfully published events", "count", len(req.Events)) + } + }() +} + +func (s *GrpcEventSender) Shutdown() { + if s.conn != nil { + s.conn.Close() + } +} diff --git a/openfeature-provider/go/confidence/integration_test.go b/openfeature-provider/go/confidence/integration_test.go index 5064aad0..befd4376 100644 --- a/openfeature-provider/go/confidence/integration_test.go +++ b/openfeature-provider/go/confidence/integration_test.go @@ -206,7 +206,7 @@ func TestIntegration_OpenFeatureResolveStickyFlagMatStoreReadAndWrite(t *testing }, matStore) // Create provider with test state - provider := NewLocalResolverProvider(resolverSupplier, stateProvider, trackingLogger, "test-secret", slog.New(slog.NewTextHandler(os.Stderr, nil))) + provider := NewLocalResolverProvider(resolverSupplier, stateProvider, trackingLogger, nil, "test-secret", slog.New(slog.NewTextHandler(os.Stderr, nil))) client := openfeature.NewClient("integration-test") @@ -337,7 +337,7 @@ func TestIntegration_OpenFeatureMaterializedSegmentCriterion(t *testing.T) { }, matStore) // Create provider with test state - provider := NewLocalResolverProvider(resolverSupplier, stateProvider, trackingLogger, SECRET, slog.New(slog.NewTextHandler(os.Stderr, nil))) + provider := NewLocalResolverProvider(resolverSupplier, stateProvider, trackingLogger, nil, SECRET, slog.New(slog.NewTextHandler(os.Stderr, nil))) client := openfeature.NewClient("integration-test-mat-seg") @@ -399,7 +399,7 @@ func TestIntegration_OpenFeatureMaterializedSegmentCriterion(t *testing.T) { }, matStore) // Create provider with test state - provider := NewLocalResolverProvider(resolverSupplier, stateProvider, trackingLogger, SECRET, slog.New(slog.NewTextHandler(os.Stderr, nil))) + provider := NewLocalResolverProvider(resolverSupplier, stateProvider, trackingLogger, nil, SECRET, slog.New(slog.NewTextHandler(os.Stderr, nil))) client := openfeature.NewClient("integration-test-mat-seg-not-in") @@ -440,7 +440,7 @@ func TestIntegration_OpenFeatureMaterializedSegmentCriterion(t *testing.T) { }, matStore) // Create provider with test state - provider := NewLocalResolverProvider(resolverSupplier, stateProvider, trackingLogger, SECRET, slog.New(slog.NewTextHandler(os.Stderr, nil))) + provider := NewLocalResolverProvider(resolverSupplier, stateProvider, trackingLogger, nil, SECRET, slog.New(slog.NewTextHandler(os.Stderr, nil))) client := openfeature.NewClient("integration-test-mat-seg-no-ctx") @@ -698,6 +698,6 @@ func createProviderWithTestState( }, matStore) // Create provider with the client secret from test state // The test state includes client secret: mkjJruAATQWjeY7foFIWfVAcBWnci2YF - provider := NewLocalResolverProvider(resolverSupplier, stateProvider, logger, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", slog.New(slog.NewTextHandler(os.Stderr, nil))) + provider := NewLocalResolverProvider(resolverSupplier, stateProvider, logger, nil, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", slog.New(slog.NewTextHandler(os.Stderr, nil))) return provider, nil } 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 d682a46986cad0cdf2690639e3da1e755bdc8c70..b56b0fd4e754733275031024c3179cc41044cace 100755 GIT binary patch delta 72817 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 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 f347cfd1..0a54233b 100644 --- a/openfeature-provider/go/confidence/internal/local_resolver/local_resolver.go +++ b/openfeature-provider/go/confidence/internal/local_resolver/local_resolver.go @@ -23,6 +23,8 @@ type LocalResolver interface { ApplyFlags(*resolver.ApplyFlagsRequest) error FlushAllLogs() error FlushAssignLogs() error + TrackEvent(*wasm.Event) error + FlushEvents() (*wasm.FlushEventsResponse, error) Close(context.Context) error } diff --git a/openfeature-provider/go/confidence/internal/local_resolver/pool.go b/openfeature-provider/go/confidence/internal/local_resolver/pool.go index ed7be424..af49b085 100644 --- a/openfeature-provider/go/confidence/internal/local_resolver/pool.go +++ b/openfeature-provider/go/confidence/internal/local_resolver/pool.go @@ -109,6 +109,32 @@ func (s *PooledResolver) FlushAssignLogs() error { }) } +// TrackEvent implements LocalResolver. +func (s *PooledResolver) TrackEvent(event *wasm.Event) error { + n := uint64(len(s.slots)) + idx := s.rr.Add(1) + for !s.slots[idx%n].rw.TryRLock() { + idx = s.rr.Add(1) + } + slot := &s.slots[idx%n] + defer slot.rw.RUnlock() + return slot.lr.TrackEvent(event) +} + +// FlushEvents implements LocalResolver. +func (s *PooledResolver) FlushEvents() (*wasm.FlushEventsResponse, error) { + combined := &wasm.FlushEventsResponse{} + err := s.maintenance(func(lr LocalResolver) error { + resp, err := lr.FlushEvents() + if err != nil { + return err + } + combined.Events = append(combined.Events, resp.Events...) + return nil + }) + return combined, err +} + func (s *PooledResolver) Close(ctx context.Context) error { return s.maintenance(func(lr LocalResolver) error { return lr.Close(ctx) diff --git a/openfeature-provider/go/confidence/internal/local_resolver/recover.go b/openfeature-provider/go/confidence/internal/local_resolver/recover.go index 85c277a2..f7fd8b04 100644 --- a/openfeature-provider/go/confidence/internal/local_resolver/recover.go +++ b/openfeature-provider/go/confidence/internal/local_resolver/recover.go @@ -125,6 +125,20 @@ func (r *RecoveringResolver) FlushAssignLogs() (err error) { return } +func (r *RecoveringResolver) TrackEvent(event *wasm.Event) (err error) { + r.withRecover("TrackEvent", &err, func(lr LocalResolver) { + err = lr.TrackEvent(event) + }) + return +} + +func (r *RecoveringResolver) FlushEvents() (resp *wasm.FlushEventsResponse, err error) { + r.withRecover("FlushEvents", &err, func(lr LocalResolver) { + resp, err = lr.FlushEvents() + }) + return +} + func (r *RecoveringResolver) Close(ctx context.Context) error { // For Close, if we panic, don't recreate during shutdown; just surface error. defer func() { diff --git a/openfeature-provider/go/confidence/internal/local_resolver/wasm.go b/openfeature-provider/go/confidence/internal/local_resolver/wasm.go index 452fa3d4..be40bd64 100644 --- a/openfeature-provider/go/confidence/internal/local_resolver/wasm.go +++ b/openfeature-provider/go/confidence/internal/local_resolver/wasm.go @@ -72,6 +72,16 @@ func (r *WasmResolver) FlushAssignLogs() error { return err } +func (r *WasmResolver) TrackEvent(event *wasm.Event) error { + return r.call("wasm_msg_guest_track_event", event, nil) +} + +func (r *WasmResolver) FlushEvents() (*wasm.FlushEventsResponse, error) { + resp := &wasm.FlushEventsResponse{} + err := r.call("wasm_msg_guest_flush_events", nil, resp) + return resp, err +} + func (r *WasmResolver) Close(ctx context.Context) error { // TODO we should call flush assigned until it doesn't flush any more r.FlushAllLogs() diff --git a/openfeature-provider/go/confidence/internal/proto/events/api.pb.go b/openfeature-provider/go/confidence/internal/proto/events/api.pb.go new file mode 100644 index 00000000..57766dc2 --- /dev/null +++ b/openfeature-provider/go/confidence/internal/proto/events/api.pb.go @@ -0,0 +1,200 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.10 +// protoc v5.29.3 +// source: confidence/events/v1/api.proto + +package events + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type PublishEventsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ClientSecret string `protobuf:"bytes,1,opt,name=client_secret,json=clientSecret,proto3" json:"client_secret,omitempty"` + Events []*Event `protobuf:"bytes,2,rep,name=events,proto3" json:"events,omitempty"` + SendTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=send_time,json=sendTime,proto3" json:"send_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PublishEventsRequest) Reset() { + *x = PublishEventsRequest{} + mi := &file_confidence_events_v1_api_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PublishEventsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PublishEventsRequest) ProtoMessage() {} + +func (x *PublishEventsRequest) ProtoReflect() protoreflect.Message { + mi := &file_confidence_events_v1_api_proto_msgTypes[0] + 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 PublishEventsRequest.ProtoReflect.Descriptor instead. +func (*PublishEventsRequest) Descriptor() ([]byte, []int) { + return file_confidence_events_v1_api_proto_rawDescGZIP(), []int{0} +} + +func (x *PublishEventsRequest) GetClientSecret() string { + if x != nil { + return x.ClientSecret + } + return "" +} + +func (x *PublishEventsRequest) GetEvents() []*Event { + if x != nil { + return x.Events + } + return nil +} + +func (x *PublishEventsRequest) GetSendTime() *timestamppb.Timestamp { + if x != nil { + return x.SendTime + } + return nil +} + +type PublishEventsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Errors []*EventError `protobuf:"bytes,1,rep,name=errors,proto3" json:"errors,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PublishEventsResponse) Reset() { + *x = PublishEventsResponse{} + mi := &file_confidence_events_v1_api_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PublishEventsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PublishEventsResponse) ProtoMessage() {} + +func (x *PublishEventsResponse) ProtoReflect() protoreflect.Message { + mi := &file_confidence_events_v1_api_proto_msgTypes[1] + 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 PublishEventsResponse.ProtoReflect.Descriptor instead. +func (*PublishEventsResponse) Descriptor() ([]byte, []int) { + return file_confidence_events_v1_api_proto_rawDescGZIP(), []int{1} +} + +func (x *PublishEventsResponse) GetErrors() []*EventError { + if x != nil { + return x.Errors + } + return nil +} + +var File_confidence_events_v1_api_proto protoreflect.FileDescriptor + +const file_confidence_events_v1_api_proto_rawDesc = "" + + "\n" + + "\x1econfidence/events/v1/api.proto\x12\x14confidence.events.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a confidence/events/v1/types.proto\"\xa9\x01\n" + + "\x14PublishEventsRequest\x12#\n" + + "\rclient_secret\x18\x01 \x01(\tR\fclientSecret\x123\n" + + "\x06events\x18\x02 \x03(\v2\x1b.confidence.events.v1.EventR\x06events\x127\n" + + "\tsend_time\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\bsendTime\"Q\n" + + "\x15PublishEventsResponse\x128\n" + + "\x06errors\x18\x01 \x03(\v2 .confidence.events.v1.EventErrorR\x06errors2{\n" + + "\rEventsService\x12j\n" + + "\rPublishEvents\x12*.confidence.events.v1.PublishEventsRequest\x1a+.confidence.events.v1.PublishEventsResponse\"\x00B\x93\x01\n" + + "$com.spotify.confidence.sdk.events.v1B\bApiProtoP\x01Z_github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/eventsb\x06proto3" + +var ( + file_confidence_events_v1_api_proto_rawDescOnce sync.Once + file_confidence_events_v1_api_proto_rawDescData []byte +) + +func file_confidence_events_v1_api_proto_rawDescGZIP() []byte { + file_confidence_events_v1_api_proto_rawDescOnce.Do(func() { + file_confidence_events_v1_api_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_confidence_events_v1_api_proto_rawDesc), len(file_confidence_events_v1_api_proto_rawDesc))) + }) + return file_confidence_events_v1_api_proto_rawDescData +} + +var file_confidence_events_v1_api_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_confidence_events_v1_api_proto_goTypes = []any{ + (*PublishEventsRequest)(nil), // 0: confidence.events.v1.PublishEventsRequest + (*PublishEventsResponse)(nil), // 1: confidence.events.v1.PublishEventsResponse + (*Event)(nil), // 2: confidence.events.v1.Event + (*timestamppb.Timestamp)(nil), // 3: google.protobuf.Timestamp + (*EventError)(nil), // 4: confidence.events.v1.EventError +} +var file_confidence_events_v1_api_proto_depIdxs = []int32{ + 2, // 0: confidence.events.v1.PublishEventsRequest.events:type_name -> confidence.events.v1.Event + 3, // 1: confidence.events.v1.PublishEventsRequest.send_time:type_name -> google.protobuf.Timestamp + 4, // 2: confidence.events.v1.PublishEventsResponse.errors:type_name -> confidence.events.v1.EventError + 0, // 3: confidence.events.v1.EventsService.PublishEvents:input_type -> confidence.events.v1.PublishEventsRequest + 1, // 4: confidence.events.v1.EventsService.PublishEvents:output_type -> confidence.events.v1.PublishEventsResponse + 4, // [4:5] is the sub-list for method output_type + 3, // [3:4] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_confidence_events_v1_api_proto_init() } +func file_confidence_events_v1_api_proto_init() { + if File_confidence_events_v1_api_proto != nil { + return + } + file_confidence_events_v1_types_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_confidence_events_v1_api_proto_rawDesc), len(file_confidence_events_v1_api_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_confidence_events_v1_api_proto_goTypes, + DependencyIndexes: file_confidence_events_v1_api_proto_depIdxs, + MessageInfos: file_confidence_events_v1_api_proto_msgTypes, + }.Build() + File_confidence_events_v1_api_proto = out.File + file_confidence_events_v1_api_proto_goTypes = nil + file_confidence_events_v1_api_proto_depIdxs = nil +} diff --git a/openfeature-provider/go/confidence/internal/proto/events/api_grpc.pb.go b/openfeature-provider/go/confidence/internal/proto/events/api_grpc.pb.go new file mode 100644 index 00000000..ec217cac --- /dev/null +++ b/openfeature-provider/go/confidence/internal/proto/events/api_grpc.pb.go @@ -0,0 +1,121 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v5.29.3 +// source: confidence/events/v1/api.proto + +package events + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + EventsService_PublishEvents_FullMethodName = "/confidence.events.v1.EventsService/PublishEvents" +) + +// EventsServiceClient is the client API for EventsService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type EventsServiceClient interface { + PublishEvents(ctx context.Context, in *PublishEventsRequest, opts ...grpc.CallOption) (*PublishEventsResponse, error) +} + +type eventsServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewEventsServiceClient(cc grpc.ClientConnInterface) EventsServiceClient { + return &eventsServiceClient{cc} +} + +func (c *eventsServiceClient) PublishEvents(ctx context.Context, in *PublishEventsRequest, opts ...grpc.CallOption) (*PublishEventsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(PublishEventsResponse) + err := c.cc.Invoke(ctx, EventsService_PublishEvents_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// EventsServiceServer is the server API for EventsService service. +// All implementations must embed UnimplementedEventsServiceServer +// for forward compatibility. +type EventsServiceServer interface { + PublishEvents(context.Context, *PublishEventsRequest) (*PublishEventsResponse, error) + mustEmbedUnimplementedEventsServiceServer() +} + +// UnimplementedEventsServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedEventsServiceServer struct{} + +func (UnimplementedEventsServiceServer) PublishEvents(context.Context, *PublishEventsRequest) (*PublishEventsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method PublishEvents not implemented") +} +func (UnimplementedEventsServiceServer) mustEmbedUnimplementedEventsServiceServer() {} +func (UnimplementedEventsServiceServer) testEmbeddedByValue() {} + +// UnsafeEventsServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to EventsServiceServer will +// result in compilation errors. +type UnsafeEventsServiceServer interface { + mustEmbedUnimplementedEventsServiceServer() +} + +func RegisterEventsServiceServer(s grpc.ServiceRegistrar, srv EventsServiceServer) { + // If the following call pancis, it indicates UnimplementedEventsServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&EventsService_ServiceDesc, srv) +} + +func _EventsService_PublishEvents_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PublishEventsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(EventsServiceServer).PublishEvents(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: EventsService_PublishEvents_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(EventsServiceServer).PublishEvents(ctx, req.(*PublishEventsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// EventsService_ServiceDesc is the grpc.ServiceDesc for EventsService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var EventsService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "confidence.events.v1.EventsService", + HandlerType: (*EventsServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "PublishEvents", + Handler: _EventsService_PublishEvents_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "confidence/events/v1/api.proto", +} diff --git a/openfeature-provider/go/confidence/internal/proto/events/types.pb.go b/openfeature-provider/go/confidence/internal/proto/events/types.pb.go new file mode 100644 index 00000000..1d38e8a3 --- /dev/null +++ b/openfeature-provider/go/confidence/internal/proto/events/types.pb.go @@ -0,0 +1,272 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.10 +// protoc v5.29.3 +// source: confidence/events/v1/types.proto + +package events + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type EventError_Reason int32 + +const ( + EventError_REASON_UNSPECIFIED EventError_Reason = 0 + EventError_EVENT_DEFINITION_NOT_FOUND EventError_Reason = 1 + EventError_EVENT_SCHEMA_VALIDATION_FAILED EventError_Reason = 2 +) + +// Enum value maps for EventError_Reason. +var ( + EventError_Reason_name = map[int32]string{ + 0: "REASON_UNSPECIFIED", + 1: "EVENT_DEFINITION_NOT_FOUND", + 2: "EVENT_SCHEMA_VALIDATION_FAILED", + } + EventError_Reason_value = map[string]int32{ + "REASON_UNSPECIFIED": 0, + "EVENT_DEFINITION_NOT_FOUND": 1, + "EVENT_SCHEMA_VALIDATION_FAILED": 2, + } +) + +func (x EventError_Reason) Enum() *EventError_Reason { + p := new(EventError_Reason) + *p = x + return p +} + +func (x EventError_Reason) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (EventError_Reason) Descriptor() protoreflect.EnumDescriptor { + return file_confidence_events_v1_types_proto_enumTypes[0].Descriptor() +} + +func (EventError_Reason) Type() protoreflect.EnumType { + return &file_confidence_events_v1_types_proto_enumTypes[0] +} + +func (x EventError_Reason) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use EventError_Reason.Descriptor instead. +func (EventError_Reason) EnumDescriptor() ([]byte, []int) { + return file_confidence_events_v1_types_proto_rawDescGZIP(), []int{1, 0} +} + +type Event struct { + state protoimpl.MessageState `protogen:"open.v1"` + EventDefinition string `protobuf:"bytes,1,opt,name=event_definition,json=eventDefinition,proto3" json:"event_definition,omitempty"` + Payload *structpb.Struct `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` + EventTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=event_time,json=eventTime,proto3" json:"event_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Event) Reset() { + *x = Event{} + mi := &file_confidence_events_v1_types_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Event) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Event) ProtoMessage() {} + +func (x *Event) ProtoReflect() protoreflect.Message { + mi := &file_confidence_events_v1_types_proto_msgTypes[0] + 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 Event.ProtoReflect.Descriptor instead. +func (*Event) Descriptor() ([]byte, []int) { + return file_confidence_events_v1_types_proto_rawDescGZIP(), []int{0} +} + +func (x *Event) GetEventDefinition() string { + if x != nil { + return x.EventDefinition + } + return "" +} + +func (x *Event) GetPayload() *structpb.Struct { + if x != nil { + return x.Payload + } + return nil +} + +func (x *Event) GetEventTime() *timestamppb.Timestamp { + if x != nil { + return x.EventTime + } + return nil +} + +type EventError struct { + state protoimpl.MessageState `protogen:"open.v1"` + Index int32 `protobuf:"varint,1,opt,name=index,proto3" json:"index,omitempty"` + Reason EventError_Reason `protobuf:"varint,2,opt,name=reason,proto3,enum=confidence.events.v1.EventError_Reason" json:"reason,omitempty"` + Message string `protobuf:"bytes,3,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EventError) Reset() { + *x = EventError{} + mi := &file_confidence_events_v1_types_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EventError) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EventError) ProtoMessage() {} + +func (x *EventError) ProtoReflect() protoreflect.Message { + mi := &file_confidence_events_v1_types_proto_msgTypes[1] + 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 EventError.ProtoReflect.Descriptor instead. +func (*EventError) Descriptor() ([]byte, []int) { + return file_confidence_events_v1_types_proto_rawDescGZIP(), []int{1} +} + +func (x *EventError) GetIndex() int32 { + if x != nil { + return x.Index + } + return 0 +} + +func (x *EventError) GetReason() EventError_Reason { + if x != nil { + return x.Reason + } + return EventError_REASON_UNSPECIFIED +} + +func (x *EventError) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +var File_confidence_events_v1_types_proto protoreflect.FileDescriptor + +const file_confidence_events_v1_types_proto_rawDesc = "" + + "\n" + + " confidence/events/v1/types.proto\x12\x14confidence.events.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xa0\x01\n" + + "\x05Event\x12)\n" + + "\x10event_definition\x18\x01 \x01(\tR\x0feventDefinition\x121\n" + + "\apayload\x18\x02 \x01(\v2\x17.google.protobuf.StructR\apayload\x129\n" + + "\n" + + "event_time\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\teventTime\"\xe3\x01\n" + + "\n" + + "EventError\x12\x14\n" + + "\x05index\x18\x01 \x01(\x05R\x05index\x12?\n" + + "\x06reason\x18\x02 \x01(\x0e2'.confidence.events.v1.EventError.ReasonR\x06reason\x12\x18\n" + + "\amessage\x18\x03 \x01(\tR\amessage\"d\n" + + "\x06Reason\x12\x16\n" + + "\x12REASON_UNSPECIFIED\x10\x00\x12\x1e\n" + + "\x1aEVENT_DEFINITION_NOT_FOUND\x10\x01\x12\"\n" + + "\x1eEVENT_SCHEMA_VALIDATION_FAILED\x10\x02B\x95\x01\n" + + "$com.spotify.confidence.sdk.events.v1B\n" + + "TypesProtoP\x01Z_github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/eventsb\x06proto3" + +var ( + file_confidence_events_v1_types_proto_rawDescOnce sync.Once + file_confidence_events_v1_types_proto_rawDescData []byte +) + +func file_confidence_events_v1_types_proto_rawDescGZIP() []byte { + file_confidence_events_v1_types_proto_rawDescOnce.Do(func() { + file_confidence_events_v1_types_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_confidence_events_v1_types_proto_rawDesc), len(file_confidence_events_v1_types_proto_rawDesc))) + }) + return file_confidence_events_v1_types_proto_rawDescData +} + +var file_confidence_events_v1_types_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_confidence_events_v1_types_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_confidence_events_v1_types_proto_goTypes = []any{ + (EventError_Reason)(0), // 0: confidence.events.v1.EventError.Reason + (*Event)(nil), // 1: confidence.events.v1.Event + (*EventError)(nil), // 2: confidence.events.v1.EventError + (*structpb.Struct)(nil), // 3: google.protobuf.Struct + (*timestamppb.Timestamp)(nil), // 4: google.protobuf.Timestamp +} +var file_confidence_events_v1_types_proto_depIdxs = []int32{ + 3, // 0: confidence.events.v1.Event.payload:type_name -> google.protobuf.Struct + 4, // 1: confidence.events.v1.Event.event_time:type_name -> google.protobuf.Timestamp + 0, // 2: confidence.events.v1.EventError.reason:type_name -> confidence.events.v1.EventError.Reason + 3, // [3:3] is the sub-list for method output_type + 3, // [3:3] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_confidence_events_v1_types_proto_init() } +func file_confidence_events_v1_types_proto_init() { + if File_confidence_events_v1_types_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_confidence_events_v1_types_proto_rawDesc), len(file_confidence_events_v1_types_proto_rawDesc)), + NumEnums: 1, + NumMessages: 2, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_confidence_events_v1_types_proto_goTypes, + DependencyIndexes: file_confidence_events_v1_types_proto_depIdxs, + EnumInfos: file_confidence_events_v1_types_proto_enumTypes, + MessageInfos: file_confidence_events_v1_types_proto_msgTypes, + }.Build() + File_confidence_events_v1_types_proto = out.File + file_confidence_events_v1_types_proto_goTypes = nil + file_confidence_events_v1_types_proto_depIdxs = nil +} diff --git a/openfeature-provider/go/confidence/internal/proto/wasm/messages.pb.go b/openfeature-provider/go/confidence/internal/proto/wasm/messages.pb.go index 25ddc2d4..34a5caf9 100644 --- a/openfeature-provider/go/confidence/internal/proto/wasm/messages.pb.go +++ b/openfeature-provider/go/confidence/internal/proto/wasm/messages.pb.go @@ -10,6 +10,8 @@ import ( resolver "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolver" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" unsafe "unsafe" @@ -244,11 +246,115 @@ func (*Response_Data) isResponse_Result() {} func (*Response_Error) isResponse_Result() {} +type Event struct { + state protoimpl.MessageState `protogen:"open.v1"` + EventDefinition string `protobuf:"bytes,1,opt,name=event_definition,json=eventDefinition,proto3" json:"event_definition,omitempty"` + Payload *structpb.Struct `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` + EventTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=event_time,json=eventTime,proto3" json:"event_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Event) Reset() { + *x = Event{} + mi := &file_confidence_wasm_messages_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Event) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Event) ProtoMessage() {} + +func (x *Event) ProtoReflect() protoreflect.Message { + mi := &file_confidence_wasm_messages_proto_msgTypes[4] + 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 Event.ProtoReflect.Descriptor instead. +func (*Event) Descriptor() ([]byte, []int) { + return file_confidence_wasm_messages_proto_rawDescGZIP(), []int{4} +} + +func (x *Event) GetEventDefinition() string { + if x != nil { + return x.EventDefinition + } + return "" +} + +func (x *Event) GetPayload() *structpb.Struct { + if x != nil { + return x.Payload + } + return nil +} + +func (x *Event) GetEventTime() *timestamppb.Timestamp { + if x != nil { + return x.EventTime + } + return nil +} + +type FlushEventsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Events []*Event `protobuf:"bytes,2,rep,name=events,proto3" json:"events,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FlushEventsResponse) Reset() { + *x = FlushEventsResponse{} + mi := &file_confidence_wasm_messages_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FlushEventsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FlushEventsResponse) ProtoMessage() {} + +func (x *FlushEventsResponse) ProtoReflect() protoreflect.Message { + mi := &file_confidence_wasm_messages_proto_msgTypes[5] + 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 FlushEventsResponse.ProtoReflect.Descriptor instead. +func (*FlushEventsResponse) Descriptor() ([]byte, []int) { + return file_confidence_wasm_messages_proto_rawDescGZIP(), []int{5} +} + +func (x *FlushEventsResponse) GetEvents() []*Event { + if x != nil { + return x.Events + } + return nil +} + var File_confidence_wasm_messages_proto protoreflect.FileDescriptor const file_confidence_wasm_messages_proto_rawDesc = "" + "\n" + - "\x1econfidence/wasm/messages.proto\x12\x0fconfidence.wasm\x1a(confidence/flags/resolver/v1/types.proto\"\x06\n" + + "\x1econfidence/wasm/messages.proto\x12\x0fconfidence.wasm\x1a(confidence/flags/resolver/v1/types.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x06\n" + "\x04Void\"\x83\x01\n" + "\x17SetResolverStateRequest\x12\x14\n" + "\x05state\x18\x01 \x01(\fR\x05state\x12\x1d\n" + @@ -260,7 +366,14 @@ const file_confidence_wasm_messages_proto_rawDesc = "" + "\bResponse\x12\x14\n" + "\x04data\x18\x01 \x01(\fH\x00R\x04data\x12\x16\n" + "\x05error\x18\x02 \x01(\tH\x00R\x05errorB\b\n" + - "\x06resultB\x8c\x01\n" + + "\x06result\"\xa0\x01\n" + + "\x05Event\x12)\n" + + "\x10event_definition\x18\x01 \x01(\tR\x0feventDefinition\x121\n" + + "\apayload\x18\x02 \x01(\v2\x17.google.protobuf.StructR\apayload\x129\n" + + "\n" + + "event_time\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\teventTime\"E\n" + + "\x13FlushEventsResponse\x12.\n" + + "\x06events\x18\x02 \x03(\v2\x16.confidence.wasm.EventR\x06eventsB\x8c\x01\n" + "\x1fcom.spotify.confidence.sdk.wasmB\bMessagesP\x00Z]github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/wasmb\x06proto3" var ( @@ -275,21 +388,28 @@ func file_confidence_wasm_messages_proto_rawDescGZIP() []byte { return file_confidence_wasm_messages_proto_rawDescData } -var file_confidence_wasm_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_confidence_wasm_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 6) var file_confidence_wasm_messages_proto_goTypes = []any{ (*Void)(nil), // 0: confidence.wasm.Void (*SetResolverStateRequest)(nil), // 1: confidence.wasm.SetResolverStateRequest (*Request)(nil), // 2: confidence.wasm.Request (*Response)(nil), // 3: confidence.wasm.Response - (*resolver.Sdk)(nil), // 4: confidence.flags.resolver.v1.Sdk + (*Event)(nil), // 4: confidence.wasm.Event + (*FlushEventsResponse)(nil), // 5: confidence.wasm.FlushEventsResponse + (*resolver.Sdk)(nil), // 6: confidence.flags.resolver.v1.Sdk + (*structpb.Struct)(nil), // 7: google.protobuf.Struct + (*timestamppb.Timestamp)(nil), // 8: google.protobuf.Timestamp } var file_confidence_wasm_messages_proto_depIdxs = []int32{ - 4, // 0: confidence.wasm.SetResolverStateRequest.sdk:type_name -> confidence.flags.resolver.v1.Sdk - 1, // [1:1] is the sub-list for method output_type - 1, // [1:1] is the sub-list for method input_type - 1, // [1:1] is the sub-list for extension type_name - 1, // [1:1] is the sub-list for extension extendee - 0, // [0:1] is the sub-list for field type_name + 6, // 0: confidence.wasm.SetResolverStateRequest.sdk:type_name -> confidence.flags.resolver.v1.Sdk + 7, // 1: confidence.wasm.Event.payload:type_name -> google.protobuf.Struct + 8, // 2: confidence.wasm.Event.event_time:type_name -> google.protobuf.Timestamp + 4, // 3: confidence.wasm.FlushEventsResponse.events:type_name -> confidence.wasm.Event + 4, // [4:4] is the sub-list for method output_type + 4, // [4:4] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name } func init() { file_confidence_wasm_messages_proto_init() } @@ -307,7 +427,7 @@ func file_confidence_wasm_messages_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_confidence_wasm_messages_proto_rawDesc), len(file_confidence_wasm_messages_proto_rawDesc)), NumEnums: 0, - NumMessages: 4, + NumMessages: 6, NumExtensions: 0, NumServices: 0, }, diff --git a/openfeature-provider/go/confidence/internal/testutil/helpers.go b/openfeature-provider/go/confidence/internal/testutil/helpers.go index 52ac8044..e039dd79 100644 --- a/openfeature-provider/go/confidence/internal/testutil/helpers.go +++ b/openfeature-provider/go/confidence/internal/testutil/helpers.go @@ -429,6 +429,10 @@ func (m *MockedLocalResolver) ResolveProcess(*wasm.ResolveProcessRequest) (*wasm } func (m MockedLocalResolver) SetResolverState(*wasm.SetResolverStateRequest) error { return nil } func (m MockedLocalResolver) ApplyFlags(*resolver.ApplyFlagsRequest) error { return nil } +func (m MockedLocalResolver) TrackEvent(*wasm.Event) error { return nil } +func (m MockedLocalResolver) FlushEvents() (*wasm.FlushEventsResponse, error) { + return &wasm.FlushEventsResponse{}, nil +} func MustJSONToProto(jsonString string) *structpb.Value { var v structpb.Value diff --git a/openfeature-provider/go/confidence/materialization.go b/openfeature-provider/go/confidence/materialization.go index 4b2e1ebd..90b1acf7 100644 --- a/openfeature-provider/go/confidence/materialization.go +++ b/openfeature-provider/go/confidence/materialization.go @@ -185,6 +185,14 @@ func (m *materializationSupportedResolver) SetResolverState(request *wasm.SetRes return m.current.SetResolverState(request) } +func (m *materializationSupportedResolver) TrackEvent(event *wasm.Event) error { + return m.current.TrackEvent(event) +} + +func (m *materializationSupportedResolver) FlushEvents() (*wasm.FlushEventsResponse, error) { + return m.current.FlushEvents() +} + func (m *materializationSupportedResolver) Close(ctx context.Context) error { return m.current.Close(ctx) } diff --git a/openfeature-provider/go/confidence/provider.go b/openfeature-provider/go/confidence/provider.go index 659c6a65..7a9ec4cb 100644 --- a/openfeature-provider/go/confidence/provider.go +++ b/openfeature-provider/go/confidence/provider.go @@ -16,6 +16,7 @@ import ( resolvertypes "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolver" "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/wasm" "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" ) const ( @@ -54,6 +55,7 @@ type LocalResolverProvider struct { resolver lr.LocalResolver stateProvider StateProvider flagLogger FlagLogger + eventSender EventSender clientSecret string logger *slog.Logger cancelFunc context.CancelFunc @@ -67,6 +69,7 @@ type LocalResolverProvider struct { var ( _ openfeature.FeatureProvider = (*LocalResolverProvider)(nil) _ openfeature.StateHandler = (*LocalResolverProvider)(nil) + _ openfeature.Tracker = (*LocalResolverProvider)(nil) ) // NewLocalResolverProvider creates a new LocalResolverProvider @@ -74,6 +77,7 @@ func NewLocalResolverProvider( resolverSupplier LocalResolverSupplier, stateProvider StateProvider, flagLogger FlagLogger, + eventSender EventSender, clientSecret string, logger *slog.Logger, opts ...Option, @@ -105,6 +109,7 @@ func NewLocalResolverProvider( resolverSupplier: resolverSupplier, stateProvider: stateProvider, flagLogger: flagLogger, + eventSender: eventSender, clientSecret: clientSecret, logger: logger, statePollInterval: statePollInterval, @@ -363,6 +368,33 @@ func (p *LocalResolverProvider) ApplyFlags( return p.resolver.ApplyFlags(request) } +// Track implements the openfeature.Tracker interface for recording business events. +func (p *LocalResolverProvider) Track( + ctx context.Context, + trackingEventName string, + evalCtx openfeature.EvaluationContext, + details openfeature.TrackingEventDetails, +) { + if p.resolver == nil { + return + } + + processedCtx := processTargetingKey(evalCtx.Attributes()) + protoCtx, err := flattenedContextToProto(processedCtx) + if err != nil { + p.logger.Error("Failed to convert context for track event", "error", err) + return + } + + if err := p.resolver.TrackEvent(&wasm.Event{ + EventDefinition: "eventDefinitions/" + trackingEventName, + Payload: protoCtx, + EventTime: timestamppb.Now(), + }); err != nil { + p.logger.Error("Failed to track event", "event", trackingEventName, "error", err) + } +} + // Hooks returns provider hooks (none for this implementation) func (p *LocalResolverProvider) Hooks() []openfeature.Hook { return []openfeature.Hook{} @@ -470,6 +502,14 @@ func (p *LocalResolverProvider) Shutdown() { } } + // Shutdown event sender + if p.eventSender != nil { + p.eventSender.Shutdown() + if p.logger != nil { + p.logger.Debug("Shut down event sender") + } + } + if p.logger != nil { p.logger.Info("Provider has been shut down") } @@ -538,6 +578,11 @@ func (p *LocalResolverProvider) startScheduledTasks(parentCtx context.Context) { if err := p.resolver.FlushAllLogs(); err != nil { p.logger.Error("Failed to flush all logs", "error", err) } + if resp, err := p.resolver.FlushEvents(); err != nil { + p.logger.Error("Failed to flush events", "error", err) + } else if len(resp.GetEvents()) > 0 && p.eventSender != nil { + p.eventSender.Send(resp, p.clientSecret) + } case <-assignTicker.C: if err := p.resolver.FlushAssignLogs(); err != nil { p.logger.Error("Failed to flush assign logs", "error", err) @@ -549,6 +594,7 @@ func (p *LocalResolverProvider) startScheduledTasks(parentCtx context.Context) { }() } + // getStatePollInterval gets the state poll interval from environment or returns default // Deprecated: Use ProviderConfig.StatePollInterval instead. Environment variable support will be removed in a future version. func getStatePollInterval(logger *slog.Logger) time.Duration { diff --git a/openfeature-provider/go/confidence/provider_builder.go b/openfeature-provider/go/confidence/provider_builder.go index 7760f5df..8dc0c59e 100644 --- a/openfeature-provider/go/confidence/provider_builder.go +++ b/openfeature-provider/go/confidence/provider_builder.go @@ -82,12 +82,18 @@ func NewProvider(ctx context.Context, config ProviderConfig) (*LocalResolverProv materializationStore = newRemoteMaterializationStore(resolverv1.NewInternalFlagLoggerServiceClient(conn), config.ClientSecret) } + // Create event sender using the same gRPC connection + eventSender, err := NewGrpcEventSender(target, logger, opts...) + if err != nil { + return nil, fmt.Errorf("failed to create event sender: %w", err) + } + resolverSupplier := func(ctx context.Context, logSink lr.LogSink) lr.LocalResolver { return lr.NewLocalResolverWithPoolSize(ctx, logSink, config.ResolverPoolSize) } resolverSupplierWithMaterialization := wrapResolverSupplierWithMaterializations(resolverSupplier, materializationStore) providerOpts := buildProviderOptions(config.StatePollInterval, config.LogPollInterval) - provider := NewLocalResolverProvider(resolverSupplierWithMaterialization, stateProvider, flagLogger, config.ClientSecret, logger, providerOpts...) + provider := NewLocalResolverProvider(resolverSupplierWithMaterialization, stateProvider, flagLogger, eventSender, config.ClientSecret, logger, providerOpts...) return provider, nil } @@ -116,7 +122,7 @@ func NewProviderForTest(ctx context.Context, config ProviderTestConfig) (*LocalR } resolverSupplierWithMaterialization := wrapResolverSupplierWithMaterializations(resolverSupplier, materializationStore) providerOpts := buildProviderOptions(config.StatePollInterval, config.LogPollInterval) - provider := NewLocalResolverProvider(resolverSupplierWithMaterialization, config.StateProvider, config.FlagLogger, config.ClientSecret, logger, providerOpts...) + provider := NewLocalResolverProvider(resolverSupplierWithMaterialization, config.StateProvider, config.FlagLogger, nil, config.ClientSecret, logger, providerOpts...) return provider, nil } diff --git a/openfeature-provider/go/confidence/provider_resolve_test.go b/openfeature-provider/go/confidence/provider_resolve_test.go index 6541f7ea..80529e81 100644 --- a/openfeature-provider/go/confidence/provider_resolve_test.go +++ b/openfeature-provider/go/confidence/provider_resolve_test.go @@ -43,7 +43,7 @@ func TestLocalResolverProvider_ReturnsDefaultOnError(t *testing.T) { return lr.NewLocalResolverWithPoolSize(ctx, logSink, 2) }, unsupportedMatStore) // Use different client secret that won't match - openfeature.SetProviderAndWait(NewLocalResolverProvider(resolverSupplier, stateProvider, mockFlagLogger, "test-secret", slog.New(slog.NewTextHandler(os.Stderr, nil)))) + openfeature.SetProviderAndWait(NewLocalResolverProvider(resolverSupplier, stateProvider, mockFlagLogger, nil, "test-secret", slog.New(slog.NewTextHandler(os.Stderr, nil)))) client := openfeature.NewClient("test-client") evalCtx := openfeature.NewTargetlessEvaluationContext(map[string]interface{}{ @@ -90,7 +90,7 @@ func TestLocalResolverProvider_ReturnsCorrectValue(t *testing.T) { return lr.NewLocalResolverWithPoolSize(ctx, logSink, 2) }, unsupportedMatStore) // Use the correct client secret from test data - openfeature.SetProviderAndWait(NewLocalResolverProvider(resolverSupplier, stateProvider, mockFlagLogger, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", slog.New(slog.NewTextHandler(os.Stderr, nil)))) + openfeature.SetProviderAndWait(NewLocalResolverProvider(resolverSupplier, stateProvider, mockFlagLogger, nil, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", slog.New(slog.NewTextHandler(os.Stderr, nil)))) client := openfeature.NewClient("test-client") evalCtx := openfeature.NewTargetlessEvaluationContext(map[string]interface{}{ @@ -173,7 +173,7 @@ func TestLocalResolverProvider_PathNotFound(t *testing.T) { return lr.NewLocalResolverWithPoolSize(ctx, logSink, 2) }, unsupportedMatStore) // Use the correct client secret from test data - openfeature.SetProviderAndWait(NewLocalResolverProvider(resolverSupplier, stateProvider, mockFlagLogger, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", slog.New(slog.NewTextHandler(os.Stderr, nil)))) + openfeature.SetProviderAndWait(NewLocalResolverProvider(resolverSupplier, stateProvider, mockFlagLogger, nil, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", slog.New(slog.NewTextHandler(os.Stderr, nil)))) client := openfeature.NewClient("test-client") evalCtx := openfeature.NewTargetlessEvaluationContext(map[string]interface{}{ @@ -240,7 +240,7 @@ func TestLocalResolverProvider_MissingMaterializations(t *testing.T) { resolverSupplier := wrapResolverSupplierWithMaterializations(func(ctx context.Context, logSink lr.LogSink) lr.LocalResolver { return lr.NewLocalResolverWithPoolSize(ctx, logSink, 2) }, unsupportedMatStore) - openfeature.SetProviderAndWait(NewLocalResolverProvider(resolverSupplier, stateProvider, mockFlagLogger, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", slog.New(slog.NewTextHandler(os.Stderr, nil)))) + openfeature.SetProviderAndWait(NewLocalResolverProvider(resolverSupplier, stateProvider, mockFlagLogger, nil, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", slog.New(slog.NewTextHandler(os.Stderr, nil)))) client := openfeature.NewClient("test-client") evalCtx := openfeature.NewTargetlessEvaluationContext(map[string]interface{}{ @@ -281,7 +281,7 @@ func TestLocalResolverProvider_MissingMaterializations(t *testing.T) { resolverSupplier := wrapResolverSupplierWithMaterializations(func(ctx context.Context, logSink lr.LogSink) lr.LocalResolver { return lr.NewLocalResolverWithPoolSize(ctx, logSink, 2) }, unsupportedMatStore) - openfeature.SetProviderAndWait(NewLocalResolverProvider(resolverSupplier, stateProvider, mockFlagLogger, "test-secret", slog.New(slog.NewTextHandler(os.Stderr, nil)))) + openfeature.SetProviderAndWait(NewLocalResolverProvider(resolverSupplier, stateProvider, mockFlagLogger, nil, "test-secret", slog.New(slog.NewTextHandler(os.Stderr, nil)))) client := openfeature.NewClient("test-client") evalCtx := openfeature.NewTargetlessEvaluationContext(map[string]interface{}{ diff --git a/openfeature-provider/go/confidence/provider_test.go b/openfeature-provider/go/confidence/provider_test.go index 26777983..2fe870d9 100644 --- a/openfeature-provider/go/confidence/provider_test.go +++ b/openfeature-provider/go/confidence/provider_test.go @@ -15,7 +15,7 @@ import ( ) func TestNewLocalResolverProvider(t *testing.T) { - provider := NewLocalResolverProvider(nil, nil, nil, "test-secret", nil) + provider := NewLocalResolverProvider(nil, nil, nil, nil, "test-secret", nil) if provider == nil { t.Fatal("Expected provider to be created, got nil") @@ -26,7 +26,7 @@ func TestNewLocalResolverProvider(t *testing.T) { } func TestLocalResolverProvider_Metadata(t *testing.T) { - provider := NewLocalResolverProvider(nil, nil, nil, "secret", nil) + provider := NewLocalResolverProvider(nil, nil, nil, nil, "secret", nil) metadata := provider.Metadata() if metadata.Name != "confidence-sdk-go-local" { @@ -35,7 +35,7 @@ func TestLocalResolverProvider_Metadata(t *testing.T) { } func TestLocalResolverProvider_Hooks(t *testing.T) { - provider := NewLocalResolverProvider(nil, nil, nil, "secret", nil) + provider := NewLocalResolverProvider(nil, nil, nil, nil, "secret", nil) hooks := provider.Hooks() if hooks == nil { @@ -429,7 +429,7 @@ func TestFlattenedContextToProto_InvalidValue(t *testing.T) { } func TestLocalResolverProvider_Shutdown(t *testing.T) { - provider := NewLocalResolverProvider(nil, nil, nil, "secret", nil) + provider := NewLocalResolverProvider(nil, nil, nil, nil, "secret", nil) provider.Shutdown() // Verify the method can be called without panicking even with nil components @@ -437,7 +437,7 @@ func TestLocalResolverProvider_Shutdown(t *testing.T) { } func TestLocalResolverProvider_ShutdownWithCancelFunc(t *testing.T) { - provider := NewLocalResolverProvider(nil, nil, nil, "secret", nil) + provider := NewLocalResolverProvider(nil, nil, nil, nil, "secret", nil) // Simulate Init having been called by setting cancelFunc cancelCalled := false @@ -503,12 +503,18 @@ func (m *mockResolverAPIForInit) ApplyFlags(request *resolver.ApplyFlagsRequest) return nil } +func (m *mockResolverAPIForInit) TrackEvent(event *wasm.Event) error { return nil } +func (m *mockResolverAPIForInit) FlushEvents() (*wasm.FlushEventsResponse, error) { + return &wasm.FlushEventsResponse{}, nil +} + // TestLocalResolverProvider_Init_NilStateProvider verifies Init fails when stateProvider is nil func TestLocalResolverProvider_Init_NilStateProvider(t *testing.T) { provider := NewLocalResolverProvider( mockResolverSupplier, nil, // nil state provider &tu.MockFlagLogger{}, + nil, "secret", nil, ) @@ -528,6 +534,7 @@ func TestLocalResolverProvider_Init_NilResolverAPI(t *testing.T) { nil, // nil resolver API &tu.StateProviderMock{}, &tu.MockFlagLogger{}, + nil, "secret", nil, ) @@ -547,6 +554,7 @@ func TestLocalResolverProvider_Init_NilFlagLogger(t *testing.T) { mockResolverSupplier, &tu.StateProviderMock{}, nil, // nil flag logger + nil, "secret", nil, ) @@ -574,6 +582,7 @@ func TestLocalResolverProvider_Init_StateProviderError(t *testing.T) { mockResolverSupplier, mockStateProvider, mockFlagLogger, + nil, "secret", nil, ) @@ -604,6 +613,7 @@ func TestLocalResolverProvider_Init_EmptyAccountID(t *testing.T) { func(ctx context.Context, ls lr.LogSink) lr.LocalResolver { return mockResolverAPI }, mockStateProvider, mockFlagLogger, + nil, "secret", nil, ) @@ -641,6 +651,7 @@ func TestLocalResolverProvider_Init_UpdateStateError(t *testing.T) { mockResolverSupplier, mockStateProvider, mockFlagLogger, + nil, "secret", nil, ) @@ -685,6 +696,7 @@ func TestLocalResolverProvider_Init_Success(t *testing.T) { mockResolverSupplier, mockStateProvider, mockFlagLogger, + nil, "secret", nil, ) diff --git a/openfeature-provider/go/scripts/generate_proto.sh b/openfeature-provider/go/scripts/generate_proto.sh index 58a5de19..2bf88a21 100755 --- a/openfeature-provider/go/scripts/generate_proto.sh +++ b/openfeature-provider/go/scripts/generate_proto.sh @@ -35,6 +35,7 @@ mkdir -p confidence/internal/proto/resolverinternal mkdir -p confidence/internal/proto/admin mkdir -p confidence/internal/proto/types mkdir -p confidence/internal/proto/wasm +mkdir -p confidence/internal/proto/events protoc --proto_path=../proto \ --go_out=confidence/internal/proto \ @@ -48,7 +49,9 @@ protoc --proto_path=../proto \ confidence/flags/resolver/v1/internal_api.proto \ confidence/flags/admin/v1/resolver.proto \ confidence/wasm/wasm_api.proto \ - confidence/wasm/messages.proto + confidence/wasm/messages.proto \ + confidence/events/v1/types.proto \ + confidence/events/v1/api.proto echo "Protobuf generation complete!" echo "Generated files:" diff --git a/openfeature-provider/java/pom.xml b/openfeature-provider/java/pom.xml index 5d1240ad..76de3d55 100644 --- a/openfeature-provider/java/pom.xml +++ b/openfeature-provider/java/pom.xml @@ -246,6 +246,7 @@ confidence/flags/resolver/v1/**/*.proto confidence/flags/types/v1/**/*.proto confidence/flags/admin/v1/**/*.proto + confidence/events/v1/**/*.proto confidence/wasm/*.proto diff --git a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/GrpcEventSender.java b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/GrpcEventSender.java new file mode 100644 index 00000000..20c353fd --- /dev/null +++ b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/GrpcEventSender.java @@ -0,0 +1,121 @@ +package com.spotify.confidence.sdk; + +import static com.spotify.confidence.sdk.GrpcUtil.createConfidenceChannel; + +import com.google.protobuf.Timestamp; +import com.spotify.confidence.sdk.events.v1.EventsServiceGrpc; +import com.spotify.confidence.sdk.events.v1.PublishEventsRequest; +import com.spotify.confidence.sdk.wasm.Messages; +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.ForwardingClientCall; +import io.grpc.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import java.time.Duration; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class GrpcEventSender implements Consumer { + private static final Logger logger = LoggerFactory.getLogger(GrpcEventSender.class); + private static final Duration DEFAULT_SHUTDOWN_TIMEOUT = Duration.ofSeconds(10); + + private final String clientSecret; + private final EventsServiceGrpc.EventsServiceBlockingStub stub; + private final ExecutorService executorService; + private final Duration shutdownTimeout; + private final ManagedChannel channel; + + GrpcEventSender(String clientSecret, ChannelFactory channelFactory) { + this.clientSecret = clientSecret; + this.channel = createConfidenceChannel(channelFactory); + this.stub = addAuthInterceptor(EventsServiceGrpc.newBlockingStub(channel), clientSecret); + this.executorService = Executors.newCachedThreadPool(); + this.shutdownTimeout = DEFAULT_SHUTDOWN_TIMEOUT; + } + + @Override + public void accept(Messages.FlushEventsResponse response) { + final PublishEventsRequest.Builder builder = + PublishEventsRequest.newBuilder().setClientSecret(clientSecret); + + for (Messages.Event wasmEvent : response.getEventsList()) { + builder.addEvents( + com.spotify.confidence.sdk.events.v1.Event.newBuilder() + .setEventDefinition(wasmEvent.getEventDefinition()) + .setPayload(wasmEvent.getPayload()) + .setEventTime(wasmEvent.getEventTime()) + .build()); + } + + java.time.Instant now = java.time.Instant.now(); + builder.setSendTime( + Timestamp.newBuilder().setSeconds(now.getEpochSecond()).setNanos(now.getNano()).build()); + + final PublishEventsRequest request = builder.build(); + + executorService.submit( + () -> { + try { + stub.publishEvents(request); + logger.debug("Successfully published {} events", response.getEventsCount()); + } catch (Exception e) { + logger.error("Failed to publish events", e); + } + }); + } + + void shutdown() { + executorService.shutdown(); + try { + if (!executorService.awaitTermination( + shutdownTimeout.toMillis(), TimeUnit.MILLISECONDS)) { + logger.warn("Event sender executor did not terminate gracefully"); + executorService.shutdownNow(); + } + } catch (InterruptedException e) { + logger.warn("Interrupted while waiting for event sender shutdown", e); + executorService.shutdownNow(); + Thread.currentThread().interrupt(); + } + + if (channel != null) { + channel.shutdown(); + try { + if (!channel.awaitTermination(shutdownTimeout.toMillis(), TimeUnit.MILLISECONDS)) { + channel.shutdownNow(); + } + } catch (InterruptedException e) { + channel.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + } + + private static EventsServiceGrpc.EventsServiceBlockingStub addAuthInterceptor( + EventsServiceGrpc.EventsServiceBlockingStub stub, String clientSecret) { + return stub.withInterceptors( + new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new ForwardingClientCall.SimpleForwardingClientCall( + next.newCall(method, callOptions)) { + @Override + public void start(Listener responseListener, Metadata headers) { + Metadata.Key authKey = + Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER); + headers.put(authKey, "ClientSecret " + clientSecret); + super.start(responseListener, headers); + } + }; + } + }); + } +} diff --git a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/LocalResolver.java b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/LocalResolver.java index bc65c064..f9a71ff4 100644 --- a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/LocalResolver.java +++ b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/LocalResolver.java @@ -4,6 +4,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.wasm.Messages; import java.util.concurrent.CompletionStage; /** Common interface for the compositional local resolver layers. */ @@ -40,6 +41,16 @@ interface LocalResolver { /** Flushes pending assignment logs only. */ void flushAssignLogs(); + /** + * Tracks a business event for experimentation analytics. + * + * @param event the event to track + */ + void trackEvent(Messages.Event event); + + /** Flushes pending tracked events. */ + void flushEvents(); + /** Closes the resolver and releases resources. */ void close(); } diff --git a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/MaterializingResolver.java b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/MaterializingResolver.java index f8ee5c64..14bc4f5d 100644 --- a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/MaterializingResolver.java +++ b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/MaterializingResolver.java @@ -5,6 +5,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.wasm.Messages; import java.util.List; import java.util.Set; import java.util.concurrent.CompletableFuture; @@ -181,6 +182,16 @@ public void flushAssignLogs() { delegate.flushAssignLogs(); } + @Override + public void trackEvent(Messages.Event event) { + delegate.trackEvent(event); + } + + @Override + public void flushEvents() { + delegate.flushEvents(); + } + @Override public void close() { delegate.close(); 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 983a0e8e..f5047440 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 @@ -3,6 +3,7 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.google.protobuf.Struct; +import com.google.protobuf.Timestamp; import com.spotify.confidence.sdk.flags.resolver.v1.ApplyFlagsRequest; import com.spotify.confidence.sdk.flags.resolver.v1.ResolveFlagsRequest; import com.spotify.confidence.sdk.flags.resolver.v1.ResolveFlagsResponse; @@ -10,6 +11,7 @@ import com.spotify.confidence.sdk.flags.resolver.v1.ResolvedFlag; import com.spotify.confidence.sdk.flags.resolver.v1.Sdk; import com.spotify.confidence.sdk.flags.resolver.v1.SdkId; +import com.spotify.confidence.sdk.wasm.Messages; import dev.openfeature.sdk.*; import dev.openfeature.sdk.exceptions.FlagNotFoundError; import dev.openfeature.sdk.exceptions.GeneralError; @@ -17,10 +19,12 @@ import io.grpc.Status; import io.grpc.StatusRuntimeException; import java.time.Duration; +import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; import java.util.function.Function; import org.slf4j.Logger; @@ -52,6 +56,7 @@ public class OpenFeatureLocalResolveProvider implements FeatureProvider { org.slf4j.LoggerFactory.getLogger(OpenFeatureLocalResolveProvider.class); private final LocalResolver resolver; private final WasmFlagLogger flagLogger; + private final GrpcEventSender eventSender; private final MaterializationStore materializationStore; private static final Duration ASSIGN_LOG_FLUSH_INTERVAL = Duration.ofMillis(100); private static final Duration DEFAULT_POLL_INTERVAL = Duration.ofSeconds(15); @@ -138,11 +143,15 @@ public OpenFeatureLocalResolveProvider( this.stateProvider = new FlagsAdminStateFetcher(clientSecret, config.getHttpClientFactory()); final var wasmFlagLogger = new GrpcWasmFlagLogger(clientSecret, config.getChannelFactory()); this.flagLogger = wasmFlagLogger; + this.eventSender = new GrpcEventSender(clientSecret, config.getChannelFactory()); final int numInstances = PooledResolver.getNumInstances(config.getResolverPoolSize()); + final Consumer eventSinkRef = this.eventSender; final LocalResolver inner = new PooledResolver( numInstances, - () -> new RecoveringResolver(() -> new WasmLocalResolver(flagLogger::write))); + () -> + new RecoveringResolver( + () -> new WasmLocalResolver(flagLogger::write, eventSinkRef))); this.resolver = new MaterializingResolver(inner, materializationStore); } @@ -160,16 +169,29 @@ public OpenFeatureLocalResolveProvider( String clientSecret, MaterializationStore materializationStore, WasmFlagLogger wasmFlagLogger) { + this(accountStateProvider, clientSecret, materializationStore, wasmFlagLogger, resp -> {}); + } + + @VisibleForTesting + public OpenFeatureLocalResolveProvider( + AccountStateProvider accountStateProvider, + String clientSecret, + MaterializationStore materializationStore, + WasmFlagLogger wasmFlagLogger, + Consumer eventSink) { this.clientSecret = clientSecret; this.materializationStore = materializationStore; this.stateProvider = accountStateProvider; this.flagLogger = wasmFlagLogger; + this.eventSender = null; final int numInstances = PooledResolver.getNumInstances(LocalProviderConfig.DEFAULT_RESOLVER_POOL_SIZE); final LocalResolver inner = new PooledResolver( numInstances, - () -> new RecoveringResolver(() -> new WasmLocalResolver(wasmFlagLogger::write))); + () -> + new RecoveringResolver( + () -> new WasmLocalResolver(wasmFlagLogger::write, eventSink))); this.resolver = new MaterializingResolver(inner, materializationStore); } @@ -234,9 +256,10 @@ private void scheduleStateRefresh( this.state.set(ProviderState.READY); log.info("Provider recovered and is now READY"); } else { - // State refresh + full log flush + // State refresh + full log flush + event flush resolver.setResolverState(resolverStateProtobuf.get(), accountIdRef.get(), SDK); resolver.flushAllLogs(); + resolver.flushEvents(); } } @@ -349,6 +372,11 @@ public void shutdown() { // flagLogger.shutdown() waits for pending async writes to complete this.flagLogger.shutdown(); + // eventSender.shutdown() waits for pending event publishes to complete + if (this.eventSender != null) { + this.eventSender.shutdown(); + } + FeatureProvider.super.shutdown(); } @@ -491,6 +519,37 @@ void applyFlags(ApplyFlagsRequest request) { resolver.applyFlags(request); } + @Override + public void track(String trackingEventName, EvaluationContext ctx, TrackingEventDetails details) { + if (!initialized) { + return; + } + Struct contextStruct = OpenFeatureUtils.convertToProto(ctx); + Struct.Builder payloadBuilder = contextStruct.toBuilder(); + if (details != null) { + details + .getValue() + .ifPresent( + value -> + payloadBuilder.putFields( + "value", + com.google.protobuf.Value.newBuilder() + .setNumberValue(value.doubleValue()) + .build())); + } + Instant now = Instant.now(); + resolver.trackEvent( + Messages.Event.newBuilder() + .setEventDefinition("eventDefinitions/" + trackingEventName) + .setPayload(payloadBuilder.build()) + .setEventTime( + Timestamp.newBuilder() + .setSeconds(now.getEpochSecond()) + .setNanos(now.getNano()) + .build()) + .build()); + } + private static void handleStatusRuntimeException(StatusRuntimeException e) { if (e.getStatus().getCode() == Status.Code.DEADLINE_EXCEEDED) { log.error("Deadline exceeded when calling provider backend", e); diff --git a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/PooledResolver.java b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/PooledResolver.java index 08e527c0..0cfaab8a 100644 --- a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/PooledResolver.java +++ b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/PooledResolver.java @@ -4,6 +4,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.wasm.Messages; import java.util.Optional; import java.util.concurrent.CompletionStage; import java.util.concurrent.atomic.AtomicLong; @@ -76,6 +77,16 @@ public void flushAssignLogs() { maintenance(LocalResolver::flushAssignLogs); } + @Override + public void trackEvent(Messages.Event event) { + withReadSlotVoid(lr -> lr.trackEvent(event)); + } + + @Override + public void flushEvents() { + maintenance(LocalResolver::flushEvents); + } + @Override public void close() { maintenance(LocalResolver::close); diff --git a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/RecoveringResolver.java b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/RecoveringResolver.java index 5112e52e..df28d996 100644 --- a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/RecoveringResolver.java +++ b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/RecoveringResolver.java @@ -5,6 +5,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.wasm.Messages; import java.util.concurrent.CompletionStage; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; @@ -122,6 +123,25 @@ public void flushAssignLogs() { } } + @Override + public void trackEvent(Messages.Event event) { + try { + current.get().trackEvent(event); + } catch (ChicoryException e) { + handleFailure("trackEvent", e); + throw e; + } + } + + @Override + public void flushEvents() { + try { + current.get().flushEvents(); + } catch (ChicoryException e) { + handleFailure("flushEvents", e); + } + } + @Override public void close() { // During close, do NOT recreate on failure — we are shutting down. 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 9d9c2f79..73768d80 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 @@ -43,16 +43,22 @@ class WasmLocalResolver implements LocalResolver { private final ExportFunction wasmMsgFree; private final Consumer logSink; + private final Consumer eventSink; + // api private final ExportFunction wasmMsgGuestSetResolverState; private final ExportFunction wasmMsgBoundedFlushLogs; private final ExportFunction wasmMsgBoundedFlushAssign; private final ExportFunction wasmMsgGuestApplyFlags; private final ExportFunction wasmMsgGuestResolveProcess; + private final ExportFunction wasmMsgGuestTrackEvent; + private final ExportFunction wasmMsgGuestFlushEvents; private final ReentrantLock lock = new ReentrantLock(); - public WasmLocalResolver(Consumer logSink) { + public WasmLocalResolver( + Consumer logSink, Consumer eventSink) { this.logSink = logSink; + this.eventSink = eventSink; instance = Instance.builder(ConfidenceResolverModule.load()) .withImportValues( @@ -78,6 +84,8 @@ public WasmLocalResolver(Consumer logSink) { wasmMsgBoundedFlushAssign = instance.export("wasm_msg_guest_bounded_flush_assign"); wasmMsgGuestApplyFlags = instance.export("wasm_msg_guest_apply_flags"); wasmMsgGuestResolveProcess = instance.export("wasm_msg_guest_resolve_flags"); + wasmMsgGuestTrackEvent = instance.export("wasm_msg_guest_track_event"); + wasmMsgGuestFlushEvents = instance.export("wasm_msg_guest_flush_events"); } private Message log(LogMessage message) { @@ -188,6 +196,40 @@ public void flushAssignLogs() { } } + @Override + public void trackEvent(Messages.Event event) { + lock.lock(); + try { + if (closed) { + return; + } + final int reqPtr = transferRequest(event); + final int respPtr = (int) wasmMsgGuestTrackEvent.apply(reqPtr)[0]; + consumeResponse(respPtr, Messages.Void::parseFrom); + } finally { + lock.unlock(); + } + } + + @Override + public void flushEvents() { + lock.lock(); + try { + if (closed) { + return; + } + final var voidRequest = Messages.Void.getDefaultInstance(); + final var reqPtr = transferRequest(voidRequest); + final var respPtr = (int) wasmMsgGuestFlushEvents.apply(reqPtr)[0]; + final var response = consumeResponse(respPtr, Messages.FlushEventsResponse::parseFrom); + if (response.getEventsCount() > 0) { + eventSink.accept(response); + } + } finally { + lock.unlock(); + } + } + @Override public void close() { lock.lock(); @@ -205,6 +247,15 @@ public void close() { logSink.accept(assignRequest); } + // Flush pending events + final var eventReqPtr = transferRequest(voidRequest); + final var eventRespPtr = (int) wasmMsgGuestFlushEvents.apply(eventReqPtr)[0]; + final var eventResponse = + consumeResponse(eventRespPtr, Messages.FlushEventsResponse::parseFrom); + if (eventResponse.getEventsCount() > 0) { + eventSink.accept(eventResponse); + } + // Final flush of resolve logs (also drains any remaining assigns) final var reqPtr = transferRequest(voidRequest); final var respPtr = (int) wasmMsgBoundedFlushLogs.apply(reqPtr)[0]; diff --git a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ChannelFactoryTest.java b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ChannelFactoryTest.java index 3d64852b..04d60340 100644 --- a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ChannelFactoryTest.java +++ b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ChannelFactoryTest.java @@ -39,9 +39,9 @@ public ManagedChannel create(String target, List interceptors new OpenFeatureLocalResolveProvider(new LocalProviderConfig(customFactory), "clientsecret"); assertEquals( - 1, + 2, factoryCallCount.get(), - "ChannelFactory should have been called once for flag logger, but was called " + "ChannelFactory should have been called twice (flag logger + event sender), but was called " + factoryCallCount.get() + " times"); @@ -50,7 +50,7 @@ public ManagedChannel create(String target, List interceptors assertTrue( targetsReceived.get(0).contains("grpc") || targetsReceived.get(0).contains("edge"), "Target should be a gRPC endpoint, got: " + targetsReceived.get(0)); - assertEquals(1, interceptorCounts.size(), "Interceptors should have been called"); + assertEquals(2, interceptorCounts.size(), "Interceptors should have been called for each channel"); } @Test 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 4894f63c..5da06abb 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 @@ -34,7 +34,7 @@ class ResolveTest { private final LocalResolver resolver; public ResolveTest() { - resolver = new WasmLocalResolver(request -> {}); + resolver = new WasmLocalResolver(request -> {}, response -> {}); } @BeforeEach 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 be488bbb..48564c91 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 @@ -44,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); + final var resolver = new WasmLocalResolver(logger::write, response -> {}); resolver.setResolverState(resolverState, accountId, null); // Resolve a flag to create a flag assignment in the WASM buffer diff --git a/openfeature-provider/js/Makefile b/openfeature-provider/js/Makefile index 40c853cc..5fa55de8 100644 --- a/openfeature-provider/js/Makefile +++ b/openfeature-provider/js/Makefile @@ -7,8 +7,8 @@ ROOT := $(realpath $(CURDIR)/../..) INSTALL_STAMP := .install.stamp BUILD_STAMP := .build.stamp GEN_DIR := src/proto -GEN_TS := $(GEN_DIR)/test-only.ts $(GEN_DIR)/confidence/wasm/messages.ts $(GEN_DIR)/confidence/wasm/wasm_api.ts $(GEN_DIR)/confidence/flags/resolver/v1/types.ts $(GEN_DIR)/confidence/flags/resolver/v1/api.ts $(GEN_DIR)/confidence/flags/resolver/v1/internal_api.ts $(GEN_DIR)/confidence/flags/types/v1/types.ts -PROTO_SRC := proto/test-only.proto $(ROOT)/openfeature-provider/proto/confidence/wasm/messages.proto $(ROOT)/openfeature-provider/proto/confidence/wasm/wasm_api.proto $(ROOT)/openfeature-provider/proto/confidence/flags/resolver/v1/types.proto $(ROOT)/openfeature-provider/proto/confidence/flags/resolver/v1/api.proto $(ROOT)/openfeature-provider/proto/confidence/flags/resolver/v1/internal_api.proto $(ROOT)/openfeature-provider/proto/confidence/flags/types/v1/types.proto +GEN_TS := $(GEN_DIR)/test-only.ts $(GEN_DIR)/confidence/wasm/messages.ts $(GEN_DIR)/confidence/wasm/wasm_api.ts $(GEN_DIR)/confidence/flags/resolver/v1/types.ts $(GEN_DIR)/confidence/flags/resolver/v1/api.ts $(GEN_DIR)/confidence/flags/resolver/v1/internal_api.ts $(GEN_DIR)/confidence/flags/types/v1/types.ts $(GEN_DIR)/confidence/events/v1/api.ts $(GEN_DIR)/confidence/events/v1/types.ts +PROTO_SRC := proto/test-only.proto $(ROOT)/openfeature-provider/proto/confidence/wasm/messages.proto $(ROOT)/openfeature-provider/proto/confidence/wasm/wasm_api.proto $(ROOT)/openfeature-provider/proto/confidence/flags/resolver/v1/types.proto $(ROOT)/openfeature-provider/proto/confidence/flags/resolver/v1/api.proto $(ROOT)/openfeature-provider/proto/confidence/flags/resolver/v1/internal_api.proto $(ROOT)/openfeature-provider/proto/confidence/flags/types/v1/types.proto $(ROOT)/openfeature-provider/proto/confidence/events/v1/api.proto $(ROOT)/openfeature-provider/proto/confidence/events/v1/types.proto SRC := $(shell find src -name '*.ts') CONFIG := package.json yarn.lock tsconfig.json tsdown.config.ts vitest.config.ts WASM_ARTIFACT := $(ROOT)/wasm/confidence_resolver.wasm diff --git a/openfeature-provider/js/package.json b/openfeature-provider/js/package.json index 0c8fd4f0..df634406 100644 --- a/openfeature-provider/js/package.json +++ b/openfeature-provider/js/package.json @@ -41,7 +41,7 @@ "format:check": "prettier --config prettier.config.cjs -c .", "test": "vitest", "typecheck": "tsc --noEmit", - "proto:gen": "rm -rf src/proto && mkdir -p src/proto && protoc --plugin=node_modules/.bin/protoc-gen-ts_proto --ts_proto_opt useOptionals=messages --ts_proto_opt esModuleInterop=true --ts_proto_out src/proto -Iproto -I../../openfeature-provider/proto test-only.proto ../../openfeature-provider/proto/confidence/wasm/messages.proto ../../openfeature-provider/proto/confidence/wasm/wasm_api.proto ../../openfeature-provider/proto/confidence/flags/resolver/v1/types.proto ../../openfeature-provider/proto/confidence/flags/resolver/v1/api.proto ../../openfeature-provider/proto/confidence/flags/resolver/v1/internal_api.proto ../../openfeature-provider/proto/confidence/flags/types/v1/types.proto" + "proto:gen": "rm -rf src/proto && mkdir -p src/proto && protoc --plugin=node_modules/.bin/protoc-gen-ts_proto --ts_proto_opt useOptionals=messages --ts_proto_opt esModuleInterop=true --ts_proto_out src/proto -Iproto -I../../openfeature-provider/proto test-only.proto ../../openfeature-provider/proto/confidence/wasm/messages.proto ../../openfeature-provider/proto/confidence/wasm/wasm_api.proto ../../openfeature-provider/proto/confidence/flags/resolver/v1/types.proto ../../openfeature-provider/proto/confidence/flags/resolver/v1/api.proto ../../openfeature-provider/proto/confidence/flags/resolver/v1/internal_api.proto ../../openfeature-provider/proto/confidence/flags/types/v1/types.proto ../../openfeature-provider/proto/confidence/events/v1/api.proto ../../openfeature-provider/proto/confidence/events/v1/types.proto" }, "dependencies": { "@bufbuild/protobuf": "^2.9.0" diff --git a/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts b/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts index 0ad118b9..7e5a1773 100644 --- a/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts +++ b/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts @@ -23,6 +23,8 @@ const mockedWasmResolver: MockedObject = { flushLogs: vi.fn().mockReturnValue(new Uint8Array(100)), flushAssigned: vi.fn().mockReturnValue(new Uint8Array(50)), applyFlags: vi.fn(), + trackEvent: vi.fn(), + flushEvents: vi.fn().mockReturnValue({ events: [], sendTime: undefined }), }; let provider: ConfidenceServerProviderLocal; diff --git a/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts b/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts index 03fc8f43..6371077a 100644 --- a/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts +++ b/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts @@ -1,4 +1,11 @@ -import type { EvaluationContext, JsonValue, Provider, ProviderMetadata, ProviderStatus } from '@openfeature/server-sdk'; +import type { + EvaluationContext, + JsonValue, + Provider, + ProviderMetadata, + ProviderStatus, + TrackingEventDetails, +} from '@openfeature/server-sdk'; import { ResolveFlagsResponse } from './proto/confidence/flags/resolver/v1/api'; import { ResolveProcessRequest, ResolveProcessResponse } from './proto/confidence/wasm/wasm_api'; import { SdkId } from './proto/confidence/flags/resolver/v1/types'; @@ -16,6 +23,7 @@ import { readResultsToMaterializationRecords, } from './materialization'; import { SetResolverStateRequest } from './proto/confidence/wasm/messages'; +import { PublishEventsRequest } from './proto/confidence/events/v1/api'; import FlagBundleType, * as FlagBundle from './flag-bundle'; import { ErrorCode, ResolutionDetails } from './types'; @@ -108,6 +116,10 @@ export class ConfidenceServerProviderLocal implements Provider { ], }), ], + 'https://events.confidence.dev/*': [ + withRetry({ maxAttempts: 5, baseInterval: 500 }), + withTimeout(5 * TimeUnit.SECOND), + ], '*': [ withResponse(url => { throw new Error(`Unknown route ${url}`); @@ -161,6 +173,19 @@ export class ConfidenceServerProviderLocal implements Provider { this.main.abort(); } + track(trackingEventName: string, context: EvaluationContext, details: TrackingEventDetails): void { + const payload = { + ...ConfidenceServerProviderLocal.convertEvaluationContext(context), + ...(details ?? {}), + }; + const now = new Date(); + this.resolver.trackEvent({ + eventDefinition: `eventDefinitions/${trackingEventName}`, + payload, + eventTime: now, + }); + } + async resolve(context: EvaluationContext, flagNames: string[], apply = false): Promise { const resolveRequest = { flags: flagNames.map(name => `flags/${name}`), @@ -271,6 +296,10 @@ export class ConfidenceServerProviderLocal implements Provider { if (writeFlagLogRequest.length > 0) { await this.sendFlagLogs(writeFlagLogRequest, signal); } + const { events } = this.resolver.flushEvents(); + if (events.length > 0) { + await this.sendEvents(events, signal); + } } private async flushAssigned(): Promise { @@ -301,6 +330,33 @@ export class ConfidenceServerProviderLocal implements Provider { } } + private async sendEvents( + events: { eventDefinition: string; payload?: { [key: string]: any }; eventTime?: Date }[], + signal = this.main.signal, + ): Promise { + try { + const body = PublishEventsRequest.encode({ + clientSecret: this.options.flagClientSecret, + events, + sendTime: new Date(), + }).finish(); + const response = await this.fetch('https://events.confidence.dev/v1/events:publish', { + method: 'post', + signal, + headers: { + 'Content-Type': 'application/x-protobuf', + }, + body: body as Uint8Array, + }); + if (!response.ok) { + logger.error(`Failed to publish events: ${response.status} ${response.statusText} - ${await response.text()}`); + } + } catch (err) { + logger.warn('Failed to send events', err); + throw err; + } + } + private async readMaterializations( readOps: MaterializationStore.ReadOp[], ): Promise { diff --git a/openfeature-provider/js/src/LocalResolver.ts b/openfeature-provider/js/src/LocalResolver.ts index 9827c89d..5e83d3ee 100644 --- a/openfeature-provider/js/src/LocalResolver.ts +++ b/openfeature-provider/js/src/LocalResolver.ts @@ -1,5 +1,5 @@ import type { ResolveProcessRequest, ResolveProcessResponse } from './proto/confidence/wasm/wasm_api'; -import type { SetResolverStateRequest } from './proto/confidence/wasm/messages'; +import type { Event, FlushEventsResponse, SetResolverStateRequest } from './proto/confidence/wasm/messages'; import type { ApplyFlagsRequest } from './proto/confidence/flags/resolver/v1/api'; export interface LocalResolver { @@ -8,4 +8,6 @@ export interface LocalResolver { flushLogs(): Uint8Array; flushAssigned(): Uint8Array; applyFlags(request: ApplyFlagsRequest): void; + trackEvent(event: Event): void; + flushEvents(): FlushEventsResponse; } diff --git a/openfeature-provider/js/src/WasmResolver.ts b/openfeature-provider/js/src/WasmResolver.ts index 7708879e..5e238c0f 100644 --- a/openfeature-provider/js/src/WasmResolver.ts +++ b/openfeature-provider/js/src/WasmResolver.ts @@ -1,5 +1,6 @@ import { BinaryWriter } from '@bufbuild/protobuf/wire'; -import { Request, Response, Void, SetResolverStateRequest } from './proto/confidence/wasm/messages'; +import { Request, Response, Void, SetResolverStateRequest, FlushEventsResponse } from './proto/confidence/wasm/messages'; +import { Event } from './proto/confidence/wasm/messages'; import { Timestamp } from './proto/google/protobuf/timestamp'; import { ResolveProcessRequest, ResolveProcessResponse } from './proto/confidence/wasm/wasm_api'; import { ApplyFlagsRequest } from './proto/confidence/flags/resolver/v1/api'; @@ -21,6 +22,8 @@ const EXPORT_FN_NAMES = [ 'wasm_msg_guest_bounded_flush_logs', 'wasm_msg_guest_bounded_flush_assign', 'wasm_msg_guest_apply_flags', + 'wasm_msg_guest_track_event', + 'wasm_msg_guest_flush_events', ] as const; type EXPORT_FN_NAMES = (typeof EXPORT_FN_NAMES)[number]; @@ -96,6 +99,21 @@ export class UnsafeWasmResolver implements LocalResolver { this.consumeResponse(resPtr, Void); } + trackEvent(event: Event): void { + const reqPtr = this.transferRequest(event, Event); + const resPtr = this.exports.wasm_msg_guest_track_event(reqPtr); + this.consumeResponse(resPtr, Void); + } + + flushEvents(): FlushEventsResponse { + const resPtr = this.exports.wasm_msg_guest_flush_events(0); + const { data, error } = this.consume(resPtr, Response); + if (error) { + throw new Error(error); + } + return FlushEventsResponse.decode(data!); + } + private transferRequest(value: T, codec: Codec): number { const data = codec.encode(value).finish(); return this.transfer({ data }, Request); @@ -219,4 +237,26 @@ export class WasmResolver implements LocalResolver { throw error; } } + + trackEvent(event: Event): void { + try { + this.delegate.trackEvent(event); + } catch (error: unknown) { + if (error instanceof WebAssembly.RuntimeError) { + this.reloadInstance(error); + } + throw error; + } + } + + flushEvents(): FlushEventsResponse { + try { + return this.delegate.flushEvents(); + } catch (error: unknown) { + if (error instanceof WebAssembly.RuntimeError) { + this.reloadInstance(error); + } + throw error; + } + } } diff --git a/openfeature-provider/proto/confidence/events/v1/api.proto b/openfeature-provider/proto/confidence/events/v1/api.proto new file mode 100644 index 00000000..c20aafa6 --- /dev/null +++ b/openfeature-provider/proto/confidence/events/v1/api.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; +package confidence.events.v1; + +import "google/protobuf/timestamp.proto"; +import "confidence/events/v1/types.proto"; + +option java_package = "com.spotify.confidence.sdk.events.v1"; +option java_multiple_files = true; +option java_outer_classname = "ApiProto"; +option go_package = "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/events"; + +service EventsService { + rpc PublishEvents(PublishEventsRequest) returns (PublishEventsResponse) {} +} + +message PublishEventsRequest { + string client_secret = 1; + repeated Event events = 2; + google.protobuf.Timestamp send_time = 3; +} + +message PublishEventsResponse { + repeated EventError errors = 1; +} diff --git a/openfeature-provider/proto/confidence/events/v1/types.proto b/openfeature-provider/proto/confidence/events/v1/types.proto new file mode 100644 index 00000000..cfb8c718 --- /dev/null +++ b/openfeature-provider/proto/confidence/events/v1/types.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; +package confidence.events.v1; + +import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; + +option java_package = "com.spotify.confidence.sdk.events.v1"; +option java_multiple_files = true; +option java_outer_classname = "TypesProto"; +option go_package = "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/events"; + +message Event { + string event_definition = 1; + google.protobuf.Struct payload = 2; + google.protobuf.Timestamp event_time = 3; +} + +message EventError { + int32 index = 1; + Reason reason = 2; + string message = 3; + + enum Reason { + REASON_UNSPECIFIED = 0; + EVENT_DEFINITION_NOT_FOUND = 1; + EVENT_SCHEMA_VALIDATION_FAILED = 2; + } +} diff --git a/openfeature-provider/proto/confidence/wasm/messages.proto b/openfeature-provider/proto/confidence/wasm/messages.proto index 52153dca..bdef56fb 100644 --- a/openfeature-provider/proto/confidence/wasm/messages.proto +++ b/openfeature-provider/proto/confidence/wasm/messages.proto @@ -3,6 +3,8 @@ syntax = "proto3"; package confidence.wasm; import "confidence/flags/resolver/v1/types.proto"; +import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; option java_package = "com.spotify.confidence.sdk.wasm"; option java_multiple_files = false; @@ -27,3 +29,13 @@ message Response { string error = 2; } } + +message Event { + string event_definition = 1; + google.protobuf.Struct payload = 2; + google.protobuf.Timestamp event_time = 3; +} + +message FlushEventsResponse { + repeated Event events = 2; +} diff --git a/wasm/proto/messages.proto b/wasm/proto/messages.proto index 6bc493e8..bf1f4653 100644 --- a/wasm/proto/messages.proto +++ b/wasm/proto/messages.proto @@ -3,6 +3,7 @@ syntax = "proto3"; package rust_guest; import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; import "types.proto"; option java_package = "com.spotify.confidence.wasm"; @@ -38,3 +39,13 @@ message Response { string error = 2; } } + +message Event { + string event_definition = 1; + google.protobuf.Struct payload = 2; + google.protobuf.Timestamp event_time = 3; +} + +message FlushEventsResponse { + repeated Event events = 2; +} diff --git a/wasm/rust-guest/src/lib.rs b/wasm/rust-guest/src/lib.rs index c361e0c9..85ca2071 100644 --- a/wasm/rust-guest/src/lib.rs +++ b/wasm/rust-guest/src/lib.rs @@ -4,6 +4,7 @@ use std::sync::LazyLock; use arc_swap::{ArcSwap, ArcSwapOption}; use bytes::Bytes; use confidence_resolver::assign_logger::AssignLogger; +use confidence_resolver::event_logger::{EventLogger, TrackedEvent}; use confidence_resolver::proto::confidence::flags::resolver::v1::resolve_process_response; use confidence_resolver::telemetry::{Telemetry, TelemetrySnapshot}; use prost::Message; @@ -56,6 +57,7 @@ const ENCRYPTION_KEY: Bytes = Bytes::from_static(&[0; 16]); static RESOLVER_STATE: ArcSwapOption = ArcSwapOption::const_empty(); static RESOLVE_LOGGER: LazyLock> = LazyLock::new(ResolveLogger::new); static ASSIGN_LOGGER: LazyLock = LazyLock::new(AssignLogger::new); +static EVENT_LOGGER: LazyLock = LazyLock::new(EventLogger::new); static TELEMETRY: LazyLock = LazyLock::new(|| { Telemetry::with_memory_provider(|| (core::arch::wasm32::memory_size::<0>() * 65536) as u64) }); @@ -199,6 +201,27 @@ wasm_msg_guest! { Ok(ASSIGN_LOGGER.checkpoint_with_limit(LOG_TARGET_BYTES, true)) } + fn track_event(event: proto::Event) -> WasmResult { + EVENT_LOGGER.track(TrackedEvent { + event_definition: event.event_definition, + payload: event.payload.unwrap_or_default(), + event_time: event.event_time.unwrap_or_default(), + }); + Ok(VOID) + } + + fn flush_events(_request: Void) -> WasmResult { + let events = EVENT_LOGGER.flush(); + let pb_events = events.into_iter().map(|e| proto::Event { + event_definition: e.event_definition, + payload: Some(e.payload), + event_time: Some(e.event_time), + }).collect(); + Ok(proto::FlushEventsResponse { + events: pb_events, + }) + } + fn apply_flags(request: ApplyFlagsRequest) -> WasmResult { let resolver_state = get_resolver_state()?; // Use empty evaluation context - the real one is extracted from the resolve token From abe0cec34994dc34d286b428030ce897b765f9a0 Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Fri, 20 Mar 2026 10:42:40 +0100 Subject: [PATCH 2/4] fix: gofmt provider.go Co-Authored-By: Claude Opus 4.6 (1M context) --- openfeature-provider/go/confidence/provider.go | 1 - 1 file changed, 1 deletion(-) diff --git a/openfeature-provider/go/confidence/provider.go b/openfeature-provider/go/confidence/provider.go index 7a9ec4cb..489f78e1 100644 --- a/openfeature-provider/go/confidence/provider.go +++ b/openfeature-provider/go/confidence/provider.go @@ -594,7 +594,6 @@ func (p *LocalResolverProvider) startScheduledTasks(parentCtx context.Context) { }() } - // getStatePollInterval gets the state poll interval from environment or returns default // Deprecated: Use ProviderConfig.StatePollInterval instead. Environment variable support will be removed in a future version. func getStatePollInterval(logger *slog.Logger) time.Duration { From 115ff3a0818b7fc69925966d91f06fcd6e50d23a Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Fri, 20 Mar 2026 10:48:58 +0100 Subject: [PATCH 3/4] style: format WasmResolver.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- openfeature-provider/js/src/WasmResolver.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/openfeature-provider/js/src/WasmResolver.ts b/openfeature-provider/js/src/WasmResolver.ts index 5e238c0f..055bd93e 100644 --- a/openfeature-provider/js/src/WasmResolver.ts +++ b/openfeature-provider/js/src/WasmResolver.ts @@ -1,5 +1,11 @@ import { BinaryWriter } from '@bufbuild/protobuf/wire'; -import { Request, Response, Void, SetResolverStateRequest, FlushEventsResponse } from './proto/confidence/wasm/messages'; +import { + Request, + Response, + Void, + SetResolverStateRequest, + FlushEventsResponse, +} from './proto/confidence/wasm/messages'; import { Event } from './proto/confidence/wasm/messages'; import { Timestamp } from './proto/google/protobuf/timestamp'; import { ResolveProcessRequest, ResolveProcessResponse } from './proto/confidence/wasm/wasm_api'; From 26756b16b11bba08df47a55f1b740a9d919d75e3 Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Fri, 20 Mar 2026 11:48:51 +0100 Subject: [PATCH 4/4] style: format Java files Co-Authored-By: Claude Opus 4.6 (1M context) --- .../main/java/com/spotify/confidence/sdk/GrpcEventSender.java | 3 +-- .../java/com/spotify/confidence/sdk/ChannelFactoryTest.java | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/GrpcEventSender.java b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/GrpcEventSender.java index 20c353fd..e7aedcd2 100644 --- a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/GrpcEventSender.java +++ b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/GrpcEventSender.java @@ -74,8 +74,7 @@ public void accept(Messages.FlushEventsResponse response) { void shutdown() { executorService.shutdown(); try { - if (!executorService.awaitTermination( - shutdownTimeout.toMillis(), TimeUnit.MILLISECONDS)) { + if (!executorService.awaitTermination(shutdownTimeout.toMillis(), TimeUnit.MILLISECONDS)) { logger.warn("Event sender executor did not terminate gracefully"); executorService.shutdownNow(); } diff --git a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ChannelFactoryTest.java b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ChannelFactoryTest.java index 04d60340..cd30e50d 100644 --- a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ChannelFactoryTest.java +++ b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ChannelFactoryTest.java @@ -50,7 +50,8 @@ public ManagedChannel create(String target, List interceptors assertTrue( targetsReceived.get(0).contains("grpc") || targetsReceived.get(0).contains("edge"), "Target should be a gRPC endpoint, got: " + targetsReceived.get(0)); - assertEquals(2, interceptorCounts.size(), "Interceptors should have been called for each channel"); + assertEquals( + 2, interceptorCounts.size(), "Interceptors should have been called for each channel"); } @Test