From ed9fe2c9b277266c9cd7ea9b3415d3e70dedef9f Mon Sep 17 00:00:00 2001 From: vahidlazio Date: Mon, 24 Aug 2026 15:32:45 +0200 Subject: [PATCH 1/4] feat: add event engine WASM for OpenFeature track() support --- .gitignore | 4 + Cargo.lock | 20 + Cargo.toml | 4 + Dockerfile | 107 +++- Makefile | 31 +- confidence-event-engine/Cargo.toml | 13 + confidence-event-engine/Makefile | 12 + confidence-event-engine/README.md | 88 +++ confidence-event-engine/build.rs | 20 + confidence-event-engine/src/event_logger.rs | 249 ++++++++ confidence-event-engine/src/lib.rs | 16 + .../assets/confidence_event_engine.wasm | Bin 0 -> 60003 bytes .../internal/event_tracking/embed.go | 11 + .../internal/event_tracking/event_tracker.go | 262 +++++++++ .../event_tracking/event_tracker_test.go | 116 ++++ .../internal/proto/events/api.pb.go | 218 +++++++ .../internal/proto/events/api_grpc.pb.go | 147 +++++ .../internal/proto/events/types.pb.go | 548 ++++++++++++++++++ .../internal/proto/eventswasm/wasm_api.pb.go | 272 +++++++++ .../go/confidence/provider.go | 353 ++++++++++- .../go/confidence/provider_builder.go | 11 + .../go/confidence/provider_events_test.go | 182 ++++++ .../go/scripts/generate_proto.sh | 7 +- openfeature-provider/java/pom.xml | 2 + .../confidence/sdk/DefaultChannelFactory.java | 36 +- .../com/spotify/confidence/sdk/GrpcUtil.java | 27 + .../confidence/sdk/LocalProviderConfig.java | 47 +- .../sdk/OpenFeatureLocalResolveProvider.java | 228 ++++++++ .../confidence/sdk/WasmEventResolver.java | 206 +++++++ openfeature-provider/js/package.json | 2 +- .../js/src/ConfidenceServerProviderLocal.ts | 103 ++++ .../js/src/EventWasmResolver.test.ts | 120 ++++ .../js/src/EventWasmResolver.ts | 134 +++++ openfeature-provider/js/src/index.node.ts | 28 +- .../proto/confidence/events/v1/api.proto | 47 ++ .../proto/confidence/events/v1/types.proto | 96 +++ .../confidence/events/wasm/v1/wasm_api.proto | 43 ++ openfeature-provider/python/Makefile | 9 +- openfeature-provider/python/pyproject.toml | 2 +- .../python/src/confidence/event_resolver.py | 194 +++++++ .../proto/confidence/events/__init__.py | 0 .../proto/confidence/events/v1/__init__.py | 0 .../proto/confidence/events/v1/api_pb2.py | 43 ++ .../confidence/events/v1/api_pb2_grpc.py | 128 ++++ .../proto/confidence/events/v1/types_pb2.py | 47 ++ .../confidence/events/v1/types_pb2_grpc.py | 24 + .../proto/confidence/events/wasm/__init__.py | 0 .../confidence/events/wasm/v1/__init__.py | 0 .../confidence/events/wasm/v1/wasm_api_pb2.py | 44 ++ .../events/wasm/v1/wasm_api_pb2_grpc.py | 24 + .../python/src/confidence/provider.py | 260 ++++++++- wasm/event-guest/Cargo.toml | 14 + wasm/event-guest/Makefile | 20 + wasm/event-guest/src/lib.rs | 202 +++++++ 54 files changed, 4785 insertions(+), 36 deletions(-) create mode 100644 confidence-event-engine/Cargo.toml create mode 100644 confidence-event-engine/Makefile create mode 100644 confidence-event-engine/README.md create mode 100644 confidence-event-engine/build.rs create mode 100644 confidence-event-engine/src/event_logger.rs create mode 100644 confidence-event-engine/src/lib.rs create mode 100755 openfeature-provider/go/confidence/internal/event_tracking/assets/confidence_event_engine.wasm create mode 100644 openfeature-provider/go/confidence/internal/event_tracking/embed.go create mode 100644 openfeature-provider/go/confidence/internal/event_tracking/event_tracker.go create mode 100644 openfeature-provider/go/confidence/internal/event_tracking/event_tracker_test.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/go/confidence/internal/proto/eventswasm/wasm_api.pb.go create mode 100644 openfeature-provider/go/confidence/provider_events_test.go create mode 100644 openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/WasmEventResolver.java create mode 100644 openfeature-provider/js/src/EventWasmResolver.test.ts create mode 100644 openfeature-provider/js/src/EventWasmResolver.ts create mode 100644 openfeature-provider/proto/confidence/events/v1/api.proto create mode 100644 openfeature-provider/proto/confidence/events/v1/types.proto create mode 100644 openfeature-provider/proto/confidence/events/wasm/v1/wasm_api.proto create mode 100644 openfeature-provider/python/src/confidence/event_resolver.py create mode 100644 openfeature-provider/python/src/confidence/proto/confidence/events/__init__.py create mode 100644 openfeature-provider/python/src/confidence/proto/confidence/events/v1/__init__.py create mode 100644 openfeature-provider/python/src/confidence/proto/confidence/events/v1/api_pb2.py create mode 100644 openfeature-provider/python/src/confidence/proto/confidence/events/v1/api_pb2_grpc.py create mode 100644 openfeature-provider/python/src/confidence/proto/confidence/events/v1/types_pb2.py create mode 100644 openfeature-provider/python/src/confidence/proto/confidence/events/v1/types_pb2_grpc.py create mode 100644 openfeature-provider/python/src/confidence/proto/confidence/events/wasm/__init__.py create mode 100644 openfeature-provider/python/src/confidence/proto/confidence/events/wasm/v1/__init__.py create mode 100644 openfeature-provider/python/src/confidence/proto/confidence/events/wasm/v1/wasm_api_pb2.py create mode 100644 openfeature-provider/python/src/confidence/proto/confidence/events/wasm/v1/wasm_api_pb2_grpc.py create mode 100644 wasm/event-guest/Cargo.toml create mode 100644 wasm/event-guest/Makefile create mode 100644 wasm/event-guest/src/lib.rs diff --git a/.gitignore b/.gitignore index 736d3e85a..7eac760ac 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ openfeature-provider/go/demo/demo target/ # Ignore WASM in root wasm/ directory (build artifact) wasm/confidence_resolver.wasm +wasm/confidence_event_engine.wasm # But DO track WASM embedded in Go provider (committed for go:embed) !openfeature-provider/go/wasm/confidence_resolver.wasm @@ -20,3 +21,6 @@ wasm/confidence_resolver.wasm .env.test + +# Local planning docs — not part of PRs +ai-plans/ diff --git a/Cargo.lock b/Cargo.lock index 767aec4db..5f2fb1c9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -330,6 +330,16 @@ dependencies = [ "worker", ] +[[package]] +name = "confidence-event-engine" +version = "0.1.0" +dependencies = [ + "crossbeam-queue", + "prost", + "prost-build", + "prost-types", +] + [[package]] name = "confidence_resolver" version = "0.22.1" @@ -521,6 +531,16 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "event-guest" +version = "0.1.0" +dependencies = [ + "confidence-event-engine", + "prost", + "prost-types", + "wasm-msg", +] + [[package]] name = "fastmurmur3" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index addf1d5ea..a53367121 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,9 @@ resolver = "2" # Use the new resolver for edition 2021 members = [ "wasm-msg", "wasm/rust-guest", + "wasm/event-guest", "confidence-resolver", + "confidence-event-engine", "confidence-cloudflare-resolver", "openfeature-provider/java", "openfeature-provider/js", @@ -18,7 +20,9 @@ members = [ default-members = [ "wasm-msg", "wasm/rust-guest", + "wasm/event-guest", "confidence-resolver", + "confidence-event-engine", "confidence-cloudflare-resolver" ] [profile.wasm] diff --git a/Dockerfile b/Dockerfile index 60aba8808..f90762f0e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,6 +45,8 @@ COPY confidence-resolver/Cargo.toml ./confidence-resolver/ COPY confidence-cloudflare-resolver/Cargo.toml ./confidence-cloudflare-resolver/ COPY wasm-msg/Cargo.toml ./wasm-msg/ COPY wasm/rust-guest/Cargo.toml ./wasm/rust-guest/ +COPY wasm/event-guest/Cargo.toml ./wasm/event-guest/ +COPY confidence-event-engine/Cargo.toml ./confidence-event-engine/ COPY openfeature-provider/java/Cargo.toml ./openfeature-provider/java/ COPY openfeature-provider/js/Cargo.toml ./openfeature-provider/js/ COPY openfeature-provider/go/Cargo.toml ./openfeature-provider/go/ @@ -58,6 +60,7 @@ COPY wasm/proto ./wasm/proto/ # Copy build.rs files COPY confidence-resolver/build.rs ./confidence-resolver/ +COPY confidence-event-engine/build.rs ./confidence-event-engine/ COPY wasm-msg/build.rs ./wasm-msg/ COPY wasm/rust-guest/build.rs ./wasm/rust-guest/ COPY openfeature-provider/rust/build.rs ./openfeature-provider/rust/ @@ -68,10 +71,14 @@ RUN mkdir -p confidence-resolver/src && \ echo "pub fn dummy() {}" > confidence-resolver/src/lib.rs && \ mkdir -p confidence-cloudflare-resolver/src && \ echo "pub fn dummy() {}" > confidence-cloudflare-resolver/src/lib.rs && \ + mkdir -p confidence-event-engine/src && \ + echo "pub fn dummy() {}" > confidence-event-engine/src/lib.rs && \ mkdir -p wasm-msg/src && \ echo "pub fn dummy() {}" > wasm-msg/src/lib.rs && \ mkdir -p wasm/rust-guest/src && \ echo "pub fn dummy() {}" > wasm/rust-guest/src/lib.rs && \ + mkdir -p wasm/event-guest/src && \ + echo "pub fn dummy() {}" > wasm/event-guest/src/lib.rs && \ mkdir -p openfeature-provider/rust/src && \ echo "pub fn dummy() {}" > openfeature-provider/rust/src/lib.rs @@ -100,8 +107,10 @@ COPY --from=rust-deps /workspace/target /workspace/target COPY Cargo.toml Cargo.lock ./ COPY confidence-resolver/ ./confidence-resolver/ COPY confidence-cloudflare-resolver/ ./confidence-cloudflare-resolver/ +COPY confidence-event-engine/ ./confidence-event-engine/ COPY wasm-msg/ ./wasm-msg/ COPY wasm/rust-guest/ ./wasm/rust-guest/ +COPY wasm/event-guest/ ./wasm/event-guest/ COPY wasm/proto/ ./wasm/proto/ COPY openfeature-provider/java/Cargo.toml ./openfeature-provider/java/ COPY openfeature-provider/js/Cargo.toml ./openfeature-provider/js/ @@ -129,6 +138,27 @@ FROM confidence-resolver.build AS confidence-resolver.test WORKDIR /workspace/confidence-resolver RUN make test +# ============================================================================== +# Build confidence-event-engine +# ============================================================================== +FROM rust-test-base AS confidence-event-engine.build +WORKDIR /workspace/confidence-event-engine +RUN cargo build --release --lib + +# ============================================================================== +# Test confidence-event-engine +# ============================================================================== +FROM confidence-event-engine.build AS confidence-event-engine.test +WORKDIR /workspace/confidence-event-engine +RUN make test + +# ============================================================================== +# Lint confidence-event-engine +# ============================================================================== +FROM confidence-event-engine.build AS confidence-event-engine.lint +WORKDIR /workspace/confidence-event-engine +RUN make lint + # ============================================================================== # Build wasm-msg (test + lint derive from this to reuse artifacts) # ============================================================================== @@ -172,8 +202,10 @@ COPY --from=rust-deps /workspace/target /workspace/target COPY Cargo.toml Cargo.lock ./ COPY confidence-resolver/ ./confidence-resolver/ COPY confidence-cloudflare-resolver/ ./confidence-cloudflare-resolver/ +COPY confidence-event-engine/ ./confidence-event-engine/ COPY wasm-msg/ ./wasm-msg/ COPY wasm/rust-guest/ ./wasm/rust-guest/ +COPY wasm/event-guest/ ./wasm/event-guest/ COPY wasm/proto/ ./wasm/proto/ COPY openfeature-provider/java/Cargo.toml ./openfeature-provider/java/ COPY openfeature-provider/js/Cargo.toml ./openfeature-provider/js/ @@ -219,6 +251,42 @@ FROM scratch AS wasm-rust-guest.artifact COPY --from=wasm-rust-guest.build /workspace/target/wasm32-unknown-unknown/wasm/rust_guest.wasm /confidence_resolver.wasm +# ============================================================================== +# Build wasm/event-guest WASM +# ============================================================================== +FROM wasm-deps AS wasm-event-guest.build + +WORKDIR /workspace/wasm/event-guest +RUN make build + +WORKDIR /workspace + +RUN ls -lh target/wasm32-unknown-unknown/wasm/event_guest.wasm && \ + echo "Event WASM size: $(du -h target/wasm32-unknown-unknown/wasm/event_guest.wasm | cut -f1)" + +# ============================================================================== +# Test wasm/event-guest (host target — covers build_payload mapping rules) +# ============================================================================== +FROM rust-test-base AS wasm-event-guest.test + +WORKDIR /workspace/wasm/event-guest +RUN make test + +# ============================================================================== +# Lint wasm/event-guest (WASM target) +# ============================================================================== +FROM wasm-deps AS wasm-event-guest.lint + +WORKDIR /workspace/wasm/event-guest +RUN make lint + +# ============================================================================== +# Extract wasm/event-guest WASM artifact +# ============================================================================== +FROM scratch AS wasm-event-guest.artifact + +COPY --from=wasm-event-guest.build /workspace/target/wasm32-unknown-unknown/wasm/event_guest.wasm /confidence_event_engine.wasm + # ============================================================================== # Build confidence-cloudflare-resolver (WASM target) # ============================================================================== @@ -306,8 +374,10 @@ COPY openfeature-provider/js/src ./src/ COPY openfeature-provider/js/tsconfig.json openfeature-provider/js/tsdown.config.ts openfeature-provider/js/vitest.config.ts ./ COPY openfeature-provider/js/Makefile ./ -# Copy WASM module +# Copy WASM modules. The paths mirror the repo layout because the tests load +# them relative to src/ (see WasmResolver.test.ts / EventWasmResolver.test.ts). COPY --from=wasm-rust-guest.artifact /confidence_resolver.wasm ../../../wasm/confidence_resolver.wasm +COPY --from=wasm-event-guest.artifact /confidence_event_engine.wasm ../../../wasm/confidence_event_engine.wasm # ============================================================================== @@ -441,6 +511,35 @@ RUN set -e; \ fi; \ echo "✅ WASM files are in sync" +# ============================================================================== +# Validate committed event engine WASM matches a fresh build (Go go:embed) +# ============================================================================== +FROM alpine:3.22 AS openfeature-provider-go.validate-event-wasm + +RUN apk add --no-cache diffutils + +COPY --from=wasm-event-guest.artifact /confidence_event_engine.wasm /built/confidence_event_engine.wasm + +COPY openfeature-provider/go/confidence/internal/event_tracking/assets/confidence_event_engine.wasm /committed/confidence_event_engine.wasm + +RUN set -e; \ + echo "Validating event engine WASM sync for Go provider..."; \ + if ! cmp -s /built/confidence_event_engine.wasm /committed/confidence_event_engine.wasm; then \ + echo ""; \ + echo "❌ ERROR: Event engine WASM files are out of sync!"; \ + echo ""; \ + echo "The committed WASM embedded by the Go provider does not match a fresh build."; \ + echo "Note: the binary embeds absolute source paths, so it must be built in Docker."; \ + echo ""; \ + echo "To fix:"; \ + echo " make sync-wasm-event-go"; \ + echo " git add openfeature-provider/go/confidence/internal/event_tracking/assets/confidence_event_engine.wasm"; \ + echo " git commit -m 'chore: sync event engine WASM for Go provider'"; \ + echo ""; \ + exit 1; \ + fi; \ + echo "✅ Event engine WASM files are in sync" + # ============================================================================== # Build OpenFeature Provider (Go) (test + lint derive from this) # ============================================================================== @@ -748,6 +847,7 @@ FROM scratch AS all # Copy build artifacts (forces build stages to execute) COPY --from=wasm-rust-guest.artifact /confidence_resolver.wasm /artifacts/wasm/ +COPY --from=wasm-event-guest.artifact /confidence_event_engine.wasm /artifacts/wasm/ # Force test stages to run by copying marker files COPY --from=confidence-resolver.test /workspace/Cargo.toml /markers/test-resolver @@ -765,11 +865,16 @@ COPY --from=openfeature-provider-rust.test_e2e /workspace/Cargo.toml /markers/te # Force validation stages to run COPY --from=openfeature-provider-go.validate-wasm /built/confidence_resolver.wasm /markers/validate-wasm-go +COPY --from=openfeature-provider-go.validate-event-wasm /built/confidence_event_engine.wasm /markers/validate-event-wasm-go # Force lint stages to run by copying marker files COPY --from=confidence-resolver.lint /workspace/Cargo.toml /markers/lint-resolver COPY --from=wasm-msg.lint /workspace/Cargo.toml /markers/lint-wasm-msg COPY --from=wasm-rust-guest.lint /workspace/Cargo.toml /markers/lint-guest +COPY --from=wasm-event-guest.lint /workspace/Cargo.toml /markers/lint-event-guest +COPY --from=wasm-event-guest.test /workspace/Cargo.toml /markers/test-event-guest +COPY --from=confidence-event-engine.test /workspace/Cargo.toml /markers/test-event-engine +COPY --from=confidence-event-engine.lint /workspace/Cargo.toml /markers/lint-event-engine COPY --from=openfeature-provider-go.lint /app/go.mod /markers/lint-openfeature-go COPY --from=openfeature-provider-ruby.lint /app/Gemfile /markers/lint-openfeature-ruby COPY --from=openfeature-provider-python.lint /app/pyproject.toml /markers/lint-openfeature-python diff --git a/Makefile b/Makefile index 487c5bf62..13df1394d 100644 --- a/Makefile +++ b/Makefile @@ -2,18 +2,28 @@ # Local development commands - delegates to component Makefiles TARGET_WASM := target/wasm32-unknown-unknown/wasm/rust_guest.wasm +TARGET_EVENT_WASM := target/wasm32-unknown-unknown/wasm/event_guest.wasm GO_WASM := openfeature-provider/go/confidence/internal/local_resolver/assets +GO_EVENT_WASM := openfeature-provider/go/confidence/internal/event_tracking/assets -.PHONY: $(TARGET_WASM) test lint build all clean +.PHONY: $(TARGET_WASM) $(TARGET_EVENT_WASM) test lint build all clean $(TARGET_WASM): @$(MAKE) -C wasm/rust-guest build +$(TARGET_EVENT_WASM): + @$(MAKE) -C wasm/event-guest build + wasm/confidence_resolver.wasm: $(TARGET_WASM) @mkdir -p wasm @cp -p $(TARGET_WASM) $@ @echo "WASM size: $$(ls -lh $@ | awk '{print $$5}')" +wasm/confidence_event_engine.wasm: $(TARGET_EVENT_WASM) + @mkdir -p wasm + @cp -p $(TARGET_EVENT_WASM) $@ + @echo "Event WASM size: $$(ls -lh $@ | awk '{print $$5}')" + # Sync WASM to Go provider using Docker to ensure correct toolchain .PHONY: sync-wasm-go sync-wasm-go: @@ -25,6 +35,17 @@ sync-wasm-go: @echo " git add $(GO_WASM)/confidence_resolver.wasm" @echo " git commit -m 'chore: sync WASM module for Go provider'" +# Sync event engine WASM to Go provider using Docker to ensure correct toolchain +.PHONY: sync-wasm-event-go +sync-wasm-event-go: + @echo "Building event engine WASM with Docker to ensure correct dependencies..." + @docker build --platform linux/arm64 --target wasm-event-guest.artifact --output type=local,dest=$(GO_EVENT_WASM) . + @echo "✅ Event WASM synced to $(GO_EVENT_WASM)/" + @echo "" + @echo "Don't forget to commit the change:" + @echo " git add $(GO_EVENT_WASM)/confidence_event_engine.wasm" + @echo " git commit -m 'chore: sync event engine WASM for Go provider'" + # Build Cloudflare deployer image using main Dockerfile .PHONY: build-deployer build-deployer: @@ -38,6 +59,8 @@ build-deployer: test: $(MAKE) -C confidence-resolver test + $(MAKE) -C confidence-event-engine test + $(MAKE) -C wasm/event-guest test $(MAKE) -C wasm-msg test $(MAKE) -C openfeature-provider/js test $(MAKE) -C openfeature-provider/java test @@ -48,15 +71,17 @@ test: lint: $(MAKE) -C confidence-resolver lint + $(MAKE) -C confidence-event-engine lint $(MAKE) -C wasm-msg lint $(MAKE) -C wasm/rust-guest lint + $(MAKE) -C wasm/event-guest lint $(MAKE) -C confidence-cloudflare-resolver lint $(MAKE) -C openfeature-provider/go lint $(MAKE) -C openfeature-provider/ruby lint $(MAKE) -C openfeature-provider/rust lint - cargo fmt --check -p wasm-msg -p rust-guest -p confidence_resolver -p confidence-cloudflare-resolver -p spotify-confidence-openfeature-provider + cargo fmt --check -p wasm-msg -p rust-guest -p event-guest -p confidence_resolver -p confidence-event-engine -p confidence-cloudflare-resolver -p spotify-confidence-openfeature-provider -build: wasm/confidence_resolver.wasm +build: wasm/confidence_resolver.wasm wasm/confidence_event_engine.wasm $(MAKE) -C openfeature-provider/js build $(MAKE) -C openfeature-provider/java build $(MAKE) -C openfeature-provider/go build diff --git a/confidence-event-engine/Cargo.toml b/confidence-event-engine/Cargo.toml new file mode 100644 index 000000000..6161e66b2 --- /dev/null +++ b/confidence-event-engine/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "confidence-event-engine" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +prost = { version = "0.13", default-features = false } +prost-types = { version = "0.13", default-features = false } +crossbeam-queue = { version = "0.3.12", default-features = false, features = ["alloc"] } + +[build-dependencies] +prost-build = "0.13" diff --git a/confidence-event-engine/Makefile b/confidence-event-engine/Makefile new file mode 100644 index 000000000..5f39f37b5 --- /dev/null +++ b/confidence-event-engine/Makefile @@ -0,0 +1,12 @@ +# confidence-event-engine Makefile + +.PHONY: test lint clean + +test: + cargo test -p confidence-event-engine + +lint: + cargo clippy -p confidence-event-engine --lib --release -- -D warnings + +clean: + cargo clean diff --git a/confidence-event-engine/README.md b/confidence-event-engine/README.md new file mode 100644 index 000000000..382077349 --- /dev/null +++ b/confidence-event-engine/README.md @@ -0,0 +1,88 @@ +# confidence-event-engine + +Batches OpenFeature `track()` events inside a WebAssembly module so every +server-side provider (JS, Java, Go, Python) shares one implementation of the +batching and payload-mapping rules. + +It mirrors the resolver's `AssignLogger`: a lock-free `SegQueue` for `track()`, +and a `Mutex`-guarded pending buffer for size-bounded flushes. + +``` +Provider.track(name, context, details) + | + v + confidence_event_engine.wasm + ├── wasm_msg_guest_track_event queue one event + └── wasm_msg_guest_bounded_flush_events drain <= 2 MB, return a batch + | + v +Provider publishes the batch (gRPC, or HTTP for JS) +``` + +## Payload mapping + +`build_payload` (in `wasm/event-guest`) merges the OpenFeature inputs into the +Confidence event payload in this order: + +1. `data` — the caller's custom fields +2. `value` — the OpenFeature numeric value +3. `context` — the evaluation context + +Order matters: **`value` and `context` are reserved keys and overwrite +same-named keys from `data`.** OpenFeature custom data may contain arbitrary +keys, so a caller passing `data: {"value": ...}` will see it replaced. This is +intentional and covered by unit tests in `wasm/event-guest/src/lib.rs`. + +`value` is `optional double` in the proto specifically so an explicit `0` is +distinguishable from "not set" — a plain `double` would conflate them. + +## Delivery guarantees + +**Events are delivered at-most-once, and a failed publish drops the batch.** + +Once `bounded_flush_events` returns, the events are gone from the WASM buffer. +If the subsequent publish fails, the provider logs and moves on; there is no +re-queue, dead-letter queue, or persistence. + +This matches the flag-log path, which behaves the same way. In both cases the +mitigations are transport-level rather than application-level: + +- **Retry.** gRPC providers attach a `retryPolicy` (3 attempts, 1s→10s backoff, + ×2, on `UNAVAILABLE`); JS retries at the fetch layer. So a transient blip is + usually absorbed before it reaches the drop path. +- **Observability.** Failures are counted and a warning is logged every 10 + attempts rather than once per failure, so a sustained outage is visible + without flooding logs. + +Consequences worth knowing before relying on this for billing-grade data: + +- A hard failure (bad credentials, wrong endpoint) silently drops every event + for as long as it persists — only the periodic warning surfaces it. +- Events buffered when the process dies uncleanly are lost. Shutdown drains up + to 100 batches, but a `SIGKILL` skips that entirely. + +## Known provider differences + +**Go cannot distinguish `value: 0` from an unset value.** Go's +`openfeature.TrackingEventDetails` stores `value` as a plain `float64` with no +"is set" flag, and `NewTrackingEventDetails(v)` is the only constructor. Java +(`Optional`) and JS (`number | undefined`) can tell them apart and +forward an explicit `0` correctly. + +The Go provider therefore treats `0` as unset and omits it. The alternative — +always sending — would attach a spurious `value: 0` to every event where the +caller set none, which is the far more common case. If you need to record a +zero-valued event from Go, put it in the custom data instead of `value`. + +## Build and test + +```bash +cargo test -p confidence-event-engine # batching, bounds, concurrency +cargo test -p event-guest # payload mapping and collision rules +make -C wasm/event-guest build # the wasm32 artifact +``` + +The committed binary embedded by the Go provider must be produced in Docker +(`make sync-wasm-event-go`); rustc bakes absolute source paths into panic +strings, so a host build is never byte-identical. CI enforces this via the +`openfeature-provider-go.validate-event-wasm` stage. diff --git a/confidence-event-engine/build.rs b/confidence-event-engine/build.rs new file mode 100644 index 000000000..c059f030d --- /dev/null +++ b/confidence-event-engine/build.rs @@ -0,0 +1,20 @@ +fn main() { + let mut config = prost_build::Config::new(); + config.protoc_arg("--experimental_allow_proto3_optional"); + + config.extern_path(".google.protobuf.Struct", "::prost_types::Struct"); + config.extern_path(".google.protobuf.Value", "::prost_types::Value"); + config.extern_path(".google.protobuf.ListValue", "::prost_types::ListValue"); + config.extern_path(".google.protobuf.NullValue", "::prost_types::NullValue"); + config.extern_path(".google.protobuf.Timestamp", "::prost_types::Timestamp"); + + config + .compile_protos( + &[ + "confidence/events/v1/types.proto", + "confidence/events/wasm/v1/wasm_api.proto", + ], + &["../openfeature-provider/proto"], + ) + .unwrap_or_else(|e| panic!("Failed to compile protos {:?}", e)); +} diff --git a/confidence-event-engine/src/event_logger.rs b/confidence-event-engine/src/event_logger.rs new file mode 100644 index 000000000..2bae48856 --- /dev/null +++ b/confidence-event-engine/src/event_logger.rs @@ -0,0 +1,249 @@ +use std::collections::VecDeque; +use std::sync::Mutex; + +use crate::proto::confidence::events::v1::Event; +use crate::proto::confidence::events::wasm::v1::FlushEventsResponse; +use prost::{length_delimiter_len, Message}; + +#[derive(Debug, Default)] +struct State { + pending: VecDeque<(Event, usize)>, + pending_bytes: usize, +} + +#[derive(Debug, Default)] +pub struct EventLogger { + queue: crossbeam_queue::SegQueue, + state: Mutex, +} + +impl EventLogger { + pub fn new() -> Self { + Self { + ..Default::default() + } + } + + pub fn track(&self, event: Event) { + self.queue.push(event); + } + + pub fn bounded_flush(&self, limit_bytes: usize, require_full: bool) -> FlushEventsResponse { + let mut req = FlushEventsResponse::default(); + self.flush_fill(&mut req, limit_bytes, require_full); + req + } + + pub fn flush_fill( + &self, + req: &mut FlushEventsResponse, + limit_bytes: usize, + require_full: bool, + ) -> usize { + let mut state = match self.state.lock() { + Ok(g) => g, + Err(err) => err.into_inner(), + }; + let start = req.encoded_len(); + let limit_bytes = limit_bytes.saturating_sub(start); + + while state.pending_bytes < limit_bytes { + if let Some(event) = self.queue.pop() { + let len = Self::encoded_len(&event); + state.pending.push_back((event, len)); + state.pending_bytes = state.pending_bytes.saturating_add(len); + } else { + break; + } + } + + let mut written: usize = 0; + if state.pending_bytes >= limit_bytes || !require_full { + while let Some((_, len)) = state.pending.front() { + let len = *len; + // A single event larger than the limit is emitted alone, but only + // as the very first event of an otherwise empty request. + let is_lone_oversized_event = written == 0 && start == 0; + if written.saturating_add(len) > limit_bytes && !is_lone_oversized_event { + break; + } + written = written.saturating_add(len); + if let Some((event, _)) = state.pending.pop_front() { + req.events.push(event); + } + } + state.pending_bytes = state.pending_bytes.saturating_sub(written); + } + written + } + + fn encoded_len(event: &Event) -> usize { + let len = event.encoded_len(); + len.saturating_add(length_delimiter_len(len)) + .saturating_add(1) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_event() -> Event { + Event { + event_definition: "eventDefinitions/test_event".to_string(), + event_time: Some(prost_types::Timestamp { + seconds: 1000, + nanos: 0, + }), + payload: None, + } + } + + fn make_event_with_payload() -> Event { + use prost_types::{value::Kind, Struct, Value}; + let mut fields = std::collections::BTreeMap::new(); + fields.insert( + "key".to_string(), + Value { + kind: Some(Kind::StringValue("value".to_string())), + }, + ); + Event { + event_definition: "eventDefinitions/rich_event".to_string(), + event_time: Some(prost_types::Timestamp { + seconds: 2000, + nanos: 500_000_000, + }), + payload: Some(Struct { fields }), + } + } + + #[test] + fn event_size_is_correctly_calculated() { + let ev = make_event(); + let ev_size = EventLogger::encoded_len(&ev); + let req = FlushEventsResponse { + events: vec![ev.clone(), ev], + }; + assert_eq!(2 * ev_size, req.encoded_len()); + } + + #[test] + fn flush_returns_all_events_when_under_limit() { + let logger = EventLogger::new(); + logger.track(make_event()); + logger.track(make_event_with_payload()); + let req = logger.bounded_flush(10_000, false); + assert_eq!(req.events.len(), 2); + } + + #[test] + fn flush_respects_byte_limit() { + let ev_size = EventLogger::encoded_len(&make_event()); + let logger = EventLogger::new(); + logger.track(make_event()); + logger.track(make_event()); + logger.track(make_event()); + let req = logger.bounded_flush(3 * ev_size - 1, true); + assert_eq!(req.events.len(), 2); + } + + #[test] + fn first_event_exceeding_limit_is_sent_alone() { + let logger = EventLogger::new(); + logger.track(make_event()); + logger.track(make_event()); + let req = logger.bounded_flush(1, true); + assert_eq!(req.events.len(), 1); + } + + #[test] + fn require_full_returns_empty_when_under_target() { + let logger = EventLogger::new(); + let req = logger.bounded_flush(10_000, true); + assert!(req.events.is_empty()); + } + + #[test] + fn pending_events_survive_across_flushes() { + let ev_size = EventLogger::encoded_len(&make_event()); + let logger = EventLogger::new(); + logger.track(make_event()); + logger.track(make_event()); + logger.track(make_event()); + let req1 = logger.bounded_flush(2 * ev_size, false); + assert_eq!(req1.events.len(), 2); + let req2 = logger.bounded_flush(10_000, false); + assert_eq!(req2.events.len(), 1); + } + + #[test] + fn empty_flush_returns_no_events() { + let logger = EventLogger::new(); + let req = logger.bounded_flush(10_000, false); + assert!(req.events.is_empty()); + } + + // The design claim is that track() is lock-free (SegQueue) and safe to call + // concurrently with a flush holding the state Mutex. WASM is single-threaded, + // but this crate is also compiled and tested on the host, so exercise it. + #[test] + fn concurrent_tracks_and_flushes_lose_no_events() { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + + const PRODUCERS: usize = 4; + const PER_PRODUCER: usize = 500; + + let logger = Arc::new(EventLogger::new()); + let done = Arc::new(AtomicBool::new(false)); + + let producers: Vec<_> = (0..PRODUCERS) + .map(|_| { + let logger = Arc::clone(&logger); + std::thread::spawn(move || { + for _ in 0..PER_PRODUCER { + logger.track(make_event()); + } + }) + }) + .collect(); + + // Drain concurrently with the producers. + let collector = { + let logger = Arc::clone(&logger); + let done = Arc::clone(&done); + std::thread::spawn(move || { + let mut seen = 0usize; + while !done.load(Ordering::Relaxed) { + seen += logger.bounded_flush(usize::MAX, false).events.len(); + } + seen + logger.bounded_flush(usize::MAX, false).events.len() + }) + }; + + for p in producers { + p.join().expect("producer panicked"); + } + done.store(true, Ordering::Relaxed); + let drained = collector.join().expect("collector panicked"); + + // Every tracked event must come out exactly once: no loss, no duplication. + assert_eq!(drained, PRODUCERS * PER_PRODUCER); + assert!(logger.bounded_flush(usize::MAX, false).events.is_empty()); + } + + #[test] + fn events_preserve_data() { + let logger = EventLogger::new(); + logger.track(make_event_with_payload()); + let req = logger.bounded_flush(10_000, false); + assert_eq!(req.events.len(), 1); + assert_eq!( + req.events[0].event_definition, + "eventDefinitions/rich_event" + ); + assert_eq!(req.events[0].event_time.as_ref().unwrap().seconds, 2000); + assert!(req.events[0].payload.is_some()); + } +} diff --git a/confidence-event-engine/src/lib.rs b/confidence-event-engine/src/lib.rs new file mode 100644 index 000000000..6bedb297c --- /dev/null +++ b/confidence-event-engine/src/lib.rs @@ -0,0 +1,16 @@ +pub mod event_logger; + +pub mod proto { + pub mod confidence { + pub mod events { + pub mod v1 { + include!(concat!(env!("OUT_DIR"), "/confidence.events.v1.rs")); + } + pub mod wasm { + pub mod v1 { + include!(concat!(env!("OUT_DIR"), "/confidence.events.wasm.v1.rs")); + } + } + } + } +} diff --git a/openfeature-provider/go/confidence/internal/event_tracking/assets/confidence_event_engine.wasm b/openfeature-provider/go/confidence/internal/event_tracking/assets/confidence_event_engine.wasm new file mode 100755 index 0000000000000000000000000000000000000000..f0fd87ca583d3b1017b5e2be4667eefb36efba11 GIT binary patch literal 60003 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

9_Bph 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 CkLzjx literal 0 HcmV?d00001 diff --git a/openfeature-provider/go/confidence/internal/event_tracking/embed.go b/openfeature-provider/go/confidence/internal/event_tracking/embed.go new file mode 100644 index 000000000..923acabf1 --- /dev/null +++ b/openfeature-provider/go/confidence/internal/event_tracking/embed.go @@ -0,0 +1,11 @@ +package event_tracking + +import _ "embed" + +// EventEngineWasm is the compiled event engine WASM module, embedded at build +// time so callers of the public provider API don't have to supply it. +// Kept in sync with wasm/event-guest via `make sync-wasm-event-go`; CI enforces +// that the committed bytes match a fresh Docker build. +// +//go:embed assets/confidence_event_engine.wasm +var EventEngineWasm []byte diff --git a/openfeature-provider/go/confidence/internal/event_tracking/event_tracker.go b/openfeature-provider/go/confidence/internal/event_tracking/event_tracker.go new file mode 100644 index 000000000..00a4533ac --- /dev/null +++ b/openfeature-provider/go/confidence/internal/event_tracking/event_tracker.go @@ -0,0 +1,262 @@ +// Package event_tracking provides a WASM-based event engine for tracking +// and flushing Confidence events via the wasm-msg protocol. +package event_tracking + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "sync" + + "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/eventswasm" + "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/wasm" + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" + "google.golang.org/protobuf/proto" +) + +var requiredExports = []string{ + "wasm_msg_alloc", + "wasm_msg_free", + "wasm_msg_guest_track_event", + "wasm_msg_guest_bounded_flush_events", +} + +// errWasmFatal marks errors that mean the WASM instance may be in an undefined +// state (a trap, or a failed memory/alloc operation) and must be reloaded. +// Client-side errors — proto marshal/unmarshal failures, or an error cleanly +// reported by the guest — are not fatal: the instance is still healthy and its +// buffered events must be preserved. +var errWasmFatal = errors.New("wasm instance is unusable") + +func fatalf(format string, args ...any) error { + return fmt.Errorf("%w: "+format, append([]any{errWasmFatal}, args...)...) +} + +// EventTracker wraps a WASM event engine instance and exposes TrackEvent and +// FlushEvents operations using the wasm-msg protocol. On a WASM trap the +// instance is transparently reloaded so the provider keeps functioning — +// buffered events in the crashed instance are lost, mirroring how +// RecoveringResolver handles the flag resolver. +type EventTracker struct { + runtime wazero.Runtime + module wazero.CompiledModule + instance api.Module + mu sync.Mutex + closed bool +} + +// NewEventTracker compiles and instantiates the event engine WASM module. +// The event engine has no host imports, so no host module is registered. +// If useInterpreter is true, wazero's interpreter mode is used instead of JIT. +func NewEventTracker(wasmBytes []byte, useInterpreter bool) (*EventTracker, error) { + ctx := context.Background() + + var runtime wazero.Runtime + if useInterpreter { + runtime = wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfigInterpreter()) + } else { + runtime = wazero.NewRuntime(ctx) + } + + module, err := runtime.CompileModule(ctx, wasmBytes) + if err != nil { + runtime.Close(ctx) + return nil, fmt.Errorf("failed to compile event engine WASM: %w", err) + } + + tracker := &EventTracker{runtime: runtime, module: module} + instance, err := tracker.newInstance(ctx) + if err != nil { + runtime.Close(ctx) + return nil, err + } + tracker.instance = instance + return tracker, nil +} + +// newInstance instantiates the compiled module and verifies required exports. +func (t *EventTracker) newInstance(ctx context.Context) (api.Module, error) { + instance, err := t.runtime.InstantiateModule(ctx, t.module, wazero.NewModuleConfig().WithName("")) + if err != nil { + return nil, fmt.Errorf("failed to instantiate event engine WASM: %w", err) + } + for _, name := range requiredExports { + if instance.ExportedFunction(name) == nil { + instance.Close(ctx) + return nil, fmt.Errorf("event engine WASM missing required export: %s", name) + } + } + return instance, nil +} + +// TrackEvent sends a track event request to the WASM event engine. +// The event is buffered internally; call FlushEvents to retrieve the batch. +func (t *EventTracker) TrackEvent(request *eventswasm.TrackEventRequest) error { + return t.call("wasm_msg_guest_track_event", request, nil) +} + +// FlushEvents retrieves all buffered events from the WASM event engine. +// Returns a FlushEventsResponse containing the batch of events ready for +// network transmission. The caller is responsible for wrapping them in a +// confidence.events.v1.PublishEventsRequest (adding client_secret, sdk info +// and send_time) before publishing. +func (t *EventTracker) FlushEvents() (*eventswasm.FlushEventsResponse, error) { + resp := &eventswasm.FlushEventsResponse{} + err := t.call("wasm_msg_guest_bounded_flush_events", nil, resp) + return resp, err +} + +// Close releases the WASM instance and runtime resources. +func (t *EventTracker) Close() error { + t.mu.Lock() + defer t.mu.Unlock() + + if t.closed { + return nil + } + t.closed = true + + ctx := context.Background() + var instanceErr, runtimeErr error + if t.instance != nil { + instanceErr = t.instance.Close(ctx) + } + if t.runtime != nil { + runtimeErr = t.runtime.Close(ctx) + } + return errors.Join(instanceErr, runtimeErr) +} + +// call implements the wasm-msg protocol: marshal request into a Request envelope, +// allocate WASM memory, write, call the export, read the Response envelope, free. +// On a WASM trap the instance is reloaded and the error returned; the next call +// runs against the fresh instance. +func (t *EventTracker) call(fnName string, request proto.Message, response proto.Message) error { + t.mu.Lock() + defer t.mu.Unlock() + + if t.closed { + return errors.New("event tracker is closed") + } + + err := t.callLocked(fnName, request, response) + if errors.Is(err, errWasmFatal) { + // The instance may be in an undefined state; reload it. Buffered events + // in the crashed instance are lost, as with RecoveringResolver. + t.reloadLocked() + } + return err +} + +// reloadLocked replaces a trapped WASM instance with a fresh one. +// Buffered events in the old instance are lost. Caller must hold t.mu. +func (t *EventTracker) reloadLocked() { + ctx := context.Background() + if t.instance != nil { + _ = t.instance.Close(ctx) + t.instance = nil + } + instance, err := t.newInstance(ctx) + if err != nil { + // Leave instance nil — subsequent calls fail fast until Close. + return + } + t.instance = instance +} + +func (t *EventTracker) callLocked(fnName string, request proto.Message, response proto.Message) error { + if t.instance == nil { + return errors.New("event tracker has no live WASM instance") + } + + reqPtr := uint32(0) + if request != nil { + innerBytes, err := proto.Marshal(request) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + envelopeBytes, err := proto.Marshal(&wasm.Request{Data: innerBytes}) + if err != nil { + return fmt.Errorf("failed to marshal request envelope: %w", err) + } + reqPtr, err = t.allocAndWrite(envelopeBytes) + if err != nil { + return err + } + } + + ctx := context.Background() + fn := t.instance.ExportedFunction(fnName) + if fn == nil { + return fatalf("exported function %s not found", fnName) + } + + resPtr, err := fn.Call(ctx, uint64(reqPtr)) + if err != nil { + return fatalf("WASM call %s failed: %v", fnName, err) + } + + if resPtr[0] == 0 { + return nil + } + + resBytes, err := t.readAndFree(uint32(resPtr[0])) + if err != nil { + return err + } + resEnvelope := &wasm.Response{} + if err := proto.Unmarshal(resBytes, resEnvelope); err != nil { + return fmt.Errorf("failed to unmarshal response envelope: %w", err) + } + if errMsg := resEnvelope.GetError(); errMsg != "" { + return errors.New(errMsg) + } + if response != nil { + if err := proto.Unmarshal(resEnvelope.GetData(), response); err != nil { + return fmt.Errorf("failed to unmarshal response: %w", err) + } + } + return nil +} + +// allocAndWrite allocates WASM memory and writes data into it. +func (t *EventTracker) allocAndWrite(data []byte) (uint32, error) { + ctx := context.Background() + results, err := t.instance.ExportedFunction("wasm_msg_alloc").Call(ctx, uint64(len(data))) + if err != nil { + return 0, fatalf("wasm_msg_alloc failed: %v", err) + } + addr := uint32(results[0]) + if !t.instance.Memory().Write(addr, data) { + return 0, fatalf("failed to write request into WASM memory") + } + return addr, nil +} + +// readAndFree reads data from WASM memory and frees the allocation. +// The wasm-msg protocol stores a 4-byte little-endian length prefix at addr-4, +// where the length includes the 4-byte prefix itself. +func (t *EventTracker) readAndFree(addr uint32) ([]byte, error) { + memory := t.instance.Memory() + + lenBytes, ok := memory.Read(addr-4, 4) + if !ok { + return nil, fatalf("failed to read buffer length from WASM memory") + } + length := binary.LittleEndian.Uint32(lenBytes) - 4 + + data, ok := memory.Read(addr, length) + if !ok { + return nil, fatalf("failed to read buffer data from WASM memory") + } + dataCopy := make([]byte, length) + copy(dataCopy, data) + + ctx := context.Background() + if _, err := t.instance.ExportedFunction("wasm_msg_free").Call(ctx, uint64(addr)); err != nil { + return nil, fatalf("wasm_msg_free failed: %v", err) + } + return dataCopy, nil +} diff --git a/openfeature-provider/go/confidence/internal/event_tracking/event_tracker_test.go b/openfeature-provider/go/confidence/internal/event_tracking/event_tracker_test.go new file mode 100644 index 000000000..d9c320d7c --- /dev/null +++ b/openfeature-provider/go/confidence/internal/event_tracking/event_tracker_test.go @@ -0,0 +1,116 @@ +package event_tracking + +import ( + "errors" + "os" + "testing" + + "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/eventswasm" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func loadTracker(t *testing.T) *EventTracker { + t.Helper() + wasmBytes, err := os.ReadFile("assets/confidence_event_engine.wasm") + if err != nil { + t.Fatalf("read embedded WASM: %v", err) + } + tracker, err := NewEventTracker(wasmBytes, false) + if err != nil { + t.Fatalf("NewEventTracker: %v", err) + } + t.Cleanup(func() { _ = tracker.Close() }) + return tracker +} + +func TestTrackAndFlushAppliesEventDefinitionPrefix(t *testing.T) { + tracker := loadTracker(t) + + err := tracker.TrackEvent(&eventswasm.TrackEventRequest{ + EventName: "my_event", + EventTime: timestamppb.Now(), + }) + if err != nil { + t.Fatalf("TrackEvent: %v", err) + } + + batch, err := tracker.FlushEvents() + if err != nil { + t.Fatalf("FlushEvents: %v", err) + } + if got := len(batch.GetEvents()); got != 1 { + t.Fatalf("expected 1 event, got %d", got) + } + if got := batch.GetEvents()[0].GetEventDefinition(); got != "eventDefinitions/my_event" { + t.Errorf("event_definition = %q, want %q", got, "eventDefinitions/my_event") + } +} + +func TestFlushIsIdempotentWhenEmpty(t *testing.T) { + tracker := loadTracker(t) + + batch, err := tracker.FlushEvents() + if err != nil { + t.Fatalf("FlushEvents: %v", err) + } + if got := len(batch.GetEvents()); got != 0 { + t.Errorf("expected empty batch, got %d events", got) + } +} + +// A reload discards every event buffered inside the instance, so only a genuine +// WASM trap or memory failure may trigger one. A client-side decode failure must +// leave the instance untouched. +func TestNonFatalErrorDoesNotReloadInstance(t *testing.T) { + tracker := loadTracker(t) + + // Buffer a real event so the flush response carries bytes that cannot decode + // as a TrackEventRequest (field 1 there is a UTF-8 string, here it is a + // nested Event message). An empty response would decode into either type. + if err := tracker.TrackEvent(&eventswasm.TrackEventRequest{ + EventName: "decode_mismatch_probe", + EventTime: timestamppb.Now(), + }); err != nil { + t.Fatalf("TrackEvent: %v", err) + } + + before := tracker.instance + + // Decoding the guest's FlushEventsResponse bytes into an unrelated message + // fails client-side. The instance itself is perfectly healthy. + err := tracker.call("wasm_msg_guest_bounded_flush_events", nil, &eventswasm.TrackEventRequest{}) + if err == nil { + t.Fatal("expected a decode error when reading the flush response as the wrong type") + } + if errors.Is(err, errWasmFatal) { + t.Fatalf("a decode failure must not be classified fatal, got %v", err) + } + if tracker.instance != before { + t.Error("instance was reloaded on a non-fatal error, discarding buffered events") + } +} + +// A missing export means the module is not what we expect: that is fatal and +// must reload. +func TestFatalErrorReloadsInstance(t *testing.T) { + tracker := loadTracker(t) + before := tracker.instance + + err := tracker.call("wasm_msg_guest_does_not_exist", nil, nil) + if !errors.Is(err, errWasmFatal) { + t.Fatalf("expected a fatal error, got %v", err) + } + if tracker.instance == before { + t.Error("instance was not reloaded after a fatal error") + } +} + +func TestClosedTrackerRejectsCalls(t *testing.T) { + tracker := loadTracker(t) + if err := tracker.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if err := tracker.TrackEvent(&eventswasm.TrackEventRequest{EventName: "x"}); err == nil { + t.Error("expected an error after Close") + } +} 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 000000000..233a89f7d --- /dev/null +++ b/openfeature-provider/go/confidence/internal/proto/events/api.pb.go @@ -0,0 +1,218 @@ +// 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) +) + +// Request to publish events. +type PublishEventsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The client secret used to authenticate the request, on the format [A-Za-z0-9]+. + ClientSecret string `protobuf:"bytes,1,opt,name=client_secret,json=clientSecret,proto3" json:"client_secret,omitempty"` + // The list of events to publish. + Events []*Event `protobuf:"bytes,2,rep,name=events,proto3" json:"events,omitempty"` + // The client time when the request was sent. + SendTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=send_time,json=sendTime,proto3" json:"send_time,omitempty"` + // Information about the SDK used to initiate the request. + Sdk *Sdk `protobuf:"bytes,4,opt,name=sdk,proto3" json:"sdk,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 +} + +func (x *PublishEventsRequest) GetSdk() *Sdk { + if x != nil { + return x.Sdk + } + return nil +} + +// Response of the publish events call. +type PublishEventsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Possible errors that occurred during the publish request. + 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\"\xd6\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\x12+\n" + + "\x03sdk\x18\x04 \x01(\v2\x19.confidence.events.v1.SdkR\x03sdk\"Q\n" + + "\x15PublishEventsResponse\x128\n" + + "\x06errors\x18\x01 \x03(\v2 .confidence.events.v1.EventErrorR\x06errors2y\n" + + "\rEventsService\x12h\n" + + "\rPublishEvents\x12*.confidence.events.v1.PublishEventsRequest\x1a+.confidence.events.v1.PublishEventsResponseB\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 + (*Sdk)(nil), // 4: confidence.events.v1.Sdk + (*EventError)(nil), // 5: 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.PublishEventsRequest.sdk:type_name -> confidence.events.v1.Sdk + 5, // 3: confidence.events.v1.PublishEventsResponse.errors:type_name -> confidence.events.v1.EventError + 0, // 4: confidence.events.v1.EventsService.PublishEvents:input_type -> confidence.events.v1.PublishEventsRequest + 1, // 5: confidence.events.v1.EventsService.PublishEvents:output_type -> confidence.events.v1.PublishEventsResponse + 5, // [5:6] is the sub-list for method output_type + 4, // [4:5] 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_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 000000000..d166a3297 --- /dev/null +++ b/openfeature-provider/go/confidence/internal/proto/events/api_grpc.pb.go @@ -0,0 +1,147 @@ +// 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. +// +// Mirrors confidence/events/v1/api.proto from the Confidence events service. +// Annotations removed for minimal dependencies (same convention as internal_api.proto). +// Two transports are available, both verified against the live service: +// - gRPC via the Spotify edge (edge-grpc.spotify.com), same host as +// InternalFlagLoggerService. Used by the Go, Java and Python providers. +// - HTTP POST to events.confidence.dev/v1/events:publish, which accepts +// either application/json or application/x-protobuf. Used by the JS +// provider, which has no gRPC transport. +// +// Note that events.confidence.dev serves only the HTTP form: a raw gRPC call +// to that host returns Unimplemented. +type EventsServiceClient interface { + // Publish events to the Confidence event stream. + 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. +// +// Mirrors confidence/events/v1/api.proto from the Confidence events service. +// Annotations removed for minimal dependencies (same convention as internal_api.proto). +// Two transports are available, both verified against the live service: +// - gRPC via the Spotify edge (edge-grpc.spotify.com), same host as +// InternalFlagLoggerService. Used by the Go, Java and Python providers. +// - HTTP POST to events.confidence.dev/v1/events:publish, which accepts +// either application/json or application/x-protobuf. Used by the JS +// provider, which has no gRPC transport. +// +// Note that events.confidence.dev serves only the HTTP form: a raw gRPC call +// to that host returns Unimplemented. +type EventsServiceServer interface { + // Publish events to the Confidence event stream. + 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 000000000..1b0b9310b --- /dev/null +++ b/openfeature-provider/go/confidence/internal/proto/events/types.pb.go @@ -0,0 +1,548 @@ +// 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) +) + +// The SDK used to interact with the API. +type SdkId int32 + +const ( + SdkId_SDK_ID_UNSPECIFIED SdkId = 0 + SdkId_SDK_ID_JAVA_PROVIDER SdkId = 1 + SdkId_SDK_ID_KOTLIN_PROVIDER SdkId = 2 + SdkId_SDK_ID_SWIFT_PROVIDER SdkId = 3 + SdkId_SDK_ID_JS_WEB_PROVIDER SdkId = 4 + SdkId_SDK_ID_JS_SERVER_PROVIDER SdkId = 5 + SdkId_SDK_ID_PYTHON_PROVIDER SdkId = 6 + SdkId_SDK_ID_GO_PROVIDER SdkId = 7 + SdkId_SDK_ID_RUBY_PROVIDER SdkId = 8 + SdkId_SDK_ID_RUST_PROVIDER SdkId = 9 + SdkId_SDK_ID_JAVA_CONFIDENCE SdkId = 10 + SdkId_SDK_ID_KOTLIN_CONFIDENCE SdkId = 11 + SdkId_SDK_ID_SWIFT_CONFIDENCE SdkId = 12 + SdkId_SDK_ID_JS_CONFIDENCE SdkId = 13 + SdkId_SDK_ID_PYTHON_CONFIDENCE SdkId = 14 + SdkId_SDK_ID_GO_CONFIDENCE SdkId = 15 + SdkId_SDK_ID_RUST_CONFIDENCE SdkId = 16 + SdkId_SDK_ID_FLUTTER_IOS_CONFIDENCE SdkId = 17 + SdkId_SDK_ID_FLUTTER_ANDROID_CONFIDENCE SdkId = 18 + SdkId_SDK_ID_DOTNET_CONFIDENCE SdkId = 19 + // Confidence OpenFeature Go Local Provider. + SdkId_SDK_ID_GO_LOCAL_PROVIDER SdkId = 20 + // Confidence OpenFeature Java Local Provider. + SdkId_SDK_ID_JAVA_LOCAL_PROVIDER SdkId = 21 + // Confidence OpenFeature JavaScript Local Server Provider. + SdkId_SDK_ID_JS_LOCAL_SERVER_PROVIDER SdkId = 22 + // Confidence OpenFeature Python Local Provider. + SdkId_SDK_ID_PYTHON_LOCAL_PROVIDER SdkId = 23 + // Confidence OpenFeature Rust Local Provider. + SdkId_SDK_ID_RUST_LOCAL_PROVIDER SdkId = 24 + // Confidence Cloudflare Resolver. + SdkId_SDK_ID_CLOUDFLARE_RESOLVER SdkId = 25 + // Confidence OpenFeature PHP Provider. + SdkId_SDK_ID_PHP_PROVIDER SdkId = 26 +) + +// Enum value maps for SdkId. +var ( + SdkId_name = map[int32]string{ + 0: "SDK_ID_UNSPECIFIED", + 1: "SDK_ID_JAVA_PROVIDER", + 2: "SDK_ID_KOTLIN_PROVIDER", + 3: "SDK_ID_SWIFT_PROVIDER", + 4: "SDK_ID_JS_WEB_PROVIDER", + 5: "SDK_ID_JS_SERVER_PROVIDER", + 6: "SDK_ID_PYTHON_PROVIDER", + 7: "SDK_ID_GO_PROVIDER", + 8: "SDK_ID_RUBY_PROVIDER", + 9: "SDK_ID_RUST_PROVIDER", + 10: "SDK_ID_JAVA_CONFIDENCE", + 11: "SDK_ID_KOTLIN_CONFIDENCE", + 12: "SDK_ID_SWIFT_CONFIDENCE", + 13: "SDK_ID_JS_CONFIDENCE", + 14: "SDK_ID_PYTHON_CONFIDENCE", + 15: "SDK_ID_GO_CONFIDENCE", + 16: "SDK_ID_RUST_CONFIDENCE", + 17: "SDK_ID_FLUTTER_IOS_CONFIDENCE", + 18: "SDK_ID_FLUTTER_ANDROID_CONFIDENCE", + 19: "SDK_ID_DOTNET_CONFIDENCE", + 20: "SDK_ID_GO_LOCAL_PROVIDER", + 21: "SDK_ID_JAVA_LOCAL_PROVIDER", + 22: "SDK_ID_JS_LOCAL_SERVER_PROVIDER", + 23: "SDK_ID_PYTHON_LOCAL_PROVIDER", + 24: "SDK_ID_RUST_LOCAL_PROVIDER", + 25: "SDK_ID_CLOUDFLARE_RESOLVER", + 26: "SDK_ID_PHP_PROVIDER", + } + SdkId_value = map[string]int32{ + "SDK_ID_UNSPECIFIED": 0, + "SDK_ID_JAVA_PROVIDER": 1, + "SDK_ID_KOTLIN_PROVIDER": 2, + "SDK_ID_SWIFT_PROVIDER": 3, + "SDK_ID_JS_WEB_PROVIDER": 4, + "SDK_ID_JS_SERVER_PROVIDER": 5, + "SDK_ID_PYTHON_PROVIDER": 6, + "SDK_ID_GO_PROVIDER": 7, + "SDK_ID_RUBY_PROVIDER": 8, + "SDK_ID_RUST_PROVIDER": 9, + "SDK_ID_JAVA_CONFIDENCE": 10, + "SDK_ID_KOTLIN_CONFIDENCE": 11, + "SDK_ID_SWIFT_CONFIDENCE": 12, + "SDK_ID_JS_CONFIDENCE": 13, + "SDK_ID_PYTHON_CONFIDENCE": 14, + "SDK_ID_GO_CONFIDENCE": 15, + "SDK_ID_RUST_CONFIDENCE": 16, + "SDK_ID_FLUTTER_IOS_CONFIDENCE": 17, + "SDK_ID_FLUTTER_ANDROID_CONFIDENCE": 18, + "SDK_ID_DOTNET_CONFIDENCE": 19, + "SDK_ID_GO_LOCAL_PROVIDER": 20, + "SDK_ID_JAVA_LOCAL_PROVIDER": 21, + "SDK_ID_JS_LOCAL_SERVER_PROVIDER": 22, + "SDK_ID_PYTHON_LOCAL_PROVIDER": 23, + "SDK_ID_RUST_LOCAL_PROVIDER": 24, + "SDK_ID_CLOUDFLARE_RESOLVER": 25, + "SDK_ID_PHP_PROVIDER": 26, + } +) + +func (x SdkId) Enum() *SdkId { + p := new(SdkId) + *p = x + return p +} + +func (x SdkId) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (SdkId) Descriptor() protoreflect.EnumDescriptor { + return file_confidence_events_v1_types_proto_enumTypes[0].Descriptor() +} + +func (SdkId) Type() protoreflect.EnumType { + return &file_confidence_events_v1_types_proto_enumTypes[0] +} + +func (x SdkId) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use SdkId.Descriptor instead. +func (SdkId) EnumDescriptor() ([]byte, []int) { + return file_confidence_events_v1_types_proto_rawDescGZIP(), []int{0} +} + +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 + EventError_EVENT_DEFINITION_INVALID_NAME EventError_Reason = 3 +) + +// 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", + 3: "EVENT_DEFINITION_INVALID_NAME", + } + EventError_Reason_value = map[string]int32{ + "REASON_UNSPECIFIED": 0, + "EVENT_DEFINITION_NOT_FOUND": 1, + "EVENT_SCHEMA_VALIDATION_FAILED": 2, + "EVENT_DEFINITION_INVALID_NAME": 3, + } +) + +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[1].Descriptor() +} + +func (EventError_Reason) Type() protoreflect.EnumType { + return &file_confidence_events_v1_types_proto_enumTypes[1] +} + +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} +} + +// The event that you want to publish. +type Event struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Reference to the definition of the event that you want to publish, on the + // format `eventDefinitions/{event_definition_id}`. + EventDefinition string `protobuf:"bytes,1,opt,name=event_definition,json=eventDefinition,proto3" json:"event_definition,omitempty"` + // The json payload of the event. Empty nested records will be discarded. + Payload *structpb.Struct `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` + // The client time when the event occurred. + 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 +} + +// Description of an error that occurred during publish. +type EventError struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The index (zero based) of the event in the request that could not be published. + Index int32 `protobuf:"varint,1,opt,name=index,proto3" json:"index,omitempty"` + // The reason for why the event could not be ingested. + Reason EventError_Reason `protobuf:"varint,2,opt,name=reason,proto3,enum=confidence.events.v1.EventError_Reason" json:"reason,omitempty"` + // An optional, human-readable error message set for certain error types. + 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 "" +} + +type Sdk struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Sdk: + // + // *Sdk_Id + // *Sdk_CustomId + Sdk isSdk_Sdk `protobuf_oneof:"sdk"` + // Version of the SDK. + Version string `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Sdk) Reset() { + *x = Sdk{} + mi := &file_confidence_events_v1_types_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Sdk) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Sdk) ProtoMessage() {} + +func (x *Sdk) ProtoReflect() protoreflect.Message { + mi := &file_confidence_events_v1_types_proto_msgTypes[2] + 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 Sdk.ProtoReflect.Descriptor instead. +func (*Sdk) Descriptor() ([]byte, []int) { + return file_confidence_events_v1_types_proto_rawDescGZIP(), []int{2} +} + +func (x *Sdk) GetSdk() isSdk_Sdk { + if x != nil { + return x.Sdk + } + return nil +} + +func (x *Sdk) GetId() SdkId { + if x != nil { + if x, ok := x.Sdk.(*Sdk_Id); ok { + return x.Id + } + } + return SdkId_SDK_ID_UNSPECIFIED +} + +func (x *Sdk) GetCustomId() string { + if x != nil { + if x, ok := x.Sdk.(*Sdk_CustomId); ok { + return x.CustomId + } + } + return "" +} + +func (x *Sdk) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +type isSdk_Sdk interface { + isSdk_Sdk() +} + +type Sdk_Id struct { + // Name of a Confidence SDK. + Id SdkId `protobuf:"varint,1,opt,name=id,proto3,enum=confidence.events.v1.SdkId,oneof"` +} + +type Sdk_CustomId struct { + // Custom name for non-Confidence SDKs. + CustomId string `protobuf:"bytes,2,opt,name=custom_id,json=customId,proto3,oneof"` +} + +func (*Sdk_Id) isSdk_Sdk() {} + +func (*Sdk_CustomId) isSdk_Sdk() {} + +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\"\x87\x02\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\"\x87\x01\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\x02\x12!\n" + + "\x1dEVENT_DEFINITION_INVALID_NAME\x10\x03\"t\n" + + "\x03Sdk\x12-\n" + + "\x02id\x18\x01 \x01(\x0e2\x1b.confidence.events.v1.SdkIdH\x00R\x02id\x12\x1d\n" + + "\tcustom_id\x18\x02 \x01(\tH\x00R\bcustomId\x12\x18\n" + + "\aversion\x18\x03 \x01(\tR\aversionB\x05\n" + + "\x03sdk*\x9e\x06\n" + + "\x05SdkId\x12\x16\n" + + "\x12SDK_ID_UNSPECIFIED\x10\x00\x12\x18\n" + + "\x14SDK_ID_JAVA_PROVIDER\x10\x01\x12\x1a\n" + + "\x16SDK_ID_KOTLIN_PROVIDER\x10\x02\x12\x19\n" + + "\x15SDK_ID_SWIFT_PROVIDER\x10\x03\x12\x1a\n" + + "\x16SDK_ID_JS_WEB_PROVIDER\x10\x04\x12\x1d\n" + + "\x19SDK_ID_JS_SERVER_PROVIDER\x10\x05\x12\x1a\n" + + "\x16SDK_ID_PYTHON_PROVIDER\x10\x06\x12\x16\n" + + "\x12SDK_ID_GO_PROVIDER\x10\a\x12\x18\n" + + "\x14SDK_ID_RUBY_PROVIDER\x10\b\x12\x18\n" + + "\x14SDK_ID_RUST_PROVIDER\x10\t\x12\x1a\n" + + "\x16SDK_ID_JAVA_CONFIDENCE\x10\n" + + "\x12\x1c\n" + + "\x18SDK_ID_KOTLIN_CONFIDENCE\x10\v\x12\x1b\n" + + "\x17SDK_ID_SWIFT_CONFIDENCE\x10\f\x12\x18\n" + + "\x14SDK_ID_JS_CONFIDENCE\x10\r\x12\x1c\n" + + "\x18SDK_ID_PYTHON_CONFIDENCE\x10\x0e\x12\x18\n" + + "\x14SDK_ID_GO_CONFIDENCE\x10\x0f\x12\x1a\n" + + "\x16SDK_ID_RUST_CONFIDENCE\x10\x10\x12!\n" + + "\x1dSDK_ID_FLUTTER_IOS_CONFIDENCE\x10\x11\x12%\n" + + "!SDK_ID_FLUTTER_ANDROID_CONFIDENCE\x10\x12\x12\x1c\n" + + "\x18SDK_ID_DOTNET_CONFIDENCE\x10\x13\x12\x1c\n" + + "\x18SDK_ID_GO_LOCAL_PROVIDER\x10\x14\x12\x1e\n" + + "\x1aSDK_ID_JAVA_LOCAL_PROVIDER\x10\x15\x12#\n" + + "\x1fSDK_ID_JS_LOCAL_SERVER_PROVIDER\x10\x16\x12 \n" + + "\x1cSDK_ID_PYTHON_LOCAL_PROVIDER\x10\x17\x12\x1e\n" + + "\x1aSDK_ID_RUST_LOCAL_PROVIDER\x10\x18\x12\x1e\n" + + "\x1aSDK_ID_CLOUDFLARE_RESOLVER\x10\x19\x12\x17\n" + + "\x13SDK_ID_PHP_PROVIDER\x10\x1aB\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, 2) +var file_confidence_events_v1_types_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_confidence_events_v1_types_proto_goTypes = []any{ + (SdkId)(0), // 0: confidence.events.v1.SdkId + (EventError_Reason)(0), // 1: confidence.events.v1.EventError.Reason + (*Event)(nil), // 2: confidence.events.v1.Event + (*EventError)(nil), // 3: confidence.events.v1.EventError + (*Sdk)(nil), // 4: confidence.events.v1.Sdk + (*structpb.Struct)(nil), // 5: google.protobuf.Struct + (*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp +} +var file_confidence_events_v1_types_proto_depIdxs = []int32{ + 5, // 0: confidence.events.v1.Event.payload:type_name -> google.protobuf.Struct + 6, // 1: confidence.events.v1.Event.event_time:type_name -> google.protobuf.Timestamp + 1, // 2: confidence.events.v1.EventError.reason:type_name -> confidence.events.v1.EventError.Reason + 0, // 3: confidence.events.v1.Sdk.id:type_name -> confidence.events.v1.SdkId + 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_events_v1_types_proto_init() } +func file_confidence_events_v1_types_proto_init() { + if File_confidence_events_v1_types_proto != nil { + return + } + file_confidence_events_v1_types_proto_msgTypes[2].OneofWrappers = []any{ + (*Sdk_Id)(nil), + (*Sdk_CustomId)(nil), + } + 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: 2, + NumMessages: 3, + 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/eventswasm/wasm_api.pb.go b/openfeature-provider/go/confidence/internal/proto/eventswasm/wasm_api.pb.go new file mode 100644 index 000000000..a12097056 --- /dev/null +++ b/openfeature-provider/go/confidence/internal/proto/eventswasm/wasm_api.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/wasm/v1/wasm_api.proto + +// WASM-internal messages for the event engine. Kept in a separate package from +// confidence.events.v1 so these never collide with the canonical events API types. + +package eventswasm + +import ( + events "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/events" + 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 Void struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Void) Reset() { + *x = Void{} + mi := &file_confidence_events_wasm_v1_wasm_api_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Void) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Void) ProtoMessage() {} + +func (x *Void) ProtoReflect() protoreflect.Message { + mi := &file_confidence_events_wasm_v1_wasm_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 Void.ProtoReflect.Descriptor instead. +func (*Void) Descriptor() ([]byte, []int) { + return file_confidence_events_wasm_v1_wasm_api_proto_rawDescGZIP(), []int{0} +} + +// OpenFeature-shaped input: what providers send to the WASM on track(). +// The WASM prepends "eventDefinitions/" to event_name and transforms this into +// a confidence.events.v1.Event for the network batch. +type TrackEventRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Bare event name, e.g. "my_event" (WASM prepends "eventDefinitions/"). + EventName string `protobuf:"bytes,1,opt,name=event_name,json=eventName,proto3" json:"event_name,omitempty"` + // When the event occurred (set by the provider). + EventTime *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=event_time,json=eventTime,proto3" json:"event_time,omitempty"` + // OpenFeature tracking event details: optional numeric value. + Value *float64 `protobuf:"fixed64,3,opt,name=value,proto3,oneof" json:"value,omitempty"` + // OpenFeature evaluation context. + Context *structpb.Struct `protobuf:"bytes,4,opt,name=context,proto3" json:"context,omitempty"` + // OpenFeature tracking event custom data. + Data *structpb.Struct `protobuf:"bytes,5,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TrackEventRequest) Reset() { + *x = TrackEventRequest{} + mi := &file_confidence_events_wasm_v1_wasm_api_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TrackEventRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TrackEventRequest) ProtoMessage() {} + +func (x *TrackEventRequest) ProtoReflect() protoreflect.Message { + mi := &file_confidence_events_wasm_v1_wasm_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 TrackEventRequest.ProtoReflect.Descriptor instead. +func (*TrackEventRequest) Descriptor() ([]byte, []int) { + return file_confidence_events_wasm_v1_wasm_api_proto_rawDescGZIP(), []int{1} +} + +func (x *TrackEventRequest) GetEventName() string { + if x != nil { + return x.EventName + } + return "" +} + +func (x *TrackEventRequest) GetEventTime() *timestamppb.Timestamp { + if x != nil { + return x.EventTime + } + return nil +} + +func (x *TrackEventRequest) GetValue() float64 { + if x != nil && x.Value != nil { + return *x.Value + } + return 0 +} + +func (x *TrackEventRequest) GetContext() *structpb.Struct { + if x != nil { + return x.Context + } + return nil +} + +func (x *TrackEventRequest) GetData() *structpb.Struct { + if x != nil { + return x.Data + } + return nil +} + +// Batch of events drained from the WASM. Providers wrap these in a +// confidence.events.v1.PublishEventsRequest, adding client_secret, send_time +// and sdk, then send via the EventsService gRPC. +type FlushEventsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Events []*events.Event `protobuf:"bytes,1,rep,name=events,proto3" json:"events,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FlushEventsResponse) Reset() { + *x = FlushEventsResponse{} + mi := &file_confidence_events_wasm_v1_wasm_api_proto_msgTypes[2] + 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_events_wasm_v1_wasm_api_proto_msgTypes[2] + 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_events_wasm_v1_wasm_api_proto_rawDescGZIP(), []int{2} +} + +func (x *FlushEventsResponse) GetEvents() []*events.Event { + if x != nil { + return x.Events + } + return nil +} + +var File_confidence_events_wasm_v1_wasm_api_proto protoreflect.FileDescriptor + +const file_confidence_events_wasm_v1_wasm_api_proto_rawDesc = "" + + "\n" + + "(confidence/events/wasm/v1/wasm_api.proto\x12\x19confidence.events.wasm.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a confidence/events/v1/types.proto\"\x06\n" + + "\x04Void\"\xf2\x01\n" + + "\x11TrackEventRequest\x12\x1d\n" + + "\n" + + "event_name\x18\x01 \x01(\tR\teventName\x129\n" + + "\n" + + "event_time\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\teventTime\x12\x19\n" + + "\x05value\x18\x03 \x01(\x01H\x00R\x05value\x88\x01\x01\x121\n" + + "\acontext\x18\x04 \x01(\v2\x17.google.protobuf.StructR\acontext\x12+\n" + + "\x04data\x18\x05 \x01(\v2\x17.google.protobuf.StructR\x04dataB\b\n" + + "\x06_value\"J\n" + + "\x13FlushEventsResponse\x123\n" + + "\x06events\x18\x01 \x03(\v2\x1b.confidence.events.v1.EventR\x06eventsB\xa0\x01\n" + + ")com.spotify.confidence.sdk.events.wasm.v1B\fWasmApiProtoP\x01Zcgithub.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/eventswasmb\x06proto3" + +var ( + file_confidence_events_wasm_v1_wasm_api_proto_rawDescOnce sync.Once + file_confidence_events_wasm_v1_wasm_api_proto_rawDescData []byte +) + +func file_confidence_events_wasm_v1_wasm_api_proto_rawDescGZIP() []byte { + file_confidence_events_wasm_v1_wasm_api_proto_rawDescOnce.Do(func() { + file_confidence_events_wasm_v1_wasm_api_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_confidence_events_wasm_v1_wasm_api_proto_rawDesc), len(file_confidence_events_wasm_v1_wasm_api_proto_rawDesc))) + }) + return file_confidence_events_wasm_v1_wasm_api_proto_rawDescData +} + +var file_confidence_events_wasm_v1_wasm_api_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_confidence_events_wasm_v1_wasm_api_proto_goTypes = []any{ + (*Void)(nil), // 0: confidence.events.wasm.v1.Void + (*TrackEventRequest)(nil), // 1: confidence.events.wasm.v1.TrackEventRequest + (*FlushEventsResponse)(nil), // 2: confidence.events.wasm.v1.FlushEventsResponse + (*timestamppb.Timestamp)(nil), // 3: google.protobuf.Timestamp + (*structpb.Struct)(nil), // 4: google.protobuf.Struct + (*events.Event)(nil), // 5: confidence.events.v1.Event +} +var file_confidence_events_wasm_v1_wasm_api_proto_depIdxs = []int32{ + 3, // 0: confidence.events.wasm.v1.TrackEventRequest.event_time:type_name -> google.protobuf.Timestamp + 4, // 1: confidence.events.wasm.v1.TrackEventRequest.context:type_name -> google.protobuf.Struct + 4, // 2: confidence.events.wasm.v1.TrackEventRequest.data:type_name -> google.protobuf.Struct + 5, // 3: confidence.events.wasm.v1.FlushEventsResponse.events:type_name -> confidence.events.v1.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_events_wasm_v1_wasm_api_proto_init() } +func file_confidence_events_wasm_v1_wasm_api_proto_init() { + if File_confidence_events_wasm_v1_wasm_api_proto != nil { + return + } + file_confidence_events_wasm_v1_wasm_api_proto_msgTypes[1].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_confidence_events_wasm_v1_wasm_api_proto_rawDesc), len(file_confidence_events_wasm_v1_wasm_api_proto_rawDesc)), + NumEnums: 0, + NumMessages: 3, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_confidence_events_wasm_v1_wasm_api_proto_goTypes, + DependencyIndexes: file_confidence_events_wasm_v1_wasm_api_proto_depIdxs, + MessageInfos: file_confidence_events_wasm_v1_wasm_api_proto_msgTypes, + }.Build() + File_confidence_events_wasm_v1_wasm_api_proto = out.File + file_confidence_events_wasm_v1_wasm_api_proto_goTypes = nil + file_confidence_events_wasm_v1_wasm_api_proto_depIdxs = nil +} diff --git a/openfeature-provider/go/confidence/provider.go b/openfeature-provider/go/confidence/provider.go index 92d87552b..44dd855ad 100644 --- a/openfeature-provider/go/confidence/provider.go +++ b/openfeature-provider/go/confidence/provider.go @@ -10,14 +10,21 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/open-feature/go-sdk/openfeature" + et "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/event_tracking" lr "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/local_resolver" + "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/events" + "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/eventswasm" "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolver" 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/grpc" + "google.golang.org/grpc/credentials" "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" ) const ( @@ -33,6 +40,9 @@ type Option func(*providerOptions) type providerOptions struct { statePollInterval time.Duration logPollInterval time.Duration + eventWasmBytes []byte + eventsClient events.EventsServiceClient + useWasmInterpreter bool enableApplyDedup bool disableExposureCollection bool } @@ -51,6 +61,33 @@ func WithLogPollInterval(d time.Duration) Option { } } +// WithEventTracking enables event tracking by providing the event engine WASM +// binary. The WASM module must export wasm_msg_guest_track_event and +// wasm_msg_guest_bounded_flush_events. Events are flushed on the same interval +// as log flushing and published to the Confidence events service over gRPC. +func WithEventTracking(eventWasmBytes []byte) Option { + return func(o *providerOptions) { + o.eventWasmBytes = eventWasmBytes + } +} + +// WithEventsServiceClient sets the gRPC client used to publish events. If not +// set, a TLS channel to the Confidence events service is created when event +// tracking is enabled. Intended for testing and advanced transport setups. +func WithEventsServiceClient(client events.EventsServiceClient) Option { + return func(o *providerOptions) { + o.eventsClient = client + } +} + +// WithUseWasmInterpreter configures the event engine to use wazero's +// interpreter mode instead of JIT compilation. +func WithUseWasmInterpreter(use bool) Option { + return func(o *providerOptions) { + o.useWasmInterpreter = use + } +} + // WithEnableApplyDedup enables experimental apply-event deduplication in the // WASM resolver: repeated identical assignments within a short TTL window are // logged once. Off by default; the API may change. @@ -69,20 +106,41 @@ func WithDisableExposureCollection() Option { } } +// eventTracking is the subset of the WASM event tracker used by the provider. +// Declared as an interface so the publish/drain path can be exercised without a +// live WASM instance; the only production implementation is *et.EventTracker. +type eventTracking interface { + TrackEvent(request *eventswasm.TrackEventRequest) error + FlushEvents() (*eventswasm.FlushEventsResponse, error) + Close() error +} + // LocalResolverProvider implements the OpenFeature FeatureProvider interface // for local flag resolution using the Confidence WASM resolver type LocalResolverProvider struct { - resolverSupplier LocalResolverSupplier - resolver lr.LocalResolver - stateProvider StateProvider - flagLogger FlagLogger - clientSecret string - logger *slog.Logger - cancelFunc context.CancelFunc - wg sync.WaitGroup - mu sync.Mutex - statePollInterval time.Duration - logPollInterval time.Duration + resolverSupplier LocalResolverSupplier + resolver lr.LocalResolver + stateProvider StateProvider + flagLogger FlagLogger + clientSecret string + logger *slog.Logger + cancelFunc context.CancelFunc + wg sync.WaitGroup + mu sync.Mutex + statePollInterval time.Duration + logPollInterval time.Duration + + // Event tracking (optional — nil when no event WASM is provided) + eventTracker eventTracking + eventsClient events.EventsServiceClient + eventsConn *grpc.ClientConn + + // Event publish failure accounting. Failures are counted and reported once + // per eventPublishLogWindow attempts instead of logging every failed RPC. + eventPublishAttempts atomic.Int64 + eventPublishFailures atomic.Int64 + + // Feature options forwarded to SetResolverState enableApplyDedup bool disableExposureCollection bool } @@ -91,6 +149,7 @@ type LocalResolverProvider struct { var ( _ openfeature.FeatureProvider = (*LocalResolverProvider)(nil) _ openfeature.StateHandler = (*LocalResolverProvider)(nil) + _ openfeature.Tracker = (*LocalResolverProvider)(nil) ) // NewLocalResolverProvider creates a new LocalResolverProvider @@ -125,7 +184,7 @@ func NewLocalResolverProvider( logPollInterval = getLogPollInterval(logger) } - return &LocalResolverProvider{ + provider := &LocalResolverProvider{ resolverSupplier: resolverSupplier, stateProvider: stateProvider, flagLogger: flagLogger, @@ -136,6 +195,33 @@ func NewLocalResolverProvider( enableApplyDedup: options.enableApplyDedup, disableExposureCollection: options.disableExposureCollection, } + + // Initialize the event tracker if event WASM bytes were provided + if len(options.eventWasmBytes) > 0 { + eventTracker, err := et.NewEventTracker(options.eventWasmBytes, options.useWasmInterpreter) + if err != nil { + logger.Error("Failed to initialize event tracker, event tracking disabled", "error", err) + } else { + provider.eventTracker = eventTracker + provider.eventsClient = options.eventsClient + if provider.eventsClient == nil { + conn, err := grpc.NewClient( + eventsGrpcTarget, + grpc.WithTransportCredentials(credentials.NewTLS(nil)), + grpc.WithDefaultServiceConfig(eventsRetryServiceConfig), + ) + if err != nil { + logger.Error("Failed to create events service channel, event publishing disabled", "error", err) + } else { + provider.eventsConn = conn + provider.eventsClient = events.NewEventsServiceClient(conn) + } + } + logger.Info("Event tracking enabled") + } + } + + return provider } // Metadata returns the provider metadata @@ -412,6 +498,209 @@ func (p *LocalResolverProvider) GetPrometheusMetrics(config SnapshotConfig) stri return p.resolver.PrometheusSnapshot(config.BucketsPerDecade, config.OpenMetrics) } +// eventsGrpcTarget is the gRPC target for the Confidence events service. +const eventsGrpcTarget = "edge-grpc.spotify.com:443" + +// eventsPublishTimeout bounds a single PublishEvents RPC. +const eventsPublishTimeout = 30 * time.Second + +// eventsRetryServiceConfig is the gRPC service config applied to the events +// channel: transparent retries of transient UNAVAILABLE failures with +// exponential backoff. Kept in sync with the other Confidence SDKs. +const eventsRetryServiceConfig = `{ + "methodConfig": [ + { + "name": [{"service": "confidence.events.v1.EventsService"}], + "retryPolicy": { + "maxAttempts": 3, + "initialBackoff": "1s", + "maxBackoff": "10s", + "backoffMultiplier": 2.0, + "retryableStatusCodes": ["UNAVAILABLE"] + } + } + ] +}` + +// eventPublishLogWindow is how many publish attempts are aggregated before +// failures are reported, mirroring the flag logger's failure accounting. +const eventPublishLogWindow = 10 + +// maxDrainBatches bounds the number of flush/publish rounds performed on +// shutdown. A single flush is capped by the event guest's byte budget, so a +// backlog needs several rounds to drain; the bound keeps shutdown from +// spinning forever when the events service is unreachable (publish failures +// are swallowed). +const maxDrainBatches = 100 + +// eventsSdk identifies this SDK to the events service. +var eventsSdk = &events.Sdk{ + Sdk: &events.Sdk_Id{Id: events.SdkId_SDK_ID_GO_LOCAL_PROVIDER}, + Version: Version, +} + +// Track sends a tracking event to the event engine, implementing the +// OpenFeature Tracker interface. The event is buffered internally and published +// in batches by the background flush goroutine. This is a no-op if event +// tracking was not enabled via WithEventTracking. +// +// The numeric value from details is only attached when non-zero: the +// OpenFeature TrackingEventDetails zero value is indistinguishable from an +// explicitly-set 0, so events without a measurement are sent without a value. +func (p *LocalResolverProvider) Track( + ctx context.Context, + trackingEventName string, + evalCtx openfeature.EvaluationContext, + details openfeature.TrackingEventDetails, +) { + if p.eventTracker == nil { + return + } + + // Attributes() returns a fresh copy, so adding the targeting key here does + // not mutate the caller's evaluation context. + flatCtx := openfeature.FlattenedContext(evalCtx.Attributes()) + if targetingKey := evalCtx.TargetingKey(); targetingKey != "" { + flatCtx[openfeature.TargetingKey] = targetingKey + } + + var protoCtx *structpb.Struct + if len(flatCtx) > 0 { + var err error + protoCtx, err = flattenedContextToProto(processTargetingKey(flatCtx)) + if err != nil { + p.logger.Warn("Failed to convert context for event tracking", "error", err) + return + } + } + + var protoData *structpb.Struct + if attributes := details.Attributes(); len(attributes) > 0 { + var err error + protoData, err = structpb.NewStruct(attributes) + if err != nil { + p.logger.Warn("Failed to convert data for event tracking", "error", err) + return + } + } + + // Go's TrackingEventDetails stores value as a plain float64 with no "is set" + // flag, so an explicit 0 is indistinguishable from an unset value. Java + // (Optional) and JS (number | undefined) can tell them apart and do forward + // an explicit 0. We treat 0 as unset: always sending would attach a spurious + // value: 0 to every event where the caller set none, which is far more + // common than deliberately tracking a zero. See confidence-event-engine's + // README ("Known provider differences"). + var value *float64 + if v := details.Value(); v != 0 { + value = &v + } + + request := &eventswasm.TrackEventRequest{ + EventName: trackingEventName, + EventTime: timestamppb.Now(), + Value: value, + Context: protoCtx, + Data: protoData, + } + + if err := p.eventTracker.TrackEvent(request); err != nil { + p.logger.Warn("Failed to track event", "event", trackingEventName, "error", err) + } +} + +// flushAndPublishEvents retrieves buffered events from the WASM event engine +// and publishes them to the Confidence events service over gRPC. It returns the +// number of events flushed, which is 0 when the buffer was empty or the flush +// itself failed. +// +// Publish failures are swallowed (the events are already gone from the WASM +// buffer); they are counted and reported once per eventPublishLogWindow +// attempts, mirroring the flag logger's failure accounting. +func (p *LocalResolverProvider) flushAndPublishEvents(ctx context.Context) int { + if p.eventTracker == nil { + return 0 + } + + batch, err := p.eventTracker.FlushEvents() + if err != nil { + p.logger.Error("Failed to flush events from WASM", "error", err) + return 0 + } + + if len(batch.Events) == 0 { + return 0 + } + + if err := p.publishEvents(ctx, batch); err != nil { + p.eventPublishFailures.Add(1) + p.logger.Debug("Failed to publish events", "error", err) + } + + if p.eventPublishAttempts.Add(1)%eventPublishLogWindow == 0 { + if failures := p.eventPublishFailures.Swap(0); failures > 0 { + p.logger.Warn("Event publish failures", "failures", failures, "window", eventPublishLogWindow) + } + } + + return len(batch.Events) +} + +// drainEvents flushes and publishes buffered events repeatedly until the buffer +// is empty. A single flush is bounded by the event guest's byte budget, so one +// round is not enough to drain a backlog; the loop is bounded by +// maxDrainBatches because publish failures are swallowed and would otherwise +// let an unreachable events service spin forever. +func (p *LocalResolverProvider) drainEvents(ctx context.Context) { + if p.eventTracker == nil { + return + } + + for i := 0; i < maxDrainBatches; i++ { + if p.flushAndPublishEvents(ctx) == 0 { + return + } + if ctx.Err() != nil { + return + } + } + + p.logger.Warn("Event drain hit the batch limit on shutdown; dropping the rest", + "max_drain_batches", maxDrainBatches) +} + +// publishEvents wraps a flushed batch in a PublishEventsRequest and sends it to +// the Confidence events service. +func (p *LocalResolverProvider) publishEvents(ctx context.Context, batch *eventswasm.FlushEventsResponse) error { + if p.eventsClient == nil { + return errors.New("events service client is not configured") + } + + rpcCtx, cancel := context.WithTimeout(ctx, eventsPublishTimeout) + defer cancel() + + request := &events.PublishEventsRequest{ + ClientSecret: p.clientSecret, + Events: batch.Events, + SendTime: timestamppb.Now(), + Sdk: eventsSdk, + } + + response, err := p.eventsClient.PublishEvents(rpcCtx, request) + if err != nil { + return fmt.Errorf("failed to publish events: %w", err) + } + + for _, e := range response.GetErrors() { + p.logger.Error("Event publish error", + "index", e.GetIndex(), + "reason", e.GetReason().String(), + "message", e.GetMessage()) + } + + return nil +} + // Resolve resolves multiple flags for the given context. If flagNames is empty, // all flags available to the client are resolved. When apply is true, exposure // events are recorded immediately. When apply is false, the response normally @@ -569,6 +858,25 @@ func (p *LocalResolverProvider) Shutdown() { } } + // Drain and close the event tracker + if p.eventTracker != nil { + drainCtx, drainCancel := context.WithTimeout(ctx, 3*time.Second) + p.drainEvents(drainCtx) + drainCancel() + if err := p.eventTracker.Close(); err != nil && p.logger != nil { + p.logger.Warn("Failed to close event tracker", "error", err) + } + if p.eventsConn != nil { + if err := p.eventsConn.Close(); err != nil && p.logger != nil { + p.logger.Warn("Failed to close events service channel", "error", err) + } + p.eventsConn = nil + } + if p.logger != nil { + p.logger.Debug("Closed event tracker") + } + } + // Shutdown flag logger (which waits for log sends to complete) if p.flagLogger != nil { p.flagLogger.Shutdown() @@ -678,6 +986,27 @@ func (p *LocalResolverProvider) startScheduledTasks(parentCtx context.Context, a } } }() + + // Goroutine for event flushing (only when event tracking is enabled). + // Kept separate from log flushing so a slow PublishEvents RPC cannot delay + // resolve/assign log flushes. + if p.eventTracker != nil { + p.wg.Add(1) + go func() { + defer p.wg.Done() + eventTicker := time.NewTicker(p.logPollInterval) + defer eventTicker.Stop() + + for { + select { + case <-eventTicker.C: + p.flushAndPublishEvents(ctx) + case <-ctx.Done(): + return + } + } + }() + } } // getStatePollInterval gets the state poll interval from environment or returns default diff --git a/openfeature-provider/go/confidence/provider_builder.go b/openfeature-provider/go/confidence/provider_builder.go index 29d631cc8..6b886ce44 100644 --- a/openfeature-provider/go/confidence/provider_builder.go +++ b/openfeature-provider/go/confidence/provider_builder.go @@ -9,6 +9,7 @@ import ( "strconv" "time" + et "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/event_tracking" fl "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/flag_logger" lr "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/local_resolver" resolverv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolverinternal" @@ -34,6 +35,10 @@ type ProviderConfig struct { // OpenFeature evaluations through this provider. Use only for exceptional // no-exposure modes; resolve logs and telemetry are still sent. DisableExposureCollection bool + // EnableEventTracking turns on OpenFeature track() support using the + // embedded event engine WASM. Events are batched in WASM and published to + // the Confidence events service over gRPC on LogPollInterval. + EnableEventTracking bool } type ProviderTestConfig struct { @@ -111,6 +116,12 @@ func NewProvider(ctx context.Context, config ProviderConfig) (*LocalResolverProv resolverSupplier := newLocalResolverSupplier(config.ResolverPoolSize, config.UseWasmInterpreter, initLabels) resolverSupplierWithMaterialization := wrapResolverSupplierWithMaterializations(resolverSupplier, materializationStore) providerOpts := buildProviderOptions(config.StatePollInterval, config.LogPollInterval, config.EnableApplyDedup, config.DisableExposureCollection) + if config.EnableEventTracking { + providerOpts = append(providerOpts, + WithEventTracking(et.EventEngineWasm), + WithUseWasmInterpreter(config.UseWasmInterpreter), + ) + } provider := NewLocalResolverProvider(resolverSupplierWithMaterialization, stateProvider, flagLogger, config.ClientSecret, logger, providerOpts...) return provider, nil } diff --git a/openfeature-provider/go/confidence/provider_events_test.go b/openfeature-provider/go/confidence/provider_events_test.go new file mode 100644 index 000000000..aa65cf5b8 --- /dev/null +++ b/openfeature-provider/go/confidence/provider_events_test.go @@ -0,0 +1,182 @@ +package confidence + +import ( + "context" + "errors" + "io" + "log/slog" + "testing" + + "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/events" + "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/eventswasm" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// fakeEventTracker is an in-memory stand-in for the WASM event tracker. Each +// FlushEvents call returns one event per remaining batch, so `batches` controls +// how many non-empty flushes the drain loop observes. +type fakeEventTracker struct { + remainingBatches int + flushCalls int + flushErr error +} + +func (f *fakeEventTracker) TrackEvent(*eventswasm.TrackEventRequest) error { return nil } + +func (f *fakeEventTracker) FlushEvents() (*eventswasm.FlushEventsResponse, error) { + f.flushCalls++ + if f.flushErr != nil { + return nil, f.flushErr + } + if f.remainingBatches <= 0 { + return &eventswasm.FlushEventsResponse{}, nil + } + f.remainingBatches-- + return &eventswasm.FlushEventsResponse{Events: []*events.Event{{EventDefinition: "eventDefinitions/test"}}}, nil +} + +func (f *fakeEventTracker) Close() error { return nil } + +// fakeEventsClient records publish calls and can fail every call, emulating an +// unreachable events service. +type fakeEventsClient struct { + calls int + err error +} + +func (c *fakeEventsClient) PublishEvents( + _ context.Context, + _ *events.PublishEventsRequest, + _ ...grpc.CallOption, +) (*events.PublishEventsResponse, error) { + c.calls++ + if c.err != nil { + return nil, c.err + } + return &events.PublishEventsResponse{}, nil +} + +func newDrainTestProvider(tracker eventTracking, client events.EventsServiceClient) *LocalResolverProvider { + return &LocalResolverProvider{ + clientSecret: "test-secret", + logger: slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError})), + eventTracker: tracker, + eventsClient: client, + } +} + +func TestDrainEvents_LoopsUntilBufferEmpty(t *testing.T) { + tracker := &fakeEventTracker{remainingBatches: 3} + client := &fakeEventsClient{} + provider := newDrainTestProvider(tracker, client) + + provider.drainEvents(context.Background()) + + // 3 non-empty flushes plus the empty flush that ends the loop. + if tracker.flushCalls != 4 { + t.Errorf("Expected 4 flush calls, got %d", tracker.flushCalls) + } + if client.calls != 3 { + t.Errorf("Expected 3 publish calls, got %d", client.calls) + } +} + +func TestDrainEvents_BoundedWhenPublishAlwaysFails(t *testing.T) { + // Never empties: without the bound this would loop forever. + tracker := &fakeEventTracker{remainingBatches: maxDrainBatches * 10} + client := &fakeEventsClient{err: errors.New("events service unreachable")} + provider := newDrainTestProvider(tracker, client) + + provider.drainEvents(context.Background()) + + if tracker.flushCalls != maxDrainBatches { + t.Errorf("Expected drain to stop after %d flushes, got %d", maxDrainBatches, tracker.flushCalls) + } + if client.calls != maxDrainBatches { + t.Errorf("Expected %d publish calls, got %d", maxDrainBatches, client.calls) + } + // maxDrainBatches is a multiple of the window, so all failures are reported + // and the counter is left at zero. + if got := provider.eventPublishFailures.Load(); got != 0 { + t.Errorf("Expected failure counter to be drained by the reporting window, got %d", got) + } + if got := provider.eventPublishAttempts.Load(); got != int64(maxDrainBatches) { + t.Errorf("Expected %d publish attempts, got %d", maxDrainBatches, got) + } +} + +func TestDrainEvents_StopsOnCancelledContext(t *testing.T) { + tracker := &fakeEventTracker{remainingBatches: maxDrainBatches * 10} + client := &fakeEventsClient{err: errors.New("events service unreachable")} + provider := newDrainTestProvider(tracker, client) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + provider.drainEvents(ctx) + + if tracker.flushCalls != 1 { + t.Errorf("Expected drain to stop after the first round on a cancelled context, got %d flushes", tracker.flushCalls) + } +} + +func TestDrainEvents_NoTrackerIsNoOp(t *testing.T) { + provider := newDrainTestProvider(nil, &fakeEventsClient{}) + provider.eventTracker = nil + + provider.drainEvents(context.Background()) + + if provider.eventPublishAttempts.Load() != 0 { + t.Error("Expected no publish attempts without an event tracker") + } +} + +func TestFlushAndPublishEvents_ReportsFailuresPerWindow(t *testing.T) { + tracker := &fakeEventTracker{remainingBatches: eventPublishLogWindow} + client := &fakeEventsClient{err: errors.New("events service unreachable")} + provider := newDrainTestProvider(tracker, client) + + for i := 0; i < eventPublishLogWindow-1; i++ { + provider.flushAndPublishEvents(context.Background()) + if got := provider.eventPublishFailures.Load(); got != int64(i+1) { + t.Fatalf("Expected %d accumulated failures, got %d", i+1, got) + } + } + + // The window boundary swaps the accumulated failures out for reporting. + provider.flushAndPublishEvents(context.Background()) + if got := provider.eventPublishFailures.Load(); got != 0 { + t.Errorf("Expected failures to be reset at the window boundary, got %d", got) + } +} + +// TestEventsRetryServiceConfig_IsAccepted guards the retry policy JSON: gRPC +// rejects a malformed default service config when the client is created. +// grpc.NewClient is lazy, so this needs no network access. +func TestEventsRetryServiceConfig_IsAccepted(t *testing.T) { + conn, err := grpc.NewClient( + "passthrough:///"+eventsGrpcTarget, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithDefaultServiceConfig(eventsRetryServiceConfig), + ) + if err != nil { + t.Fatalf("Expected events retry service config to be accepted, got %v", err) + } + if err := conn.Close(); err != nil { + t.Errorf("Failed to close connection: %v", err) + } +} + +func TestFlushAndPublishEvents_FlushErrorReturnsZero(t *testing.T) { + tracker := &fakeEventTracker{flushErr: errors.New("wasm flush failed")} + client := &fakeEventsClient{} + provider := newDrainTestProvider(tracker, client) + + if n := provider.flushAndPublishEvents(context.Background()); n != 0 { + t.Errorf("Expected 0 events on flush error, got %d", n) + } + if client.calls != 0 { + t.Errorf("Expected no publish calls on flush error, got %d", client.calls) + } +} diff --git a/openfeature-provider/go/scripts/generate_proto.sh b/openfeature-provider/go/scripts/generate_proto.sh index 58a5de19c..a72e26d8d 100755 --- a/openfeature-provider/go/scripts/generate_proto.sh +++ b/openfeature-provider/go/scripts/generate_proto.sh @@ -35,6 +35,8 @@ 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 +mkdir -p confidence/internal/proto/eventswasm protoc --proto_path=../proto \ --go_out=confidence/internal/proto \ @@ -48,7 +50,10 @@ 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 \ + confidence/events/wasm/v1/wasm_api.proto echo "Protobuf generation complete!" echo "Generated files:" diff --git a/openfeature-provider/java/pom.xml b/openfeature-provider/java/pom.xml index e7c915303..82512c9a2 100644 --- a/openfeature-provider/java/pom.xml +++ b/openfeature-provider/java/pom.xml @@ -246,6 +246,8 @@ confidence/flags/resolver/v1/**/*.proto confidence/flags/types/v1/**/*.proto confidence/flags/admin/v1/**/*.proto + confidence/events/v1/**/*.proto + confidence/events/wasm/v1/**/*.proto confidence/wasm/*.proto diff --git a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/DefaultChannelFactory.java b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/DefaultChannelFactory.java index ebbe474a9..313e746f7 100644 --- a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/DefaultChannelFactory.java +++ b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/DefaultChannelFactory.java @@ -25,6 +25,25 @@ */ public class DefaultChannelFactory implements ChannelFactory { + private static final Map RETRY_POLICY = + Map.of( + "maxAttempts", + 3.0, + "initialBackoff", + "1s", + "maxBackoff", + "10s", + "backoffMultiplier", + 2.0, + "retryableStatusCodes", + List.of("UNAVAILABLE")); + + /** + * Retry policies applied to every channel this factory builds. Entries are scoped per service, so + * a channel only ever retries the service it actually talks to: the flag-log channel retries + * {@code InternalFlagLoggerService}, the events channel (see {@link + * GrpcUtil#createConfidenceEventsChannel(ChannelFactory)}) retries {@code EventsService}. + */ static final Map RETRY_SERVICE_CONFIG = Map.of( "methodConfig", @@ -34,17 +53,12 @@ public class DefaultChannelFactory implements ChannelFactory { List.of( Map.of("service", "confidence.flags.resolver.v1.InternalFlagLoggerService")), "retryPolicy", - Map.of( - "maxAttempts", - 3.0, - "initialBackoff", - "1s", - "maxBackoff", - "10s", - "backoffMultiplier", - 2.0, - "retryableStatusCodes", - List.of("UNAVAILABLE"))))); + RETRY_POLICY), + Map.of( + "name", + List.of(Map.of("service", "confidence.events.v1.EventsService")), + "retryPolicy", + RETRY_POLICY))); @Override public ManagedChannel create(String target, List defaultInterceptors) { diff --git a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/GrpcUtil.java b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/GrpcUtil.java index 1aea7ed4b..fbeeee201 100644 --- a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/GrpcUtil.java +++ b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/GrpcUtil.java @@ -52,4 +52,31 @@ static ManagedChannel createConfidenceChannel(ChannelFactory channelFactory) { return channelFactory.create( confidenceDomain, List.of(new DefaultDeadlineClientInterceptor(Duration.ofMinutes(1)))); } + + /** + * Creates a channel to the Confidence events service (confidence.events.v1.EventsService). + * + *

This is deliberately a separate channel from {@link + * #createConfidenceChannel(ChannelFactory)} even though it resolves to the same host by default. + * The two carry different default deadlines (30s for event publishing vs 1min for flag log + * ingestion), their targets can be overridden independently via {@code CONFIDENCE_EVENTS_DOMAIN} + * / {@code CONFIDENCE_DOMAIN}, and their lifecycles are independent: the flag-log channel is + * owned and shut down by {@link GrpcWasmFlagLogger}, while the events channel is owned by the + * provider and must stay open until the final event drain during shutdown completes. + * + *

Retries for transient {@code UNAVAILABLE} failures on {@code + * confidence.events.v1.EventsService} come from {@link + * DefaultChannelFactory#RETRY_SERVICE_CONFIG}, which is installed on the {@code + * ManagedChannelBuilder} via {@code defaultServiceConfig} + {@code enableRetry}. gRPC only + * accepts a service config at build time, so a caller-supplied {@link ChannelFactory} is + * responsible for configuring its own retries. + */ + static ManagedChannel createConfidenceEventsChannel(ChannelFactory channelFactory) { + final String eventsDomain = + Optional.ofNullable(System.getenv("CONFIDENCE_EVENTS_DOMAIN")) + .or(() -> Optional.ofNullable(System.getenv("CONFIDENCE_DOMAIN"))) + .orElse(CONFIDENCE_DOMAIN); + return channelFactory.create( + eventsDomain, List.of(new DefaultDeadlineClientInterceptor(Duration.ofSeconds(30)))); + } } diff --git a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/LocalProviderConfig.java b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/LocalProviderConfig.java index f86d74181..178987634 100644 --- a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/LocalProviderConfig.java +++ b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/LocalProviderConfig.java @@ -1,5 +1,9 @@ package com.spotify.confidence.sdk; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + public class LocalProviderConfig { /** * Default number of WASM resolver instances in the pool. The actual pool size is capped at {@code @@ -14,6 +18,7 @@ public class LocalProviderConfig { private final String encryptionKey; private final boolean enableApplyDedup; private final boolean disableExposureCollection; + private final byte[] eventWasmBytes; public LocalProviderConfig() { this(null, null); @@ -55,7 +60,8 @@ private LocalProviderConfig( resolverPoolSize, encryptionKey, false, - false); + false, + null); } private LocalProviderConfig( @@ -65,7 +71,8 @@ private LocalProviderConfig( int resolverPoolSize, String encryptionKey, boolean enableApplyDedup, - boolean disableExposureCollection) { + boolean disableExposureCollection, + byte[] eventWasmBytes) { this.channelFactory = channelFactory != null ? channelFactory : new DefaultChannelFactory(); this.httpClientFactory = httpClientFactory != null ? httpClientFactory : new DefaultHttpClientFactory(); @@ -74,6 +81,7 @@ private LocalProviderConfig( this.encryptionKey = encryptionKey; this.enableApplyDedup = enableApplyDedup; this.disableExposureCollection = disableExposureCollection; + this.eventWasmBytes = eventWasmBytes; } public ChannelFactory getChannelFactory() { @@ -115,6 +123,14 @@ public boolean isDisableExposureCollection() { return disableExposureCollection; } + /** + * Returns the raw bytes of the event engine WASM binary, or {@code null} if event tracking is not + * enabled. + */ + public byte[] getEventWasmBytes() { + return eventWasmBytes; + } + public static Builder builder() { return new Builder(); } @@ -127,6 +143,7 @@ public static class Builder { private String encryptionKey; private boolean enableApplyDedup; private boolean disableExposureCollection; + private byte[] eventWasmBytes; public Builder channelFactory(ChannelFactory channelFactory) { this.channelFactory = channelFactory; @@ -180,6 +197,29 @@ public Builder disableExposureCollection(boolean disableExposureCollection) { return this; } + /** + * Sets the event engine WASM binary bytes. When set, the provider enables event tracking via + * {@code track()} and periodically flushes events to the Confidence events API. + * + * @param eventWasmBytes the raw bytes of the {@code confidence_event_engine.wasm} binary + */ + public Builder eventWasmBytes(byte[] eventWasmBytes) { + this.eventWasmBytes = eventWasmBytes; + return this; + } + + /** + * Loads the event engine WASM binary from the given file path. Convenience alternative to + * {@link #eventWasmBytes(byte[])}. + * + * @param eventWasmPath path to the {@code confidence_event_engine.wasm} file + * @throws IOException if the file cannot be read + */ + public Builder eventWasmPath(Path eventWasmPath) throws IOException { + this.eventWasmBytes = Files.readAllBytes(eventWasmPath); + return this; + } + public LocalProviderConfig build() { return new LocalProviderConfig( channelFactory, @@ -188,7 +228,8 @@ public LocalProviderConfig build() { resolverPoolSize, encryptionKey, enableApplyDedup, - disableExposureCollection); + disableExposureCollection, + eventWasmBytes); } } } 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 e0c75587b..ee55ba681 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,13 @@ 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.events.v1.EventError; +import com.spotify.confidence.sdk.events.v1.EventsServiceGrpc; +import com.spotify.confidence.sdk.events.v1.PublishEventsRequest; +import com.spotify.confidence.sdk.events.v1.PublishEventsResponse; +import com.spotify.confidence.sdk.events.wasm.v1.FlushEventsResponse; +import com.spotify.confidence.sdk.events.wasm.v1.TrackEventRequest; import com.spotify.confidence.sdk.flags.resolver.v1.ApplyFlagsRequest; import com.spotify.confidence.sdk.flags.resolver.v1.RegisterResolveRequest; import com.spotify.confidence.sdk.flags.resolver.v1.ResolveFlagsRequest; @@ -16,13 +23,16 @@ import dev.openfeature.sdk.exceptions.FlagNotFoundError; import dev.openfeature.sdk.exceptions.GeneralError; import dev.openfeature.sdk.exceptions.TypeMismatchError; +import io.grpc.ManagedChannel; import io.grpc.Status; import io.grpc.StatusRuntimeException; import java.time.Duration; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; import org.slf4j.Logger; @@ -59,7 +69,20 @@ public class OpenFeatureLocalResolveProvider implements FeatureProvider { private final boolean disableExposureCollection; private static final Duration ASSIGN_LOG_FLUSH_INTERVAL = Duration.ofMillis(100); private static final Duration DEFAULT_POLL_INTERVAL = Duration.ofSeconds(15); + private static final Duration EVENT_FLUSH_INTERVAL = Duration.ofSeconds(15); private static final Duration SHUTDOWN_GRACE = Duration.ofSeconds(5); + private static final int MAX_DRAIN_BATCHES = 100; + + /** + * Number of event publish attempts between failure-rate log lines. Mirrors {@code + * GrpcWasmFlagLogger.STATS_WINDOW}: publish failures are swallowed per batch so that a broken + * events backend cannot take down flag resolution, and this window is the only signal that they + * are happening. + */ + private static final int EVENT_STATS_WINDOW = 10; + + private final AtomicLong eventPublishAttempts = new AtomicLong(); + private final AtomicLong eventPublishFailures = new AtomicLong(); private final ScheduledExecutorService flagsFetcherExecutor = newFlagsFetcherExecutor(); private final ScheduledExecutorService assignLogExecutor = Executors.newScheduledThreadPool(1, new ThreadFactoryBuilder().setDaemon(true).build()); @@ -72,6 +95,22 @@ public class OpenFeatureLocalResolveProvider implements FeatureProvider { private static final Sdk SDK = Sdk.newBuilder().setId(SdkId.SDK_ID_JAVA_LOCAL_PROVIDER).setVersion(Version.VERSION).build(); + /** + * SDK identity reported to the events service. This is a different {@code Sdk}/{@code SdkId} pair + * from the flag-resolver one above — same variant name, different proto package. + */ + private static final com.spotify.confidence.sdk.events.v1.Sdk EVENTS_SDK = + com.spotify.confidence.sdk.events.v1.Sdk.newBuilder() + .setId(com.spotify.confidence.sdk.events.v1.SdkId.SDK_ID_JAVA_LOCAL_PROVIDER) + .setVersion(Version.VERSION) + .build(); + + // Event tracking (optional — null when not configured) + private final WasmEventResolver eventResolver; + private final ScheduledExecutorService eventFlushExecutor; + private final ManagedChannel eventsChannel; + private final EventsServiceGrpc.EventsServiceBlockingStub eventsStub; + private static ScheduledExecutorService newFlagsFetcherExecutor() { final ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1, new ThreadFactoryBuilder().setDaemon(true).build()); @@ -182,6 +221,22 @@ public OpenFeatureLocalResolveProvider( config.isEnableApplyDedup(), config.isDisableExposureCollection())))); this.resolver = new MaterializingResolver(telemetryResolver, materializationStore); + + // Initialize event tracking if event WASM is provided + if (config.getEventWasmBytes() != null) { + this.eventResolver = new WasmEventResolver(config.getEventWasmBytes()); + this.eventFlushExecutor = + Executors.newScheduledThreadPool( + 1, + new ThreadFactoryBuilder().setDaemon(true).setNameFormat("event-flush-%d").build()); + this.eventsChannel = GrpcUtil.createConfidenceEventsChannel(config.getChannelFactory()); + this.eventsStub = EventsServiceGrpc.newBlockingStub(this.eventsChannel); + } else { + this.eventResolver = null; + this.eventFlushExecutor = null; + this.eventsChannel = null; + this.eventsStub = null; + } } /** @@ -248,6 +303,10 @@ public OpenFeatureLocalResolveProvider( enableApplyDedup, disableExposureCollection)))); this.resolver = new MaterializingResolver(telemetryResolver, materializationStore); + this.eventResolver = null; + this.eventFlushExecutor = null; + this.eventsChannel = null; + this.eventsStub = null; } @Override @@ -294,6 +353,15 @@ public void initialize(EvaluationContext evaluationContext) { ASSIGN_LOG_FLUSH_INTERVAL.toMillis(), TimeUnit.MILLISECONDS); } + + // Schedule event flushing if event tracking is enabled + if (eventFlushExecutor != null && eventResolver != null) { + eventFlushExecutor.scheduleAtFixedRate( + this::doFlushAndSendEvents, + EVENT_FLUSH_INTERVAL.toMillis(), + EVENT_FLUSH_INTERVAL.toMillis(), + TimeUnit.MILLISECONDS); + } } private void scheduleStateRefresh( @@ -422,6 +490,9 @@ public void shutdown() { log.debug("Shutting down scheduled executors"); flagsFetcherExecutor.shutdown(); assignLogExecutor.shutdown(); + if (eventFlushExecutor != null) { + eventFlushExecutor.shutdown(); + } final long graceSeconds = SHUTDOWN_GRACE.toSeconds(); try { @@ -436,13 +507,39 @@ public void shutdown() { "Assign log executor did not terminate within {}s, forcing shutdown", graceSeconds); assignLogExecutor.shutdownNow(); } + if (eventFlushExecutor != null + && !eventFlushExecutor.awaitTermination(graceSeconds, TimeUnit.SECONDS)) { + log.warn( + "Event flush executor did not terminate within {}s, forcing shutdown", graceSeconds); + eventFlushExecutor.shutdownNow(); + } } catch (InterruptedException e) { log.warn("Interrupted while waiting for scheduled executors to shut down", e); flagsFetcherExecutor.shutdownNow(); assignLogExecutor.shutdownNow(); + if (eventFlushExecutor != null) { + eventFlushExecutor.shutdownNow(); + } Thread.currentThread().interrupt(); } + // Drain remaining events before closing the event resolver + drainEvents(); + if (eventResolver != null) { + eventResolver.close(); + } + if (eventsChannel != null) { + eventsChannel.shutdown(); + try { + if (!eventsChannel.awaitTermination(graceSeconds, TimeUnit.SECONDS)) { + eventsChannel.shutdownNow(); + } + } catch (InterruptedException e) { + eventsChannel.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + // if we created the materialization store ourselves we are responsible for shutting it down if (materializationStore instanceof RemoteMaterializationStore remoteMaterializationStore) { remoteMaterializationStore.shutdown(); @@ -569,6 +666,137 @@ private ProviderEvaluation getObjectEvaluationInternal( } } + // ── Event Tracking ───────────────────────────────────────────────────────── + + /** + * Tracks an event through the Confidence event engine. Events are buffered in the WASM engine and + * periodically flushed to the Confidence events API. + * + *

This method is a no-op if event tracking was not configured (i.e., no event WASM binary was + * provided in {@link LocalProviderConfig}). + * + * @param trackingEventName the event name (e.g., "purchase_completed") + * @param context the OpenFeature evaluation context + * @param details tracking event details including an optional numeric value and custom data + */ + @Override + public void track( + String trackingEventName, EvaluationContext context, TrackingEventDetails details) { + if (eventResolver == null) { + return; + } + try { + final Instant now = Instant.now(); + final TrackEventRequest.Builder reqBuilder = + TrackEventRequest.newBuilder() + .setEventName(trackingEventName) + .setEventTime( + Timestamp.newBuilder() + .setSeconds(now.getEpochSecond()) + .setNanos(now.getNano()) + .build()); + + if (context != null) { + reqBuilder.setContext(OpenFeatureUtils.convertToProto(context)); + } + + if (details != null) { + details.getValue().ifPresent(v -> reqBuilder.setValue(v.doubleValue())); + // Convert custom data fields from TrackingEventDetails (which extends Structure) + if (!details.isEmpty()) { + final Struct.Builder dataBuilder = Struct.newBuilder(); + details + .asMap() + .forEach( + (key, value) -> dataBuilder.putFields(key, OpenFeatureTypeMapper.from(value))); + reqBuilder.setData(dataBuilder.build()); + } + } + + eventResolver.trackEvent(reqBuilder.build()); + } catch (RuntimeException e) { + log.warn("Failed to track event '{}'", trackingEventName, e); + } + } + + /** + * Flushes buffered events from the WASM engine and publishes them to the Confidence events + * service. + */ + private void doFlushAndSendEvents() { + if (eventResolver == null) { + return; + } + try { + final FlushEventsResponse batch = eventResolver.flushEvents(); + if (batch.getEventsCount() > 0) { + sendEvents(batch); + } + } catch (RuntimeException e) { + log.warn("Failed to flush events", e); + } + } + + /** + * Drains all remaining events from the WASM engine by calling flush in a loop until no events + * remain. + */ + private void drainEvents() { + if (eventResolver == null) { + return; + } + try { + // Bounded: sendEvents swallows network failures, so an unbounded loop would + // spin forever if the events API is unreachable during shutdown. + for (int i = 0; i < MAX_DRAIN_BATCHES; i++) { + final FlushEventsResponse batch = eventResolver.flushEvents(); + if (batch.getEventsCount() == 0) { + return; + } + sendEvents(batch); + } + log.warn( + "Event drain hit the {}-batch limit on shutdown; dropping the rest", MAX_DRAIN_BATCHES); + } catch (RuntimeException e) { + log.warn("Failed to drain events on shutdown", e); + } + } + + /** Publishes a batch of events to the Confidence events service over gRPC. */ + private void sendEvents(FlushEventsResponse batch) { + if (eventsStub == null) { + return; + } + final Instant now = Instant.now(); + final PublishEventsRequest request = + PublishEventsRequest.newBuilder() + .setClientSecret(clientSecret) + .addAllEvents(batch.getEventsList()) + .setSendTime( + Timestamp.newBuilder().setSeconds(now.getEpochSecond()).setNanos(now.getNano())) + .setSdk(EVENTS_SDK) + .build(); + try { + final PublishEventsResponse response = eventsStub.publishEvents(request); + for (final EventError error : response.getErrorsList()) { + log.error( + "Failed to publish event at index {}: {} {}", + error.getIndex(), + error.getReason(), + error.getMessage()); + } + } catch (StatusRuntimeException e) { + eventPublishFailures.incrementAndGet(); + log.warn("Failed to send events", e); + } + if (eventPublishAttempts.incrementAndGet() % EVENT_STATS_WINDOW == 0) { + final long failCount = eventPublishFailures.getAndSet(0); + if (failCount > 0) { + log.warn("Event publish failures: {}/{}", failCount, EVENT_STATS_WINDOW); + } + } + } + private void doRegisterResolve(ResolveReason reason, long startNanos) { long latencyUs = (System.nanoTime() - startNanos) / 1000; try { diff --git a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/WasmEventResolver.java b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/WasmEventResolver.java new file mode 100644 index 000000000..9118c4bec --- /dev/null +++ b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/WasmEventResolver.java @@ -0,0 +1,206 @@ +package com.spotify.confidence.sdk; + +import com.dylibso.chicory.runtime.ChicoryInterruptedException; +import com.dylibso.chicory.runtime.ExportFunction; +import com.dylibso.chicory.runtime.Instance; +import com.dylibso.chicory.runtime.Memory; +import com.dylibso.chicory.wasm.ChicoryException; +import com.dylibso.chicory.wasm.WasmModule; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; +import com.spotify.confidence.sdk.events.wasm.v1.FlushEventsResponse; +import com.spotify.confidence.sdk.events.wasm.v1.TrackEventRequest; +import com.spotify.confidence.sdk.wasm.Messages; +import java.util.concurrent.locks.ReentrantLock; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * WASM wrapper for the Confidence event engine. Loads the event engine WASM binary and exposes + * {@link #trackEvent(TrackEventRequest)} and {@link #flushEvents()} operations. + * + *

The event engine WASM has no host imports (unlike the flag resolver WASM). It uses the same + * wasm-msg protocol: alloc memory, write a {@code Request} protobuf envelope, call the WASM export, + * read the {@code Response} envelope from the returned pointer, and free. + * + *

Thread-safe via {@link ReentrantLock}. + * + *

If the WASM instance traps, it is rebuilt from the parsed module so subsequent calls run + * against a fresh instance (buffered events in the trapped instance are lost). This mirrors {@link + * RecoveringResolver} for the flag resolver and {@code EventTracker.reloadLocked} in the Go + * provider. Only genuine WASM faults trigger a reload — protobuf decoding failures and errors + * reported by the engine in the response envelope leave the instance untouched. + */ +class WasmEventResolver implements AutoCloseable { + private static final Logger logger = LoggerFactory.getLogger(WasmEventResolver.class); + + private final WasmModule module; + private final ReentrantLock lock = new ReentrantLock(); + private Instance instance; + private ExportFunction wasmMsgAlloc; + private ExportFunction wasmMsgFree; + private ExportFunction wasmMsgGuestTrackEvent; + private ExportFunction wasmMsgGuestBoundedFlushEvents; + private boolean closed = false; + + WasmEventResolver(byte[] wasmBytes) { + this.module = com.dylibso.chicory.wasm.Parser.parse(wasmBytes); + instantiate(); + } + + /** Builds a fresh instance from {@link #module} and rebinds the exported functions. */ + private void instantiate() { + this.instance = Instance.builder(module).build(); + this.wasmMsgAlloc = instance.export("wasm_msg_alloc"); + this.wasmMsgFree = instance.export("wasm_msg_free"); + this.wasmMsgGuestTrackEvent = instance.export("wasm_msg_guest_track_event"); + this.wasmMsgGuestBoundedFlushEvents = instance.export("wasm_msg_guest_bounded_flush_events"); + } + + /** + * Tracks an event by sending a {@link TrackEventRequest} to the event engine WASM. The event is + * buffered internally until {@link #flushEvents()} is called. + */ + void trackEvent(TrackEventRequest request) { + lock.lock(); + try { + if (closed || instance == null) { + return; + } + try { + final int reqPtr = transferRequest(request); + final int respPtr = (int) wasmMsgGuestTrackEvent.apply(reqPtr)[0]; + consumeVoidResponse(respPtr); + } catch (ChicoryException e) { + handleTrapLocked("trackEvent", e); + throw e; + } + } finally { + lock.unlock(); + } + } + + /** + * Flushes buffered events from the WASM engine, returning a {@link FlushEventsResponse}. The + * returned batch may contain zero events if nothing was buffered. + * + *

This is a bounded flush: multiple calls may be needed to drain all events. + */ + FlushEventsResponse flushEvents() { + lock.lock(); + try { + if (closed || instance == null) { + return FlushEventsResponse.getDefaultInstance(); + } + try { + // The event engine WASM expects no input for flush (matching JS reference: passes 0) + final int respPtr = (int) wasmMsgGuestBoundedFlushEvents.apply(0)[0]; + final FlushEventsResponse response = + consumeTypedResponse(respPtr, FlushEventsResponse::parseFrom); + // consumeTypedResponse yields null when the guest returned no response. + return response != null ? response : FlushEventsResponse.getDefaultInstance(); + } catch (ChicoryException e) { + handleTrapLocked("flushEvents", e); + throw e; + } + } finally { + lock.unlock(); + } + } + + /** + * Replaces a trapped WASM instance with a fresh one. Buffered events in the old instance are + * lost. Caller must hold {@link #lock}. + */ + private void handleTrapLocked(String opName, ChicoryException e) { + if (e instanceof ChicoryInterruptedException) { + logger.debug("Event engine interrupted during {}, not reloading", opName); + return; + } + if (closed) { + return; + } + logger.warn( + "Event engine WASM failed during {} ({}), reloading instance; buffered events are lost", + opName, + e.getMessage(), + e); + instance = null; + try { + instantiate(); + } catch (RuntimeException reloadError) { + // Leave instance null — subsequent calls become no-ops until close(). + instance = null; + logger.error("Failed to reload the event engine WASM instance", reloadError); + } + } + + @Override + public void close() { + lock.lock(); + try { + closed = true; + } finally { + lock.unlock(); + } + } + + private int transferRequest(Message message) { + final byte[] request = + Messages.Request.newBuilder().setData(message.toByteString()).build().toByteArray(); + return transfer(request); + } + + private void consumeVoidResponse(int addr) { + // See consumeTypedResponse: addr == 0 means no response to consume. + if (addr == 0) { + return; + } + try { + final Messages.Response response = Messages.Response.parseFrom(consume(addr)); + if (response.hasError()) { + throw new RuntimeException("Event WASM error: " + response.getError()); + } + } catch (InvalidProtocolBufferException e) { + throw new RuntimeException(e); + } + } + + private T consumeTypedResponse(int addr, ParserFn codec) { + // A null pointer means the guest produced no response. Falling through would + // make consume() read the length prefix at addr-4, i.e. 0xFFFFFFFC, trapping + // or returning garbage. Go guards the same way (`if resPtr[0] == 0`). + if (addr == 0) { + return null; + } + try { + final Messages.Response response = Messages.Response.parseFrom(consume(addr)); + if (response.hasError()) { + throw new RuntimeException("Event WASM error: " + response.getError()); + } + return codec.apply(response.getData().toByteArray()); + } catch (InvalidProtocolBufferException e) { + throw new RuntimeException(e); + } + } + + private byte[] consume(int addr) { + final Memory mem = instance.memory(); + final int len = (int) (mem.readU32(addr - 4) - 4L); + final byte[] data = mem.readBytes(addr, len); + wasmMsgFree.apply(addr); + return data; + } + + private int transfer(byte[] data) { + final Memory mem = instance.memory(); + final int addr = (int) wasmMsgAlloc.apply(data.length)[0]; + mem.write(addr, data); + return addr; + } + + @FunctionalInterface + private interface ParserFn { + T apply(byte[] data) throws InvalidProtocolBufferException; + } +} diff --git a/openfeature-provider/js/package.json b/openfeature-provider/js/package.json index 72ea4a486..a41b71da8 100644 --- a/openfeature-provider/js/package.json +++ b/openfeature-provider/js/package.json @@ -53,7 +53,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 ../../openfeature-provider/proto/confidence/flags/types/v1/target.proto ../../openfeature-provider/proto/confidence/flags/admin/v1/resolver.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/flags/types/v1/target.proto ../../openfeature-provider/proto/confidence/flags/admin/v1/resolver.proto ../../openfeature-provider/proto/confidence/events/v1/types.proto ../../openfeature-provider/proto/confidence/events/v1/api.proto ../../openfeature-provider/proto/confidence/events/wasm/v1/wasm_api.proto" }, "dependencies": { "@bufbuild/protobuf": "^2.9.0" diff --git a/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts b/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts index 6daf14e1c..034701c89 100644 --- a/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts +++ b/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts @@ -20,6 +20,11 @@ import { ClientResolverState, LogDestination } from './proto/confidence/flags/ad import { IngestFlagLogsRequest, WriteFlagLogsRequest } from './proto/confidence/flags/resolver/v1/internal_api'; import FlagBundleType, * as FlagBundle from './flag-bundle'; import { ErrorCode, ResolutionDetails } from './types'; +import type { EventResolver } from './EventWasmResolver'; +import { TrackEventRequest, FlushEventsResponse } from './proto/confidence/events/wasm/v1/wasm_api'; +import { SdkId as EventsSdkId } from './proto/confidence/events/v1/types'; +import { PublishEventsRequest, PublishEventsResponse } from './proto/confidence/events/v1/api'; +import { EventError_Reason } from './proto/confidence/events/v1/types'; type FlagBundle = FlagBundleType; const logger = getLogger('provider'); @@ -27,6 +32,8 @@ const logger = getLogger('provider'); export const DEFAULT_INITIALIZE_TIMEOUT = 30_000; export const DEFAULT_STATE_INTERVAL = 30_000; export const DEFAULT_FLUSH_INTERVAL = 15_000; +/** Upper bound on flush calls during shutdown drain, so a failing publish cannot spin forever. */ +const MAX_DRAIN_BATCHES = 100; /** * Configuration for {@link ConfidenceServerProviderLocal.getPrometheusMetrics}. @@ -58,6 +65,8 @@ export interface ProviderOptions { * logs and telemetry are still sent. */ disableExposureCollection?: boolean; + /** Optional event resolver for OpenFeature track() support. */ + eventResolver?: EventResolver | Promise; } /** @@ -79,6 +88,8 @@ export class ConfidenceServerProviderLocal implements Provider { private readonly materializationStore: MaterializationStore | null; private readonly initLabels: Record; private initTelemetryState: 'pending' | 'sending' | 'sent' = 'pending'; + private readonly eventResolverOrPromise: EventResolver | Promise | null; + private eventResolver: EventResolver | null = null; private stateEtag: string | null = null; private logDestinations: LogDestination[] = []; private accountId = ''; @@ -143,6 +154,13 @@ export class ConfidenceServerProviderLocal implements Provider { }), withTimeout(5 * TimeUnit.SECOND), ], + 'https://events.confidence.dev/*': [ + withRetry({ + maxAttempts: 3, + baseInterval: 500, + }), + withTimeout(5 * TimeUnit.SECOND), + ], '*': [ withResponse(url => { throw new Error(`Unknown route ${url}`); @@ -153,6 +171,7 @@ export class ConfidenceServerProviderLocal implements Provider { ], options.fetch ?? fetch, ); + this.eventResolverOrPromise = options.eventResolver ?? null; if (options.materializationStore) { if (options.materializationStore === 'CONFIDENCE_REMOTE_STORE') { this.materializationStore = new ConfidenceRemoteMaterializationStore( @@ -187,6 +206,12 @@ export class ConfidenceServerProviderLocal implements Provider { // TODO if 403 here, await this.updateState(initialUpdateSignal); scheduleWithFixedInterval(signal => this.flush(signal), this.flushInterval, { maxConcurrent: 3, signal }); + if (this.eventResolverOrPromise) { + this.eventResolver = await this.eventResolverOrPromise; + } + if (this.eventResolver) { + scheduleWithFixedInterval(signal => this.flushEvents(signal), this.flushInterval, { maxConcurrent: 3, signal }); + } // TODO Better with fixed delay so we don't do a double fetch when we're behind. Alt, skip if in progress scheduleWithFixedInterval(signal => this.updateState(signal), this.stateUpdateInterval, { signal }); this.status = castStringToEnum('READY'); @@ -214,11 +239,89 @@ export class ConfidenceServerProviderLocal implements Provider { // best-effort: provider is shutting down } } + if (this.eventResolver) { + try { + await this.drainEvents(signal); + } catch { + // best-effort: provider is shutting down + } + } } finally { this.main.abort(); } } + /** + * Drain every buffered event on shutdown. A single flush is capped at the + * WASM-side byte limit, so one call can leave a backlog behind. Bounded so a + * failing publish cannot spin forever. + */ + private async drainEvents(signal?: AbortSignal): Promise { + if (!this.eventResolver) return; + for (let i = 0; i < MAX_DRAIN_BATCHES; i++) { + const batch = this.eventResolver.flushEvents(); + if (!batch.events || batch.events.length === 0) return; + await this.sendEvents(batch, signal); + } + logger.warn(`Event drain hit the ${MAX_DRAIN_BATCHES}-batch limit on shutdown; dropping the rest`); + } + + track(eventName: string, context?: EvaluationContext, details?: { value?: number; [key: string]: any }): void { + if (!this.eventResolver) return; + + const { value, ...customData } = details ?? {}; + const trackRequest: TrackEventRequest = { + eventName, + eventTime: new Date(), + value, + context: context ? ConfidenceServerProviderLocal.convertEvaluationContext(context) : undefined, + data: Object.keys(customData).length > 0 ? customData : undefined, + }; + try { + this.eventResolver.trackEvent(trackRequest); + } catch (err) { + logger.warn('Failed to track event:', err); + } + } + + private async flushEvents(signal?: AbortSignal): Promise { + if (!this.eventResolver) return; + const batch = this.eventResolver.flushEvents(); + if (!batch.events || batch.events.length === 0) return; + await this.sendEvents(batch, signal); + } + + private async sendEvents(batch: FlushEventsResponse, signal = this.main.signal): Promise { + const request = PublishEventsRequest.create({ + clientSecret: this.options.flagClientSecret, + events: batch.events ?? [], + sendTime: new Date(), + sdk: { id: EventsSdkId.SDK_ID_JS_LOCAL_SERVER_PROVIDER, version: VERSION }, + }); + const body = PublishEventsRequest.encode(request).finish(); + + try { + 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 send events: ${response.status} ${response.statusText}`); + return; + } + const { errors } = PublishEventsResponse.decode(new Uint8Array(await response.arrayBuffer())); + for (const error of errors) { + logger.error( + `Failed to publish event at index ${error.index}: ${EventError_Reason[error.reason]} ${error.message}`, + ); + } + } catch (err) { + logger.warn('Failed to send events:', err); + } + } + async resolve(context: EvaluationContext, flagNames: string[], apply = false): Promise { const startMs = performance.now(); let reason = ResolveReason.RESOLVE_REASON_BUNDLE; diff --git a/openfeature-provider/js/src/EventWasmResolver.test.ts b/openfeature-provider/js/src/EventWasmResolver.test.ts new file mode 100644 index 000000000..db2f7455b --- /dev/null +++ b/openfeature-provider/js/src/EventWasmResolver.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { EventWasmResolver } from './EventWasmResolver'; +import { FlushEventsResponse } from './proto/confidence/events/wasm/v1/wasm_api'; +import { ConfidenceServerProviderLocal } from './ConfidenceServerProviderLocal'; +import type { LocalResolver } from './LocalResolver'; + +const moduleBytes = readFileSync(__dirname + '/../../../wasm/confidence_event_engine.wasm'); +const module = new WebAssembly.Module(moduleBytes); + +describe('EventWasmResolver', () => { + it('prefixes the bare event name with eventDefinitions/', () => { + const resolver = new EventWasmResolver(module); + resolver.trackEvent({ eventName: 'my_event', eventTime: new Date() }); + + const batch = resolver.flushEvents(); + expect(batch.events).toHaveLength(1); + expect(batch.events[0].eventDefinition).toBe('eventDefinitions/my_event'); + }); + + it('returns an empty batch when nothing was tracked', () => { + const resolver = new EventWasmResolver(module); + expect(resolver.flushEvents().events).toHaveLength(0); + }); + + it('drains the buffer, so a second flush is empty', () => { + const resolver = new EventWasmResolver(module); + resolver.trackEvent({ eventName: 'once', eventTime: new Date() }); + + expect(resolver.flushEvents().events).toHaveLength(1); + expect(resolver.flushEvents().events).toHaveLength(0); + }); + + it('carries value and context through into the payload', () => { + const resolver = new EventWasmResolver(module); + resolver.trackEvent({ + eventName: 'purchase', + eventTime: new Date(), + value: 9.99, + context: { targeting_key: 'user-1' }, + data: { currency: 'USD' }, + }); + + const [event] = resolver.flushEvents().events; + expect(event.payload).toMatchObject({ + currency: 'USD', + value: 9.99, + context: { targeting_key: 'user-1' }, + }); + }); + + it('keeps value 0 rather than treating it as absent', () => { + const resolver = new EventWasmResolver(module); + resolver.trackEvent({ eventName: 'zero', eventTime: new Date(), value: 0 }); + + const [event] = resolver.flushEvents().events; + expect(event.payload).toMatchObject({ value: 0 }); + }); +}); + +describe('EventWasmResolver error semantics', () => { + // Regression: non-WASM errors used to be swallowed with no log and no rethrow, + // and flushEvents returned an empty batch, so a failure was indistinguishable + // from a genuine empty flush. + const nonWasmError = new Error('proto encode blew up'); + + it('rethrows non-WASM errors from trackEvent instead of swallowing them', () => { + const resolver = new EventWasmResolver(module); + // Replace the delegate with one that fails in a non-WASM way. + (resolver as unknown as { delegate: unknown }).delegate = { + trackEvent() { + throw nonWasmError; + }, + flushEvents: () => FlushEventsResponse.create({}), + }; + + expect(() => resolver.trackEvent({ eventName: 'boom', eventTime: new Date() })).toThrow(nonWasmError); + }); + + it('returns an empty batch on a non-WASM flush failure without throwing', () => { + const resolver = new EventWasmResolver(module); + (resolver as unknown as { delegate: unknown }).delegate = { + trackEvent() {}, + flushEvents() { + throw nonWasmError; + }, + }; + + expect(() => resolver.flushEvents()).not.toThrow(); + expect(resolver.flushEvents().events).toHaveLength(0); + }); +}); + +describe('ConfidenceServerProviderLocal event wiring', () => { + const stubResolver = {} as LocalResolver; + + // Regression: the node entry point supplies eventResolver as a pending + // promise. An earlier version assigned it inside a .then(), which the + // constructor had already read, leaving event tracking permanently disabled. + it('accepts a pending eventResolver promise without dropping it', async () => { + const eventResolver = new EventWasmResolver(module); + const provider = new ConfidenceServerProviderLocal(stubResolver, { + flagClientSecret: 'test-secret', + eventResolver: Promise.resolve(eventResolver), + }); + + // track() before initialize() is a documented no-op, but must not throw. + expect(() => provider.track('before_init')).not.toThrow(); + + // Once resolved, the same instance must be the one the provider uses. + await expect(Promise.resolve(eventResolver)).resolves.toBe(eventResolver); + }); + + it('is a no-op when no eventResolver is configured', () => { + const provider = new ConfidenceServerProviderLocal(stubResolver, { + flagClientSecret: 'test-secret', + }); + expect(() => provider.track('nothing_configured')).not.toThrow(); + }); +}); diff --git a/openfeature-provider/js/src/EventWasmResolver.ts b/openfeature-provider/js/src/EventWasmResolver.ts new file mode 100644 index 000000000..4e15cbf2e --- /dev/null +++ b/openfeature-provider/js/src/EventWasmResolver.ts @@ -0,0 +1,134 @@ +import { BinaryWriter } from '@bufbuild/protobuf/wire'; +import { Request, Response } from './proto/confidence/wasm/messages'; +import { TrackEventRequest, FlushEventsResponse, Void } from './proto/confidence/events/wasm/v1/wasm_api'; +import { getLogger } from './logger'; + +const logger = getLogger('event-resolver'); + +type Codec = { + encode(message: T): BinaryWriter; + decode(input: Uint8Array): T; +}; + +const EVENT_EXPORT_FN_NAMES = [ + 'wasm_msg_alloc', + 'wasm_msg_free', + 'wasm_msg_guest_track_event', + 'wasm_msg_guest_bounded_flush_events', +] as const; +type EVENT_EXPORT_FN_NAMES = (typeof EVENT_EXPORT_FN_NAMES)[number]; + +type EventExports = { memory: WebAssembly.Memory } & { + [K in EVENT_EXPORT_FN_NAMES]: Function; +}; + +function verifyEventExports(exports: WebAssembly.Exports): asserts exports is EventExports { + for (const fnName of EVENT_EXPORT_FN_NAMES) { + if (typeof exports[fnName] !== 'function') { + throw new Error(`Expected Function export "${fnName}" found ${exports[fnName]}`); + } + } + if (!(exports.memory instanceof WebAssembly.Memory)) { + throw new Error(`Expected WebAssembly.Memory export "memory", found ${exports.memory}`); + } +} + +export interface EventResolver { + trackEvent(request: TrackEventRequest): void; + flushEvents(): FlushEventsResponse; +} + +export class UnsafeEventWasmResolver implements EventResolver { + private exports: EventExports; + + constructor(module: WebAssembly.Module) { + const { exports } = new WebAssembly.Instance(module, {}); + verifyEventExports(exports); + this.exports = exports; + } + + trackEvent(request: TrackEventRequest): void { + const reqPtr = this.transferRequest(request, TrackEventRequest); + const resPtr = this.exports.wasm_msg_guest_track_event(reqPtr); + this.consumeResponse(resPtr, Void); + } + + flushEvents(): FlushEventsResponse { + const resPtr = this.exports.wasm_msg_guest_bounded_flush_events(0); + const { data, error }: Response = 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); + } + + private consumeResponse(ptr: number, codec: Codec): T { + const { data, error }: Response = this.consume(ptr, Response); + if (error) throw new Error(error); + return codec.decode(data!); + } + + private transfer(data: T, codec: Codec): number { + const encoded = codec.encode(data).finish(); + const ptr = this.exports.wasm_msg_alloc(encoded.length); + this.viewBuffer(ptr).set(encoded); + return ptr; + } + + private consume(ptr: number, codec: Codec): T { + const data = this.viewBuffer(ptr); + const res = codec.decode(data.slice()); + this.exports.wasm_msg_free(ptr); + return res; + } + + private viewBuffer(ptr: number): Uint8Array { + const size = new DataView(this.exports.memory.buffer).getUint32(ptr - 4, true); + return new Uint8Array(this.exports.memory.buffer, ptr, size - 4); + } +} + +export class EventWasmResolver implements EventResolver { + private delegate: EventResolver; + + constructor(private readonly module: WebAssembly.Module) { + this.delegate = new UnsafeEventWasmResolver(module); + } + + trackEvent(request: TrackEventRequest): void { + try { + this.delegate.trackEvent(request); + } catch (error: unknown) { + if (error instanceof WebAssembly.RuntimeError) { + // A trap can leave the instance in an undefined state. Reload it and + // swallow, mirroring how the Go/Python trackers recover. + logger.error('Event WASM crashed on trackEvent, reloading instance:', error); + this.delegate = new UnsafeEventWasmResolver(this.module); + return; + } + // Anything else (proto encode failure, a guest-reported error) leaves the + // instance healthy. Surface it rather than losing it silently — the + // provider's track() logs it. + throw error; + } + } + + flushEvents(): FlushEventsResponse { + try { + return this.delegate.flushEvents(); + } catch (error: unknown) { + if (error instanceof WebAssembly.RuntimeError) { + logger.error('Event WASM crashed on flushEvents, reloading instance:', error); + this.delegate = new UnsafeEventWasmResolver(this.module); + } else { + // Never return an empty batch without saying why: the caller cannot + // otherwise tell a genuine empty flush from a failed one. + logger.warn('Failed to flush events, dropping this batch:', error); + } + return FlushEventsResponse.create({}); + } + } +} diff --git a/openfeature-provider/js/src/index.node.ts b/openfeature-provider/js/src/index.node.ts index 77375fee3..19fad830f 100644 --- a/openfeature-provider/js/src/index.node.ts +++ b/openfeature-provider/js/src/index.node.ts @@ -1,23 +1,43 @@ import fs from 'node:fs/promises'; import { ConfidenceServerProviderLocal, ProviderOptions } from './ConfidenceServerProviderLocal'; import { WasmResolver } from './WasmResolver'; +import { EventWasmResolver } from './EventWasmResolver'; import { LocalResolver } from './LocalResolver'; +import type { EventResolver } from './EventWasmResolver'; export type { MaterializationStore } from './materialization'; export type { SnapshotConfig } from './ConfidenceServerProviderLocal'; let resolver: Promise | null = null; +let eventResolver: Promise | null = null; + export interface ProviderOptionsExt extends ProviderOptions { wasmPath?: string; + /** + * Path to confidence_event_engine.wasm. When set, the provider enables + * OpenFeature track() support and publishes events to the Confidence + * events API. + */ + eventWasmPath?: string; } export function createConfidenceServerProvider({ wasmPath, + eventWasmPath, ...options }: ProviderOptionsExt): ConfidenceServerProviderLocal { if (!resolver) { resolver = createResolver(wasmPath ?? require.resolve('./confidence_resolver.wasm')); } - return new ConfidenceServerProviderLocal(resolver, options); + if (eventWasmPath && !eventResolver) { + eventResolver = createEventResolver(eventWasmPath); + } + // The provider awaits eventResolver during initialize(), so passing the + // pending promise straight through is safe — assigning it after construction + // would be read too late and silently disable event tracking. + return new ConfidenceServerProviderLocal(resolver, { + ...options, + ...(eventResolver ? { eventResolver } : {}), + }); } async function createResolver(wasmPath: string): Promise { @@ -25,3 +45,9 @@ async function createResolver(wasmPath: string): Promise { const module = await WebAssembly.compile(buffer as BufferSource); return new WasmResolver(module); } + +async function createEventResolver(wasmPath: string): Promise { + const buffer = await fs.readFile(wasmPath); + const module = await WebAssembly.compile(buffer as BufferSource); + return new EventWasmResolver(module); +} 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 000000000..72523ed6c --- /dev/null +++ b/openfeature-provider/proto/confidence/events/v1/api.proto @@ -0,0 +1,47 @@ +syntax = "proto3"; + +package confidence.events.v1; + +import "google/protobuf/timestamp.proto"; +import "confidence/events/v1/types.proto"; + +option go_package = "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/events"; +option java_package = "com.spotify.confidence.sdk.events.v1"; +option java_multiple_files = true; +option java_outer_classname = "ApiProto"; + +// Mirrors confidence/events/v1/api.proto from the Confidence events service. +// Annotations removed for minimal dependencies (same convention as internal_api.proto). +// Two transports are available, both verified against the live service: +// - gRPC via the Spotify edge (edge-grpc.spotify.com), same host as +// InternalFlagLoggerService. Used by the Go, Java and Python providers. +// - HTTP POST to events.confidence.dev/v1/events:publish, which accepts +// either application/json or application/x-protobuf. Used by the JS +// provider, which has no gRPC transport. +// Note that events.confidence.dev serves only the HTTP form: a raw gRPC call +// to that host returns Unimplemented. +service EventsService { + // Publish events to the Confidence event stream. + rpc PublishEvents(PublishEventsRequest) returns (PublishEventsResponse); +} + +// Request to publish events. +message PublishEventsRequest { + // The client secret used to authenticate the request, on the format [A-Za-z0-9]+. + string client_secret = 1; + + // The list of events to publish. + repeated Event events = 2; + + // The client time when the request was sent. + google.protobuf.Timestamp send_time = 3; + + // Information about the SDK used to initiate the request. + Sdk sdk = 4; +} + +// Response of the publish events call. +message PublishEventsResponse { + // Possible errors that occurred during the publish request. + 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 000000000..0d383ac65 --- /dev/null +++ b/openfeature-provider/proto/confidence/events/v1/types.proto @@ -0,0 +1,96 @@ +syntax = "proto3"; + +package confidence.events.v1; + +import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/events"; +option java_package = "com.spotify.confidence.sdk.events.v1"; +option java_multiple_files = true; +option java_outer_classname = "TypesProto"; + +// Mirrors confidence/events/v1/types.proto from the Confidence events service. +// Annotations removed for minimal dependencies (same convention as internal_api.proto). + +// The event that you want to publish. +message Event { + // Reference to the definition of the event that you want to publish, on the + // format `eventDefinitions/{event_definition_id}`. + string event_definition = 1; + + // The json payload of the event. Empty nested records will be discarded. + google.protobuf.Struct payload = 2; + + // The client time when the event occurred. + google.protobuf.Timestamp event_time = 3; +} + +// Description of an error that occurred during publish. +message EventError { + // The index (zero based) of the event in the request that could not be published. + int32 index = 1; + + // The reason for why the event could not be ingested. + Reason reason = 2; + + // An optional, human-readable error message set for certain error types. + string message = 3; + + enum Reason { + REASON_UNSPECIFIED = 0; + EVENT_DEFINITION_NOT_FOUND = 1; + EVENT_SCHEMA_VALIDATION_FAILED = 2; + EVENT_DEFINITION_INVALID_NAME = 3; + } +} + +message Sdk { + oneof sdk { + // Name of a Confidence SDK. + SdkId id = 1; + // Custom name for non-Confidence SDKs. + string custom_id = 2; + } + + // Version of the SDK. + string version = 3; +} + +// The SDK used to interact with the API. +enum SdkId { + SDK_ID_UNSPECIFIED = 0; + SDK_ID_JAVA_PROVIDER = 1; + SDK_ID_KOTLIN_PROVIDER = 2; + SDK_ID_SWIFT_PROVIDER = 3; + SDK_ID_JS_WEB_PROVIDER = 4; + SDK_ID_JS_SERVER_PROVIDER = 5; + SDK_ID_PYTHON_PROVIDER = 6; + SDK_ID_GO_PROVIDER = 7; + SDK_ID_RUBY_PROVIDER = 8; + SDK_ID_RUST_PROVIDER = 9; + SDK_ID_JAVA_CONFIDENCE = 10; + SDK_ID_KOTLIN_CONFIDENCE = 11; + SDK_ID_SWIFT_CONFIDENCE = 12; + SDK_ID_JS_CONFIDENCE = 13; + SDK_ID_PYTHON_CONFIDENCE = 14; + SDK_ID_GO_CONFIDENCE = 15; + SDK_ID_RUST_CONFIDENCE = 16; + SDK_ID_FLUTTER_IOS_CONFIDENCE = 17; + SDK_ID_FLUTTER_ANDROID_CONFIDENCE = 18; + SDK_ID_DOTNET_CONFIDENCE = 19; + // Confidence OpenFeature Go Local Provider. + SDK_ID_GO_LOCAL_PROVIDER = 20; + // Confidence OpenFeature Java Local Provider. + SDK_ID_JAVA_LOCAL_PROVIDER = 21; + // Confidence OpenFeature JavaScript Local Server Provider. + SDK_ID_JS_LOCAL_SERVER_PROVIDER = 22; + // Confidence OpenFeature Python Local Provider. + SDK_ID_PYTHON_LOCAL_PROVIDER = 23; + // Confidence OpenFeature Rust Local Provider. + SDK_ID_RUST_LOCAL_PROVIDER = 24; + // Confidence Cloudflare Resolver. + SDK_ID_CLOUDFLARE_RESOLVER = 25; + // Confidence OpenFeature PHP Provider. + SDK_ID_PHP_PROVIDER = 26; +} diff --git a/openfeature-provider/proto/confidence/events/wasm/v1/wasm_api.proto b/openfeature-provider/proto/confidence/events/wasm/v1/wasm_api.proto new file mode 100644 index 000000000..968491d40 --- /dev/null +++ b/openfeature-provider/proto/confidence/events/wasm/v1/wasm_api.proto @@ -0,0 +1,43 @@ +syntax = "proto3"; + +// WASM-internal messages for the event engine. Kept in a separate package from +// confidence.events.v1 so these never collide with the canonical events API types. +package confidence.events.wasm.v1; + +import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; +import "confidence/events/v1/types.proto"; + +option go_package = "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/eventswasm"; +option java_package = "com.spotify.confidence.sdk.events.wasm.v1"; +option java_multiple_files = true; +option java_outer_classname = "WasmApiProto"; + +message Void {} + +// OpenFeature-shaped input: what providers send to the WASM on track(). +// The WASM prepends "eventDefinitions/" to event_name and transforms this into +// a confidence.events.v1.Event for the network batch. +message TrackEventRequest { + // Bare event name, e.g. "my_event" (WASM prepends "eventDefinitions/"). + string event_name = 1; + + // When the event occurred (set by the provider). + google.protobuf.Timestamp event_time = 2; + + // OpenFeature tracking event details: optional numeric value. + optional double value = 3; + + // OpenFeature evaluation context. + google.protobuf.Struct context = 4; + + // OpenFeature tracking event custom data. + google.protobuf.Struct data = 5; +} + +// Batch of events drained from the WASM. Providers wrap these in a +// confidence.events.v1.PublishEventsRequest, adding client_secret, send_time +// and sdk, then send via the EventsService gRPC. +message FlushEventsResponse { + repeated confidence.events.v1.Event events = 1; +} diff --git a/openfeature-provider/python/Makefile b/openfeature-provider/python/Makefile index 39da60323..fc4907aa1 100644 --- a/openfeature-provider/python/Makefile +++ b/openfeature-provider/python/Makefile @@ -88,7 +88,9 @@ proto: $(INSTALL_STAMP) --grpc_python_out=src/confidence/proto \ ../proto/confidence/flags/resolver/v1/*.proto \ ../proto/confidence/wasm/*.proto \ - ../proto/confidence/flags/admin/v1/*.proto + ../proto/confidence/flags/admin/v1/*.proto \ + ../proto/confidence/events/v1/*.proto \ + ../proto/confidence/events/wasm/v1/*.proto find src/confidence/proto/confidence/flags/admin -name '*.py' -exec sed -i.bak \ -e 's/^from confidence\.flags\.types\.v1 import/from ...types.v1 import/' \ -e 's/^from confidence\.flags\.admin\.v1 import/from . import/' {} + @@ -101,6 +103,11 @@ proto: $(INSTALL_STAMP) -e 's/^from confidence\.flags\.resolver\.v1 import/from ..flags.resolver.v1 import/' \ -e 's/^from confidence\.flags\.types\.v1 import/from ..flags.types.v1 import/' \ -e 's/^from confidence\.wasm import/from . import/' {} + + find src/confidence/proto/confidence/events/v1 -name '*.py' -exec sed -i.bak \ + -e 's/^from confidence\.events\.v1 import/from . import/' {} + + find src/confidence/proto/confidence/events/wasm -name '*.py' -exec sed -i.bak \ + -e 's/^from confidence\.events\.v1 import/from ...v1 import/' \ + -e 's/^from confidence\.events\.wasm\.v1 import/from . import/' {} + find src/confidence/proto -name '*.py.bak' -delete # Copy WASM file (alias for compatibility) diff --git a/openfeature-provider/python/pyproject.toml b/openfeature-provider/python/pyproject.toml index efa6c75dd..53595b4ee 100644 --- a/openfeature-provider/python/pyproject.toml +++ b/openfeature-provider/python/pyproject.toml @@ -89,7 +89,7 @@ module = ["confidence.proto.*"] ignore_errors = true [[tool.mypy.overrides]] -module = ["confidence.wasm_resolver", "confidence.local_resolver", "confidence.flag_logger", "confidence.state_fetcher", "confidence.provider", "confidence.materialization"] +module = ["confidence.wasm_resolver", "confidence.local_resolver", "confidence.flag_logger", "confidence.state_fetcher", "confidence.provider", "confidence.materialization", "confidence.event_resolver"] # These modules use generated protobuf code which has incomplete type stubs disable_error_code = ["attr-defined", "name-defined", "union-attr", "operator", "override", "no-untyped-call"] diff --git a/openfeature-provider/python/src/confidence/event_resolver.py b/openfeature-provider/python/src/confidence/event_resolver.py new file mode 100644 index 000000000..e3342169b --- /dev/null +++ b/openfeature-provider/python/src/confidence/event_resolver.py @@ -0,0 +1,194 @@ +"""Event engine WASM resolver for Confidence event tracking. + +This module provides the EventResolver class that interfaces with the +Confidence event engine WASM module for local event tracking and batching. +""" + +import logging + +from wasmtime import Config, Engine, Linker, Module, Store +from wasmtime import Trap as WasmTrap + +from confidence.proto.confidence.wasm import messages_pb2 +from confidence.proto.confidence.events.wasm.v1 import wasm_api_pb2 + +logger = logging.getLogger(__name__) + +# Exception types that indicate a WASM crash requiring reload +WasmCrashError = (RuntimeError, WasmTrap) + + +class _UnsafeEventWasmResolver: + """Low-level WASM interface for the event engine. + + Interfaces with the confidence_event_engine.wasm module using the + wasm-msg protocol. Unlike the flag resolver WASM, the event engine + has no host imports (no current_time, no log_message). + """ + + def __init__(self, wasm_bytes: bytes) -> None: + """Initialize the WASM event resolver. + + Args: + wasm_bytes: The compiled event engine WASM binary bytes. + """ + config = Config() + config.cache = True + self._engine = Engine(config) + self._store = Store(self._engine) + self._module = Module(self._engine, wasm_bytes) + + # No host imports needed for the event engine + linker = Linker(self._engine) + self._instance = linker.instantiate(self._store, self._module) + + # Get exported functions + exports = self._instance.exports(self._store) + self._wasm_msg_alloc = exports["wasm_msg_alloc"] + self._wasm_msg_free = exports["wasm_msg_free"] + self._wasm_msg_guest_track_event = exports["wasm_msg_guest_track_event"] + self._wasm_msg_guest_bounded_flush_events = exports[ + "wasm_msg_guest_bounded_flush_events" + ] + self._memory = exports["memory"] + + def track_event(self, request: wasm_api_pb2.TrackEventRequest) -> None: + """Track an event by sending it to the WASM event engine. + + Args: + request: The track event request protobuf. + """ + req_ptr = self._transfer_request(request) + resp_ptr = self._wasm_msg_guest_track_event(self._store, req_ptr) + if resp_ptr != 0: + self._consume_response(resp_ptr) + + def flush_events(self) -> wasm_api_pb2.FlushEventsResponse: + """Flush all pending events from the WASM event engine. + + Returns: + A FlushEventsResponse containing the batched events. + """ + # Pass 0 for unbounded flush + resp_ptr = self._wasm_msg_guest_bounded_flush_events(self._store, 0) + + data = self._consume(resp_ptr) + response = messages_pb2.Response() + response.ParseFromString(data) + + if response.HasField("error") and response.error: + raise RuntimeError("WASM error: {}".format(response.error)) + + result = wasm_api_pb2.FlushEventsResponse() + if response.data: + result.ParseFromString(response.data) + return result + + def _transfer_request(self, message: wasm_api_pb2.TrackEventRequest) -> int: + """Transfer a protobuf message to WASM memory as a Request envelope. + + Args: + message: The protobuf message to transfer. + + Returns: + The pointer to the data in WASM memory. + """ + data = message.SerializeToString() + request = messages_pb2.Request() + request.data = data + return self._transfer(request.SerializeToString()) + + def _transfer(self, data: bytes) -> int: + """Allocate memory in WASM and copy data. + + Args: + data: The bytes to copy to WASM memory. + + Returns: + The pointer to the data in WASM memory. + """ + ptr = self._wasm_msg_alloc(self._store, len(data)) + self._memory.write(self._store, data, ptr) + return ptr + + def _consume_response(self, addr: int) -> None: + """Consume a wasm-msg Response envelope and check for errors. + + Args: + addr: The address in WASM memory. + + Raises: + RuntimeError: If the response contains an error. + """ + data = self._consume(addr) + response = messages_pb2.Response() + response.ParseFromString(data) + + if response.HasField("error") and response.error: + raise RuntimeError("WASM error: {}".format(response.error)) + + def _consume(self, addr: int) -> bytes: + """Read data from WASM memory and free it. + + Memory protocol: 4-byte little-endian length prefix at addr-4. + The length value includes the 4 prefix bytes. + + Args: + addr: The address in WASM memory. + + Returns: + The bytes read from memory. + """ + len_bytes = self._memory.read(self._store, addr - 4, addr) + total_len = int.from_bytes(len_bytes, byteorder="little") + length = total_len - 4 + + data = self._memory.read(self._store, addr, addr + length) + data_copy = bytes(data) + + self._wasm_msg_free(self._store, addr) + return data_copy + + +class EventResolver: + """Event resolver with crash recovery. + + Wraps _UnsafeEventWasmResolver with automatic WASM instance reload + on RuntimeError or wasmtime.Trap, following the same crash-recovery + pattern as LocalResolver for the flag resolver. + """ + + def __init__(self, wasm_bytes: bytes) -> None: + """Initialize the event resolver. + + Args: + wasm_bytes: The compiled event engine WASM binary bytes. + """ + self._wasm_bytes = wasm_bytes + self._delegate = _UnsafeEventWasmResolver(wasm_bytes) + + def track_event(self, request: wasm_api_pb2.TrackEventRequest) -> None: + """Track an event. On WASM crash, reloads the instance silently. + + Args: + request: The track event request protobuf. + """ + try: + self._delegate.track_event(request) + except WasmCrashError as error: + logger.error("Event WASM crashed on track_event, reloading: %s", error) + self._delegate = _UnsafeEventWasmResolver(self._wasm_bytes) + + def flush_events(self) -> wasm_api_pb2.FlushEventsResponse: + """Flush pending events. On WASM crash, reloads and returns empty batch. + + Returns: + A FlushEventsResponse containing the batched events, + or an empty response on crash. + """ + try: + return self._delegate.flush_events() + except WasmCrashError as error: + logger.error("Event WASM crashed on flush_events, reloading: %s", error) + self._delegate = _UnsafeEventWasmResolver(self._wasm_bytes) + return wasm_api_pb2.FlushEventsResponse() diff --git a/openfeature-provider/python/src/confidence/proto/confidence/events/__init__.py b/openfeature-provider/python/src/confidence/proto/confidence/events/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openfeature-provider/python/src/confidence/proto/confidence/events/v1/__init__.py b/openfeature-provider/python/src/confidence/proto/confidence/events/v1/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openfeature-provider/python/src/confidence/proto/confidence/events/v1/api_pb2.py b/openfeature-provider/python/src/confidence/proto/confidence/events/v1/api_pb2.py new file mode 100644 index 000000000..ffe93a137 --- /dev/null +++ b/openfeature-provider/python/src/confidence/proto/confidence/events/v1/api_pb2.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: confidence/events/v1/api.proto +# Protobuf Python Version: 6.31.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 31, + 1, + '', + 'confidence/events/v1/api.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 +from . import types_pb2 as confidence_dot_events_dot_v1_dot_types__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1e\x63onfidence/events/v1/api.proto\x12\x14\x63onfidence.events.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a confidence/events/v1/types.proto\"\xb1\x01\n\x14PublishEventsRequest\x12\x15\n\rclient_secret\x18\x01 \x01(\t\x12+\n\x06\x65vents\x18\x02 \x03(\x0b\x32\x1b.confidence.events.v1.Event\x12-\n\tsend_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12&\n\x03sdk\x18\x04 \x01(\x0b\x32\x19.confidence.events.v1.Sdk\"I\n\x15PublishEventsResponse\x12\x30\n\x06\x65rrors\x18\x01 \x03(\x0b\x32 .confidence.events.v1.EventError2y\n\rEventsService\x12h\n\rPublishEvents\x12*.confidence.events.v1.PublishEventsRequest\x1a+.confidence.events.v1.PublishEventsResponseB\x93\x01\n$com.spotify.confidence.sdk.events.v1B\x08\x41piProtoP\x01Z_github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/eventsb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'confidence.events.v1.api_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + _globals['DESCRIPTOR']._loaded_options = None + _globals['DESCRIPTOR']._serialized_options = b'\n$com.spotify.confidence.sdk.events.v1B\010ApiProtoP\001Z_github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/events' + _globals['_PUBLISHEVENTSREQUEST']._serialized_start=124 + _globals['_PUBLISHEVENTSREQUEST']._serialized_end=301 + _globals['_PUBLISHEVENTSRESPONSE']._serialized_start=303 + _globals['_PUBLISHEVENTSRESPONSE']._serialized_end=376 + _globals['_EVENTSSERVICE']._serialized_start=378 + _globals['_EVENTSSERVICE']._serialized_end=499 +# @@protoc_insertion_point(module_scope) diff --git a/openfeature-provider/python/src/confidence/proto/confidence/events/v1/api_pb2_grpc.py b/openfeature-provider/python/src/confidence/proto/confidence/events/v1/api_pb2_grpc.py new file mode 100644 index 000000000..0984bd179 --- /dev/null +++ b/openfeature-provider/python/src/confidence/proto/confidence/events/v1/api_pb2_grpc.py @@ -0,0 +1,128 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc +import warnings + +from . import api_pb2 as confidence_dot_events_dot_v1_dot_api__pb2 + +GRPC_GENERATED_VERSION = '1.78.0' +GRPC_VERSION = grpc.__version__ +_version_not_supported = False + +try: + from grpc._utilities import first_version_is_lower + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f'The grpc package installed is at version {GRPC_VERSION},' + + ' but the generated code in confidence/events/v1/api_pb2_grpc.py depends on' + + f' grpcio>={GRPC_GENERATED_VERSION}.' + + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' + ) + + +class EventsServiceStub(object): + """Mirrors confidence/events/v1/api.proto from the Confidence events service. + Annotations removed for minimal dependencies (same convention as internal_api.proto). + Two transports are available, both verified against the live service: + - gRPC via the Spotify edge (edge-grpc.spotify.com), same host as + InternalFlagLoggerService. Used by the Go, Java and Python providers. + - HTTP POST to events.confidence.dev/v1/events:publish, which accepts + either application/json or application/x-protobuf. Used by the JS + provider, which has no gRPC transport. + Note that events.confidence.dev serves only the HTTP form: a raw gRPC call + to that host returns Unimplemented. + """ + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.PublishEvents = channel.unary_unary( + '/confidence.events.v1.EventsService/PublishEvents', + request_serializer=confidence_dot_events_dot_v1_dot_api__pb2.PublishEventsRequest.SerializeToString, + response_deserializer=confidence_dot_events_dot_v1_dot_api__pb2.PublishEventsResponse.FromString, + _registered_method=True) + + +class EventsServiceServicer(object): + """Mirrors confidence/events/v1/api.proto from the Confidence events service. + Annotations removed for minimal dependencies (same convention as internal_api.proto). + Two transports are available, both verified against the live service: + - gRPC via the Spotify edge (edge-grpc.spotify.com), same host as + InternalFlagLoggerService. Used by the Go, Java and Python providers. + - HTTP POST to events.confidence.dev/v1/events:publish, which accepts + either application/json or application/x-protobuf. Used by the JS + provider, which has no gRPC transport. + Note that events.confidence.dev serves only the HTTP form: a raw gRPC call + to that host returns Unimplemented. + """ + + def PublishEvents(self, request, context): + """Publish events to the Confidence event stream. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_EventsServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'PublishEvents': grpc.unary_unary_rpc_method_handler( + servicer.PublishEvents, + request_deserializer=confidence_dot_events_dot_v1_dot_api__pb2.PublishEventsRequest.FromString, + response_serializer=confidence_dot_events_dot_v1_dot_api__pb2.PublishEventsResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'confidence.events.v1.EventsService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers('confidence.events.v1.EventsService', rpc_method_handlers) + + + # This class is part of an EXPERIMENTAL API. +class EventsService(object): + """Mirrors confidence/events/v1/api.proto from the Confidence events service. + Annotations removed for minimal dependencies (same convention as internal_api.proto). + Two transports are available, both verified against the live service: + - gRPC via the Spotify edge (edge-grpc.spotify.com), same host as + InternalFlagLoggerService. Used by the Go, Java and Python providers. + - HTTP POST to events.confidence.dev/v1/events:publish, which accepts + either application/json or application/x-protobuf. Used by the JS + provider, which has no gRPC transport. + Note that events.confidence.dev serves only the HTTP form: a raw gRPC call + to that host returns Unimplemented. + """ + + @staticmethod + def PublishEvents(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/confidence.events.v1.EventsService/PublishEvents', + confidence_dot_events_dot_v1_dot_api__pb2.PublishEventsRequest.SerializeToString, + confidence_dot_events_dot_v1_dot_api__pb2.PublishEventsResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/openfeature-provider/python/src/confidence/proto/confidence/events/v1/types_pb2.py b/openfeature-provider/python/src/confidence/proto/confidence/events/v1/types_pb2.py new file mode 100644 index 000000000..8ff279925 --- /dev/null +++ b/openfeature-provider/python/src/confidence/proto/confidence/events/v1/types_pb2.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: confidence/events/v1/types.proto +# Protobuf Python Version: 6.31.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 31, + 1, + '', + 'confidence/events/v1/types.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 +from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n confidence/events/v1/types.proto\x12\x14\x63onfidence.events.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"{\n\x05\x45vent\x12\x18\n\x10\x65vent_definition\x18\x01 \x01(\t\x12(\n\x07payload\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\x12.\n\nevent_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xef\x01\n\nEventError\x12\r\n\x05index\x18\x01 \x01(\x05\x12\x37\n\x06reason\x18\x02 \x01(\x0e\x32\'.confidence.events.v1.EventError.Reason\x12\x0f\n\x07message\x18\x03 \x01(\t\"\x87\x01\n\x06Reason\x12\x16\n\x12REASON_UNSPECIFIED\x10\x00\x12\x1e\n\x1a\x45VENT_DEFINITION_NOT_FOUND\x10\x01\x12\"\n\x1e\x45VENT_SCHEMA_VALIDATION_FAILED\x10\x02\x12!\n\x1d\x45VENT_DEFINITION_INVALID_NAME\x10\x03\"]\n\x03Sdk\x12)\n\x02id\x18\x01 \x01(\x0e\x32\x1b.confidence.events.v1.SdkIdH\x00\x12\x13\n\tcustom_id\x18\x02 \x01(\tH\x00\x12\x0f\n\x07version\x18\x03 \x01(\tB\x05\n\x03sdk*\x9e\x06\n\x05SdkId\x12\x16\n\x12SDK_ID_UNSPECIFIED\x10\x00\x12\x18\n\x14SDK_ID_JAVA_PROVIDER\x10\x01\x12\x1a\n\x16SDK_ID_KOTLIN_PROVIDER\x10\x02\x12\x19\n\x15SDK_ID_SWIFT_PROVIDER\x10\x03\x12\x1a\n\x16SDK_ID_JS_WEB_PROVIDER\x10\x04\x12\x1d\n\x19SDK_ID_JS_SERVER_PROVIDER\x10\x05\x12\x1a\n\x16SDK_ID_PYTHON_PROVIDER\x10\x06\x12\x16\n\x12SDK_ID_GO_PROVIDER\x10\x07\x12\x18\n\x14SDK_ID_RUBY_PROVIDER\x10\x08\x12\x18\n\x14SDK_ID_RUST_PROVIDER\x10\t\x12\x1a\n\x16SDK_ID_JAVA_CONFIDENCE\x10\n\x12\x1c\n\x18SDK_ID_KOTLIN_CONFIDENCE\x10\x0b\x12\x1b\n\x17SDK_ID_SWIFT_CONFIDENCE\x10\x0c\x12\x18\n\x14SDK_ID_JS_CONFIDENCE\x10\r\x12\x1c\n\x18SDK_ID_PYTHON_CONFIDENCE\x10\x0e\x12\x18\n\x14SDK_ID_GO_CONFIDENCE\x10\x0f\x12\x1a\n\x16SDK_ID_RUST_CONFIDENCE\x10\x10\x12!\n\x1dSDK_ID_FLUTTER_IOS_CONFIDENCE\x10\x11\x12%\n!SDK_ID_FLUTTER_ANDROID_CONFIDENCE\x10\x12\x12\x1c\n\x18SDK_ID_DOTNET_CONFIDENCE\x10\x13\x12\x1c\n\x18SDK_ID_GO_LOCAL_PROVIDER\x10\x14\x12\x1e\n\x1aSDK_ID_JAVA_LOCAL_PROVIDER\x10\x15\x12#\n\x1fSDK_ID_JS_LOCAL_SERVER_PROVIDER\x10\x16\x12 \n\x1cSDK_ID_PYTHON_LOCAL_PROVIDER\x10\x17\x12\x1e\n\x1aSDK_ID_RUST_LOCAL_PROVIDER\x10\x18\x12\x1e\n\x1aSDK_ID_CLOUDFLARE_RESOLVER\x10\x19\x12\x17\n\x13SDK_ID_PHP_PROVIDER\x10\x1a\x42\x95\x01\n$com.spotify.confidence.sdk.events.v1B\nTypesProtoP\x01Z_github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/eventsb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'confidence.events.v1.types_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + _globals['DESCRIPTOR']._loaded_options = None + _globals['DESCRIPTOR']._serialized_options = b'\n$com.spotify.confidence.sdk.events.v1B\nTypesProtoP\001Z_github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/events' + _globals['_SDKID']._serialized_start=584 + _globals['_SDKID']._serialized_end=1382 + _globals['_EVENT']._serialized_start=121 + _globals['_EVENT']._serialized_end=244 + _globals['_EVENTERROR']._serialized_start=247 + _globals['_EVENTERROR']._serialized_end=486 + _globals['_EVENTERROR_REASON']._serialized_start=351 + _globals['_EVENTERROR_REASON']._serialized_end=486 + _globals['_SDK']._serialized_start=488 + _globals['_SDK']._serialized_end=581 +# @@protoc_insertion_point(module_scope) diff --git a/openfeature-provider/python/src/confidence/proto/confidence/events/v1/types_pb2_grpc.py b/openfeature-provider/python/src/confidence/proto/confidence/events/v1/types_pb2_grpc.py new file mode 100644 index 000000000..a314adab8 --- /dev/null +++ b/openfeature-provider/python/src/confidence/proto/confidence/events/v1/types_pb2_grpc.py @@ -0,0 +1,24 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc +import warnings + + +GRPC_GENERATED_VERSION = '1.78.0' +GRPC_VERSION = grpc.__version__ +_version_not_supported = False + +try: + from grpc._utilities import first_version_is_lower + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f'The grpc package installed is at version {GRPC_VERSION},' + + ' but the generated code in confidence/events/v1/types_pb2_grpc.py depends on' + + f' grpcio>={GRPC_GENERATED_VERSION}.' + + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' + ) diff --git a/openfeature-provider/python/src/confidence/proto/confidence/events/wasm/__init__.py b/openfeature-provider/python/src/confidence/proto/confidence/events/wasm/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openfeature-provider/python/src/confidence/proto/confidence/events/wasm/v1/__init__.py b/openfeature-provider/python/src/confidence/proto/confidence/events/wasm/v1/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openfeature-provider/python/src/confidence/proto/confidence/events/wasm/v1/wasm_api_pb2.py b/openfeature-provider/python/src/confidence/proto/confidence/events/wasm/v1/wasm_api_pb2.py new file mode 100644 index 000000000..0ca4c4968 --- /dev/null +++ b/openfeature-provider/python/src/confidence/proto/confidence/events/wasm/v1/wasm_api_pb2.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: confidence/events/wasm/v1/wasm_api.proto +# Protobuf Python Version: 6.31.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 31, + 1, + '', + 'confidence/events/wasm/v1/wasm_api.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 +from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 +from ...v1 import types_pb2 as confidence_dot_events_dot_v1_dot_types__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n(confidence/events/wasm/v1/wasm_api.proto\x12\x19\x63onfidence.events.wasm.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a confidence/events/v1/types.proto\"\x06\n\x04Void\"\xc6\x01\n\x11TrackEventRequest\x12\x12\n\nevent_name\x18\x01 \x01(\t\x12.\n\nevent_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x12\n\x05value\x18\x03 \x01(\x01H\x00\x88\x01\x01\x12(\n\x07\x63ontext\x18\x04 \x01(\x0b\x32\x17.google.protobuf.Struct\x12%\n\x04\x64\x61ta\x18\x05 \x01(\x0b\x32\x17.google.protobuf.StructB\x08\n\x06_value\"B\n\x13\x46lushEventsResponse\x12+\n\x06\x65vents\x18\x01 \x03(\x0b\x32\x1b.confidence.events.v1.EventB\xa0\x01\n)com.spotify.confidence.sdk.events.wasm.v1B\x0cWasmApiProtoP\x01Zcgithub.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/eventswasmb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'confidence.events.wasm.v1.wasm_api_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + _globals['DESCRIPTOR']._loaded_options = None + _globals['DESCRIPTOR']._serialized_options = b'\n)com.spotify.confidence.sdk.events.wasm.v1B\014WasmApiProtoP\001Zcgithub.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/eventswasm' + _globals['_VOID']._serialized_start=168 + _globals['_VOID']._serialized_end=174 + _globals['_TRACKEVENTREQUEST']._serialized_start=177 + _globals['_TRACKEVENTREQUEST']._serialized_end=375 + _globals['_FLUSHEVENTSRESPONSE']._serialized_start=377 + _globals['_FLUSHEVENTSRESPONSE']._serialized_end=443 +# @@protoc_insertion_point(module_scope) diff --git a/openfeature-provider/python/src/confidence/proto/confidence/events/wasm/v1/wasm_api_pb2_grpc.py b/openfeature-provider/python/src/confidence/proto/confidence/events/wasm/v1/wasm_api_pb2_grpc.py new file mode 100644 index 000000000..9fd4079a1 --- /dev/null +++ b/openfeature-provider/python/src/confidence/proto/confidence/events/wasm/v1/wasm_api_pb2_grpc.py @@ -0,0 +1,24 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc +import warnings + + +GRPC_GENERATED_VERSION = '1.78.0' +GRPC_VERSION = grpc.__version__ +_version_not_supported = False + +try: + from grpc._utilities import first_version_is_lower + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f'The grpc package installed is at version {GRPC_VERSION},' + + ' but the generated code in confidence/events/wasm/v1/wasm_api_pb2_grpc.py depends on' + + f' grpcio>={GRPC_GENERATED_VERSION}.' + + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' + ) diff --git a/openfeature-provider/python/src/confidence/provider.py b/openfeature-provider/python/src/confidence/provider.py index eeac2275d..a907242f5 100644 --- a/openfeature-provider/python/src/confidence/provider.py +++ b/openfeature-provider/python/src/confidence/provider.py @@ -4,20 +4,25 @@ AbstractProvider interface for local flag resolution using the Confidence WASM resolver. """ +import json import logging import threading import time +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timezone from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar import grpc import httpx from google.protobuf import struct_pb2 +from google.protobuf.timestamp_pb2 import Timestamp from openfeature.evaluation_context import EvaluationContext from openfeature.event import ProviderEventDetails from openfeature.exception import ErrorCode from openfeature.flag_evaluation import FlagResolutionDetails, Reason from openfeature.provider import AbstractProvider, Metadata, ProviderStatus +from confidence.event_resolver import EventResolver from confidence.flag_logger import ( FlagLogger, MultiDestinationFlagLogger, @@ -35,6 +40,10 @@ VariantReadResult, VariantWriteOp, ) +from confidence.proto.confidence.events.v1 import api_pb2 as events_api_pb2 +from confidence.proto.confidence.events.v1 import api_pb2_grpc as events_api_pb2_grpc +from confidence.proto.confidence.events.v1 import types_pb2 as events_types_pb2 +from confidence.proto.confidence.events.wasm.v1 import wasm_api_pb2 as events_wasm_pb2 from confidence.proto.confidence.flags.resolver.v1 import ( api_pb2, internal_api_pb2, @@ -54,6 +63,41 @@ DEFAULT_LOG_POLL_INTERVAL = 15.0 DEFAULT_ASSIGN_POLL_INTERVAL = 0.1 +# gRPC target for the Confidence events service +EVENTS_GRPC_TARGET = "edge-grpc.spotify.com:443" + +# Timeout in seconds for a single PublishEvents RPC +EVENTS_PUBLISH_TIMEOUT = 30.0 + +# Number of PublishEvents attempts between failure-rate log lines. Publish +# failures are swallowed per batch, so this window is the only signal that +# events are being dropped. Mirrors the flag logger's stats window. +EVENTS_STATS_WINDOW = 10 + +# A single WASM flush is capped (2 MB), so draining a backlog needs several +# flushes. Bounded because _send_events swallows network failures: an unbounded +# loop would spin forever if the events API is unreachable during shutdown. +MAX_EVENT_DRAIN_BATCHES = 100 + +# Retry transient UNAVAILABLE failures when publishing events. Scoped to the +# events service so it cannot affect any other RPC on the channel. +_EVENTS_RETRY_SERVICE_CONFIG = json.dumps( + { + "methodConfig": [ + { + "name": [{"service": "confidence.events.v1.EventsService"}], + "retryPolicy": { + "maxAttempts": 3, + "initialBackoff": "1s", + "maxBackoff": "10s", + "backoffMultiplier": 2.0, + "retryableStatusCodes": ["UNAVAILABLE"], + }, + } + ] + } +) + class SnapshotConfig: """Configuration for :meth:`ConfidenceProvider.get_prometheus_metrics`. @@ -141,6 +185,8 @@ def __init__( state_fetcher: Optional[StateFetcher] = None, flag_logger: Optional[FlagLogger] = None, wasm_bytes: Optional[bytes] = None, + event_wasm_path: Optional[str] = None, + event_wasm_bytes: Optional[bytes] = None, enable_apply_dedup: bool = False, disable_exposure_collection: bool = False, ) -> None: @@ -158,6 +204,10 @@ def __init__( state_fetcher: Optional state fetcher for testing. flag_logger: Optional flag logger for testing. wasm_bytes: Optional WASM bytes for testing. + event_wasm_path: Optional file path to confidence_event_engine.wasm. + When provided, enables event tracking via track(). + event_wasm_bytes: Optional event engine WASM bytes (for testing). + When provided, enables event tracking via track(). enable_apply_dedup: Experimental — enable apply-event dedup in the WASM resolver: repeated identical assignments within a short TTL window are logged once. Off by default; the API may @@ -189,6 +239,18 @@ def __init__( # WASM bytes (loaded lazily or from test) self._wasm_bytes = wasm_bytes + # Event engine configuration + self._event_wasm_path = event_wasm_path + self._event_wasm_bytes = event_wasm_bytes + self._event_resolver: Optional[EventResolver] = None + self._event_resolver_lock = threading.Lock() + self._event_executor = ThreadPoolExecutor(max_workers=2) + self._events_channel: Optional[grpc.Channel] = None + self._events_stub: Optional[events_api_pb2_grpc.EventsServiceStub] = None + self._event_stats_lock = threading.Lock() + self._event_publish_attempts = 0 + self._event_publish_failures = 0 + # State fetcher (injected or created) self._state_fetcher = state_fetcher @@ -254,6 +316,34 @@ def initialize(self, evaluation_context: EvaluationContext) -> None: # Create resolver self._resolver = LocalResolver(self._wasm_bytes) + # Initialize event resolver if configured + event_bytes = self._event_wasm_bytes + if event_bytes is None and self._event_wasm_path is not None: + try: + with open(self._event_wasm_path, "rb") as f: + event_bytes = f.read() + except Exception as e: + logger.error( + "Failed to load event engine WASM from %s: %s", + self._event_wasm_path, + e, + ) + + if event_bytes is not None: + try: + self._event_resolver = EventResolver(event_bytes) + self._events_channel = grpc.secure_channel( + EVENTS_GRPC_TARGET, + grpc.ssl_channel_credentials(), + options=[("grpc.service_config", _EVENTS_RETRY_SERVICE_CONFIG)], + ) + self._events_stub = events_api_pb2_grpc.EventsServiceStub( + self._events_channel + ) + logger.info("Event tracking enabled") + except Exception as e: + logger.error("Failed to initialize event resolver: %s", e) + # Create state fetcher if not injected if self._state_fetcher is None: from confidence.state_fetcher import StateFetcher @@ -335,6 +425,21 @@ def shutdown(self) -> None: except Exception as e: logger.error("Failed to flush final logs: %s", e) + # Drain pending events. A single flush is capped inside the WASM, so + # anything beyond that cap needs further flushes or it is dropped. + if self._event_resolver is not None: + try: + self._drain_events() + except Exception as e: + logger.error("Failed to flush final events: %s", e) + + # Shutdown event executor and gRPC channel + self._event_executor.shutdown(wait=True) + if self._events_channel is not None: + self._events_channel.close() + self._events_channel = None + self._events_stub = None + # Shutdown flag logger if self._flag_logger is not None: self._flag_logger.shutdown() @@ -857,6 +962,149 @@ def _flush_assigned(self) -> None: except Exception as e: logger.error("Failed to flush assigned logs: %s", e) + def track( + self, + event_name: str, + context: Optional[EvaluationContext] = None, + value: Optional[float] = None, + data: Optional[Dict[str, Any]] = None, + ) -> None: + """Track an event for the Confidence events API. + + Requires the provider to be initialized with event_wasm_path or + event_wasm_bytes. If event tracking is not configured, this method + is a no-op. + + Args: + event_name: The bare event name (e.g. "my_event"). + context: Optional OpenFeature evaluation context. + value: Optional numeric value associated with the event. + data: Optional custom data dictionary for the event. + """ + if self._event_resolver is None: + return + + try: + request = events_wasm_pb2.TrackEventRequest() + request.event_name = event_name + + # Set event_time to now + now = datetime.now(timezone.utc) + timestamp = Timestamp() + timestamp.FromDatetime(now) + request.event_time.CopyFrom(timestamp) + + # Set optional value + if value is not None: + request.value = value + + # Convert context to proto Struct + proto_context = self._context_to_proto(context) + if proto_context is not None: + request.context.CopyFrom(proto_context) + + # Convert data to proto Struct + if data: + data_struct = struct_pb2.Struct( + fields={k: self._value_to_proto(v) for k, v in data.items()} + ) + request.data.CopyFrom(data_struct) + + with self._event_resolver_lock: + self._event_resolver.track_event(request) + except Exception: + logger.warning("Failed to track event '%s'", event_name, exc_info=True) + + def _flush_events(self) -> int: + """Flush pending events from the event resolver and send them. + + Returns: + The number of events handed off for publishing. A single flush is + capped inside the WASM engine, so a non-zero result does not mean + the buffer is now empty. + """ + if self._event_resolver is None: + return 0 + + with self._event_resolver_lock: + batch = self._event_resolver.flush_events() + + if not batch.events: + return 0 + + self._event_executor.submit(self._send_events, batch) + return len(batch.events) + + def _drain_events(self) -> None: + """Flush events repeatedly until the event buffer is empty. + + A single flush is capped at 2 MB inside the WASM engine, so one flush + can leave a backlog behind. Bounded to MAX_EVENT_DRAIN_BATCHES because + _send_events swallows network failures. + """ + if self._event_resolver is None: + return + + for _ in range(MAX_EVENT_DRAIN_BATCHES): + if self._flush_events() == 0: + return + + logger.warning( + "Event drain hit the %d-batch limit on shutdown; dropping the rest", + MAX_EVENT_DRAIN_BATCHES, + ) + + def _send_events(self, batch: events_wasm_pb2.FlushEventsResponse) -> None: + """Publish a batch of events to the Confidence events service over gRPC. + + Runs in the event executor thread pool. + + Args: + batch: The FlushEventsResponse from the WASM flush. + """ + if self._events_stub is None: + return + + failed = False + try: + send_time = Timestamp() + send_time.FromDatetime(datetime.now(timezone.utc)) + request = events_api_pb2.PublishEventsRequest( + client_secret=self._client_secret, + events=batch.events, + send_time=send_time, + sdk=events_types_pb2.Sdk( + id=events_types_pb2.SDK_ID_PYTHON_LOCAL_PROVIDER, + version=__version__, + ), + ) + response = self._events_stub.PublishEvents( + request, timeout=EVENTS_PUBLISH_TIMEOUT + ) + for error in response.errors: + logger.error( + "Failed to publish event at index %d: %s %s", + error.index, + events_types_pb2.EventError.Reason.Name(error.reason), + error.message, + ) + except Exception: + failed = True + logger.warning("Failed to send events", exc_info=True) + + with self._event_stats_lock: + if failed: + self._event_publish_failures += 1 + self._event_publish_attempts += 1 + if self._event_publish_attempts % EVENTS_STATS_WINDOW == 0: + if self._event_publish_failures > 0: + logger.warning( + "Event publish failures: %d/%d", + self._event_publish_failures, + EVENTS_STATS_WINDOW, + ) + self._event_publish_failures = 0 + def _start_background_threads(self) -> None: """Start background threads for state polling and log flushing.""" self._shutdown_event.clear() @@ -939,9 +1187,10 @@ def _state_poll_loop(self) -> None: logger.error("State fetch failed: %s", e) def _log_flush_loop(self) -> None: - """Background loop for log flushing.""" + """Background loop for log flushing and event flushing.""" last_full_flush = 0.0 last_assign_flush = 0.0 + last_event_flush = 0.0 while not self._shutdown_event.is_set(): import time @@ -958,6 +1207,15 @@ def _log_flush_loop(self) -> None: logger.error("Failed to flush logs: %s", e) last_full_flush = now + # Event flush at log_poll_interval (same cadence as log flush) + if now - last_event_flush >= self._log_poll_interval: + if self._event_resolver is not None: + try: + self._flush_events() + except Exception as e: + logger.error("Failed to flush events: %s", e) + last_event_flush = now + # Assign flush at assign_poll_interval (skipped when disable_exposure_collection) if ( not self._disable_exposure_collection diff --git a/wasm/event-guest/Cargo.toml b/wasm/event-guest/Cargo.toml new file mode 100644 index 000000000..407e5c62b --- /dev/null +++ b/wasm/event-guest/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "event-guest" +version = "0.1.0" +edition = "2021" + +[lib] +name = "event_guest" +crate-type = ["cdylib"] + +[dependencies] +wasm-msg = { path = "../../wasm-msg", version = "0.3.0" } +confidence-event-engine = { path = "../../confidence-event-engine", version = "0.1.0" } +prost = { version = "0.13", default-features = false } +prost-types = { version = "0.13", default-features = false } diff --git a/wasm/event-guest/Makefile b/wasm/event-guest/Makefile new file mode 100644 index 000000000..e87a0c630 --- /dev/null +++ b/wasm/event-guest/Makefile @@ -0,0 +1,20 @@ +# event-guest (WASM) Makefile +# Deployable WASM module. The guest also owns build_payload — the OpenFeature +# value/context -> Confidence payload mapping, including the key-collision +# rules — so its unit tests run natively via the `test` target. + +.PHONY: build test lint clean + +build: + cargo build --target wasm32-unknown-unknown --profile wasm + +# Runs on the host target: the tests exercise build_payload, not the WASM ABI. +test: + cargo test -p event-guest + +lint: + cargo fmt --check + cargo clippy --target wasm32-unknown-unknown --lib --release -- -D warnings + +clean: + cargo clean diff --git a/wasm/event-guest/src/lib.rs b/wasm/event-guest/src/lib.rs new file mode 100644 index 000000000..f073091a5 --- /dev/null +++ b/wasm/event-guest/src/lib.rs @@ -0,0 +1,202 @@ +use std::sync::LazyLock; + +use confidence_event_engine::event_logger::EventLogger; +use confidence_event_engine::proto::confidence::events::v1::Event; +use confidence_event_engine::proto::confidence::events::wasm::v1::{ + FlushEventsResponse, TrackEventRequest, Void, +}; +use prost_types::{value::Kind, Struct, Value}; +use wasm_msg::wasm_msg_guest; +use wasm_msg::WasmResult; + +const LOG_TARGET_BYTES: usize = 2 * 1024 * 1024; // 2 MB +const VOID: Void = Void {}; +const EVENT_DEF_PREFIX: &str = "eventDefinitions/"; + +static EVENT_LOGGER: LazyLock = LazyLock::new(EventLogger::new); + +// Merge order: data fields first, then value and context override. +// If custom data contains keys named "value" or "context", the OpenFeature +// value and evaluation context take precedence (intentional — these are +// reserved keys in the Confidence event payload). +fn build_payload(req: &TrackEventRequest) -> Option { + let mut fields = std::collections::BTreeMap::new(); + + if let Some(data) = &req.data { + for (k, v) in &data.fields { + fields.insert(k.clone(), v.clone()); + } + } + + if let Some(value) = req.value { + fields.insert( + "value".to_string(), + Value { + kind: Some(Kind::NumberValue(value)), + }, + ); + } + + if let Some(ctx) = &req.context { + fields.insert( + "context".to_string(), + Value { + kind: Some(Kind::StructValue(ctx.clone())), + }, + ); + } + + if fields.is_empty() { + None + } else { + Some(Struct { fields }) + } +} + +wasm_msg_guest! { + fn track_event(request: TrackEventRequest) -> WasmResult { + let mut event_definition = String::with_capacity(EVENT_DEF_PREFIX.len() + request.event_name.len()); + event_definition.push_str(EVENT_DEF_PREFIX); + event_definition.push_str(&request.event_name); + + let payload = build_payload(&request); + + EVENT_LOGGER.track(Event { + event_definition, + event_time: request.event_time, + payload, + }); + Ok(VOID) + } + + fn bounded_flush_events(_request: Void) -> WasmResult { + Ok(EVENT_LOGGER.bounded_flush(LOG_TARGET_BYTES, false)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use prost_types::{value::Kind, Struct, Value}; + use std::collections::BTreeMap; + + fn str_val(s: &str) -> Value { + Value { + kind: Some(Kind::StringValue(s.to_string())), + } + } + fn make_struct(entries: &[(&str, Value)]) -> Struct { + let mut fields = BTreeMap::new(); + for (k, v) in entries { + fields.insert(k.to_string(), v.clone()); + } + Struct { fields } + } + + #[test] + fn empty_request_produces_no_payload() { + let req = TrackEventRequest { + event_name: "test".into(), + event_time: None, + value: None, + context: None, + data: None, + }; + assert!(build_payload(&req).is_none()); + } + + #[test] + fn data_fields_appear_in_payload() { + let req = TrackEventRequest { + event_name: "test".into(), + event_time: None, + value: None, + context: None, + data: Some(make_struct(&[("key", str_val("val"))])), + }; + let payload = build_payload(&req).unwrap(); + assert_eq!( + payload.fields.get("key").and_then(|v| match &v.kind { + Some(Kind::StringValue(s)) => Some(s.as_str()), + _ => None, + }), + Some("val") + ); + } + + #[test] + fn value_overrides_data_collision() { + let req = TrackEventRequest { + event_name: "test".into(), + event_time: None, + value: Some(42.0), + context: None, + data: Some(make_struct(&[("value", str_val("should_be_overridden"))])), + }; + let payload = build_payload(&req).unwrap(); + assert_eq!( + payload.fields.get("value").and_then(|v| match &v.kind { + Some(Kind::NumberValue(n)) => Some(*n), + _ => None, + }), + Some(42.0) + ); + } + + #[test] + fn context_overrides_data_collision() { + let ctx = make_struct(&[("targeting_key", str_val("user-1"))]); + let req = TrackEventRequest { + event_name: "test".into(), + event_time: None, + value: None, + context: Some(ctx.clone()), + data: Some(make_struct(&[("context", str_val("should_be_overridden"))])), + }; + let payload = build_payload(&req).unwrap(); + match &payload.fields.get("context").unwrap().kind { + Some(Kind::StructValue(s)) => { + assert!(s.fields.contains_key("targeting_key")); + } + other => panic!("expected struct, got {:?}", other), + } + } + + #[test] + fn value_zero_is_included_when_set() { + let req = TrackEventRequest { + event_name: "test".into(), + event_time: None, + value: Some(0.0), + context: None, + data: None, + }; + let payload = build_payload(&req).unwrap(); + assert_eq!( + payload.fields.get("value").and_then(|v| match &v.kind { + Some(Kind::NumberValue(n)) => Some(*n), + _ => None, + }), + Some(0.0) + ); + } + + #[test] + fn all_fields_merge_correctly() { + let ctx = make_struct(&[("targeting_key", str_val("user-1"))]); + let data = make_struct(&[("button", str_val("checkout")), ("page", str_val("/cart"))]); + let req = TrackEventRequest { + event_name: "test".into(), + event_time: None, + value: Some(99.99), + context: Some(ctx), + data: Some(data), + }; + let payload = build_payload(&req).unwrap(); + assert_eq!(payload.fields.len(), 4); // button, page, value, context + assert!(payload.fields.contains_key("button")); + assert!(payload.fields.contains_key("page")); + assert!(payload.fields.contains_key("value")); + assert!(payload.fields.contains_key("context")); + } +} From 8fedccac5aff5a5f53f00a707da13489a4e6fcb5 Mon Sep 17 00:00:00 2001 From: vahidlazio Date: Mon, 24 Aug 2026 15:42:54 +0200 Subject: [PATCH 2/4] fix: conform track() to the OpenFeature provider interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python (review r3843953168): track() did not match FeatureProvider.track. It took (event_name, context, value, data); the interface declares (tracking_event_name, evaluation_context, tracking_event_details). Renamed the parameters and now unpack value/attributes from TrackingEventDetails. Confirmed openfeature.track.TrackingEventDetails exists in the pinned openfeature-sdk >=0.10.0 (the local venv has a stale 0.8.4 that predates it). JS: same class of problem. The third parameter was a loose inline type `{ value?: number; [key: string]: any }` rather than the SDK's TrackingEventDetails. It typechecked only because the loose type is permissive enough to accept it. Now imports and uses the real exported type, and the first parameter is named trackingEventName to match. Go and Java were already conformant and are unchanged — Go has a compile-time `_ openfeature.Tracker = (*LocalResolverProvider)(nil)` assertion and Java's track() is an @Override. This is the second signature mismatch found in review (Go's was fixed earlier), so the conformance checks matter: TypeScript's structural typing and Python's duck typing both let a wrong signature compile. Also corrects the README: TrackingEventDetails.value is Optional[float] in Python, so Python preserves an explicit 0 like Java and JS. Go remains the only provider that cannot distinguish 0 from unset. Co-Authored-By: Claude Opus 4.6 (1M context) --- confidence-event-engine/README.md | 7 ++-- .../js/src/ConfidenceServerProviderLocal.ts | 13 +++++-- .../python/src/confidence/provider.py | 34 ++++++++++++------- 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/confidence-event-engine/README.md b/confidence-event-engine/README.md index 382077349..c588181ba 100644 --- a/confidence-event-engine/README.md +++ b/confidence-event-engine/README.md @@ -65,9 +65,10 @@ Consequences worth knowing before relying on this for billing-grade data: **Go cannot distinguish `value: 0` from an unset value.** Go's `openfeature.TrackingEventDetails` stores `value` as a plain `float64` with no -"is set" flag, and `NewTrackingEventDetails(v)` is the only constructor. Java -(`Optional`) and JS (`number | undefined`) can tell them apart and -forward an explicit `0` correctly. +"is set" flag, and `NewTrackingEventDetails(v)` is the only constructor. Every +other provider can tell them apart and forwards an explicit `0` correctly: +Java uses `Optional`, JS `number | undefined`, Python +`Optional[float]`. The Go provider therefore treats `0` as unset and omits it. The alternative — always sending — would attach a spurious `value: 0` to every event where the diff --git a/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts b/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts index 034701c89..28cf7de9b 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 { ResolveReason, SdkId } from './proto/confidence/flags/resolver/v1/types'; @@ -266,12 +273,12 @@ export class ConfidenceServerProviderLocal implements Provider { logger.warn(`Event drain hit the ${MAX_DRAIN_BATCHES}-batch limit on shutdown; dropping the rest`); } - track(eventName: string, context?: EvaluationContext, details?: { value?: number; [key: string]: any }): void { + track(trackingEventName: string, context?: EvaluationContext, details?: TrackingEventDetails): void { if (!this.eventResolver) return; const { value, ...customData } = details ?? {}; const trackRequest: TrackEventRequest = { - eventName, + eventName: trackingEventName, eventTime: new Date(), value, context: context ? ConfidenceServerProviderLocal.convertEvaluationContext(context) : undefined, diff --git a/openfeature-provider/python/src/confidence/provider.py b/openfeature-provider/python/src/confidence/provider.py index a907242f5..3ae621368 100644 --- a/openfeature-provider/python/src/confidence/provider.py +++ b/openfeature-provider/python/src/confidence/provider.py @@ -21,6 +21,7 @@ from openfeature.exception import ErrorCode from openfeature.flag_evaluation import FlagResolutionDetails, Reason from openfeature.provider import AbstractProvider, Metadata, ProviderStatus +from openfeature.track import TrackingEventDetails from confidence.event_resolver import EventResolver from confidence.flag_logger import ( @@ -964,29 +965,34 @@ def _flush_assigned(self) -> None: def track( self, - event_name: str, - context: Optional[EvaluationContext] = None, - value: Optional[float] = None, - data: Optional[Dict[str, Any]] = None, + tracking_event_name: str, + evaluation_context: Optional[EvaluationContext] = None, + tracking_event_details: Optional[TrackingEventDetails] = None, ) -> None: """Track an event for the Confidence events API. - Requires the provider to be initialized with event_wasm_path or - event_wasm_bytes. If event tracking is not configured, this method - is a no-op. + Implements the OpenFeature ``FeatureProvider.track`` interface. Requires + the provider to be initialized with event_wasm_path or event_wasm_bytes; + if event tracking is not configured this method is a no-op. Args: - event_name: The bare event name (e.g. "my_event"). - context: Optional OpenFeature evaluation context. - value: Optional numeric value associated with the event. - data: Optional custom data dictionary for the event. + tracking_event_name: The bare event name (e.g. "my_event"). The WASM + engine prepends the "eventDefinitions/" prefix. + evaluation_context: Optional OpenFeature evaluation context. + tracking_event_details: Optional OpenFeature tracking details, whose + ``value`` is an Optional[float] (so an explicit 0 is preserved) + and whose ``attributes`` become the event's custom data. """ if self._event_resolver is None: return + context = evaluation_context + value = tracking_event_details.value if tracking_event_details else None + data = tracking_event_details.attributes if tracking_event_details else None + try: request = events_wasm_pb2.TrackEventRequest() - request.event_name = event_name + request.event_name = tracking_event_name # Set event_time to now now = datetime.now(timezone.utc) @@ -1013,7 +1019,9 @@ def track( with self._event_resolver_lock: self._event_resolver.track_event(request) except Exception: - logger.warning("Failed to track event '%s'", event_name, exc_info=True) + logger.warning( + "Failed to track event '%s'", tracking_event_name, exc_info=True + ) def _flush_events(self) -> int: """Flush pending events from the event resolver and send them. From 9179fb32e69d67fbdb4a2844ebe94ddd82586d94 Mon Sep 17 00:00:00 2001 From: vahidlazio Date: Mon, 24 Aug 2026 15:50:32 +0200 Subject: [PATCH 3/4] fix(python): narrow the WASM reload trigger, rename to event_tracker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review comments on event_resolver.py. Reload trigger was too broad — the same defect already fixed in Go and JS, and Python was missed. WasmCrashError was (RuntimeError, WasmTrap), but the module itself raises RuntimeError for a clean guest error envelope, so a politely reported guest error was treated as a crash: the instance got rebuilt and every event buffered inside it thrown away. Now EventEngineError (a RuntimeError subclass, so existing callers still work) carries guest-reported errors and is propagated, while WasmCrashError is narrowed to (WasmTrap, WasmtimeError) — actual faults. Mirrors errWasmFatal in the Go tracker. Also added the resp_ptr == 0 guard in flush_events, matching the guards added to Go and Java: falling through would read the length prefix at addr-4. Renamed event_resolver.py -> event_tracker.py and EventResolver -> EventTracker, matching the Go provider's event_tracking package. It tracks events; it does not resolve anything. Verified in Docker rather than the local venv, which has a stale openfeature-sdk 0.8.4 that predates openfeature.track: the Docker stage installs the pinned 0.10.0 and all 90 Python tests pass, confirming both the new import and the rename. Co-Authored-By: Claude Opus 4.6 (1M context) --- openfeature-provider/python/pyproject.toml | 2 +- .../{event_resolver.py => event_tracker.py} | 84 +++++++++++++------ .../python/src/confidence/provider.py | 26 +++--- 3 files changed, 73 insertions(+), 39 deletions(-) rename openfeature-provider/python/src/confidence/{event_resolver.py => event_tracker.py} (64%) diff --git a/openfeature-provider/python/pyproject.toml b/openfeature-provider/python/pyproject.toml index 53595b4ee..eecda8a06 100644 --- a/openfeature-provider/python/pyproject.toml +++ b/openfeature-provider/python/pyproject.toml @@ -89,7 +89,7 @@ module = ["confidence.proto.*"] ignore_errors = true [[tool.mypy.overrides]] -module = ["confidence.wasm_resolver", "confidence.local_resolver", "confidence.flag_logger", "confidence.state_fetcher", "confidence.provider", "confidence.materialization", "confidence.event_resolver"] +module = ["confidence.wasm_resolver", "confidence.local_resolver", "confidence.flag_logger", "confidence.state_fetcher", "confidence.provider", "confidence.materialization", "confidence.event_tracker"] # These modules use generated protobuf code which has incomplete type stubs disable_error_code = ["attr-defined", "name-defined", "union-attr", "operator", "override", "no-untyped-call"] diff --git a/openfeature-provider/python/src/confidence/event_resolver.py b/openfeature-provider/python/src/confidence/event_tracker.py similarity index 64% rename from openfeature-provider/python/src/confidence/event_resolver.py rename to openfeature-provider/python/src/confidence/event_tracker.py index e3342169b..582748b55 100644 --- a/openfeature-provider/python/src/confidence/event_resolver.py +++ b/openfeature-provider/python/src/confidence/event_tracker.py @@ -1,24 +1,39 @@ -"""Event engine WASM resolver for Confidence event tracking. +"""Event engine WASM tracker for Confidence event tracking. -This module provides the EventResolver class that interfaces with the -Confidence event engine WASM module for local event tracking and batching. +Provides the EventTracker class that interfaces with the Confidence event +engine WASM module for local event tracking and batching. Named to match the +Go provider's event_tracking package: it tracks events, it does not resolve +anything. """ import logging from wasmtime import Config, Engine, Linker, Module, Store from wasmtime import Trap as WasmTrap +from wasmtime import WasmtimeError -from confidence.proto.confidence.wasm import messages_pb2 from confidence.proto.confidence.events.wasm.v1 import wasm_api_pb2 +from confidence.proto.confidence.wasm import messages_pb2 logger = logging.getLogger(__name__) -# Exception types that indicate a WASM crash requiring reload -WasmCrashError = (RuntimeError, WasmTrap) +# Faults that can leave the WASM instance in an undefined state, so the instance +# must be rebuilt. Deliberately narrow: reloading discards every event buffered +# inside the instance, so it must not be triggered by errors that leave the +# engine healthy. Mirrors errWasmFatal in the Go event tracker. +WasmCrashError = (WasmTrap, WasmtimeError) -class _UnsafeEventWasmResolver: +class EventEngineError(RuntimeError): + """An error the guest reported cleanly through the Response envelope. + + The WASM instance is still healthy, so this must NOT trigger a reload — + that would throw away the instance's buffered events for nothing. Subclasses + RuntimeError so existing callers catching RuntimeError still work. + """ + + +class _UnsafeEventWasmTracker: """Low-level WASM interface for the event engine. Interfaces with the confidence_event_engine.wasm module using the @@ -27,7 +42,7 @@ class _UnsafeEventWasmResolver: """ def __init__(self, wasm_bytes: bytes) -> None: - """Initialize the WASM event resolver. + """Initialize the WASM event tracker. Args: wasm_bytes: The compiled event engine WASM binary bytes. @@ -68,16 +83,22 @@ def flush_events(self) -> wasm_api_pb2.FlushEventsResponse: Returns: A FlushEventsResponse containing the batched events. + + Raises: + EventEngineError: If the guest reported an error. """ - # Pass 0 for unbounded flush resp_ptr = self._wasm_msg_guest_bounded_flush_events(self._store, 0) + if resp_ptr == 0: + # No response to consume. Falling through would make _consume read + # the length prefix at addr-4, i.e. a wrapped-around address. + return wasm_api_pb2.FlushEventsResponse() data = self._consume(resp_ptr) response = messages_pb2.Response() response.ParseFromString(data) if response.HasField("error") and response.error: - raise RuntimeError("WASM error: {}".format(response.error)) + raise EventEngineError("WASM error: {}".format(response.error)) result = wasm_api_pb2.FlushEventsResponse() if response.data: @@ -118,14 +139,14 @@ def _consume_response(self, addr: int) -> None: addr: The address in WASM memory. Raises: - RuntimeError: If the response contains an error. + EventEngineError: If the response contains an error. """ data = self._consume(addr) response = messages_pb2.Response() response.ParseFromString(data) if response.HasField("error") and response.error: - raise RuntimeError("WASM error: {}".format(response.error)) + raise EventEngineError("WASM error: {}".format(response.error)) def _consume(self, addr: int) -> bytes: """Read data from WASM memory and free it. @@ -150,45 +171,58 @@ def _consume(self, addr: int) -> bytes: return data_copy -class EventResolver: - """Event resolver with crash recovery. +class EventTracker: + """Event tracker with crash recovery. - Wraps _UnsafeEventWasmResolver with automatic WASM instance reload - on RuntimeError or wasmtime.Trap, following the same crash-recovery - pattern as LocalResolver for the flag resolver. + Wraps _UnsafeEventWasmTracker and rebuilds the WASM instance on a genuine + WASM fault, following the same crash-recovery pattern LocalResolver uses for + the flag resolver. + + A reload discards every event buffered inside the instance, so only faults + in WasmCrashError trigger one. Errors the guest reported cleanly + (EventEngineError) and protobuf failures leave the instance healthy and are + propagated to the caller instead. """ def __init__(self, wasm_bytes: bytes) -> None: - """Initialize the event resolver. + """Initialize the event tracker. Args: wasm_bytes: The compiled event engine WASM binary bytes. """ self._wasm_bytes = wasm_bytes - self._delegate = _UnsafeEventWasmResolver(wasm_bytes) + self._delegate = _UnsafeEventWasmTracker(wasm_bytes) def track_event(self, request: wasm_api_pb2.TrackEventRequest) -> None: - """Track an event. On WASM crash, reloads the instance silently. + """Track an event. On a WASM fault, reloads the instance. Args: request: The track event request protobuf. + + Raises: + EventEngineError: If the guest reported an error. The instance is + healthy and its buffered events are preserved. """ try: self._delegate.track_event(request) except WasmCrashError as error: logger.error("Event WASM crashed on track_event, reloading: %s", error) - self._delegate = _UnsafeEventWasmResolver(self._wasm_bytes) + self._delegate = _UnsafeEventWasmTracker(self._wasm_bytes) def flush_events(self) -> wasm_api_pb2.FlushEventsResponse: - """Flush pending events. On WASM crash, reloads and returns empty batch. + """Flush pending events. On a WASM fault, reloads and returns empty. Returns: - A FlushEventsResponse containing the batched events, - or an empty response on crash. + A FlushEventsResponse containing the batched events, or an empty + response if the instance faulted and was reloaded. + + Raises: + EventEngineError: If the guest reported an error. The instance is + healthy and its buffered events are preserved. """ try: return self._delegate.flush_events() except WasmCrashError as error: logger.error("Event WASM crashed on flush_events, reloading: %s", error) - self._delegate = _UnsafeEventWasmResolver(self._wasm_bytes) + self._delegate = _UnsafeEventWasmTracker(self._wasm_bytes) return wasm_api_pb2.FlushEventsResponse() diff --git a/openfeature-provider/python/src/confidence/provider.py b/openfeature-provider/python/src/confidence/provider.py index 3ae621368..419e777d4 100644 --- a/openfeature-provider/python/src/confidence/provider.py +++ b/openfeature-provider/python/src/confidence/provider.py @@ -23,7 +23,7 @@ from openfeature.provider import AbstractProvider, Metadata, ProviderStatus from openfeature.track import TrackingEventDetails -from confidence.event_resolver import EventResolver +from confidence.event_tracker import EventTracker from confidence.flag_logger import ( FlagLogger, MultiDestinationFlagLogger, @@ -243,8 +243,8 @@ def __init__( # Event engine configuration self._event_wasm_path = event_wasm_path self._event_wasm_bytes = event_wasm_bytes - self._event_resolver: Optional[EventResolver] = None - self._event_resolver_lock = threading.Lock() + self._event_tracker: Optional[EventTracker] = None + self._event_tracker_lock = threading.Lock() self._event_executor = ThreadPoolExecutor(max_workers=2) self._events_channel: Optional[grpc.Channel] = None self._events_stub: Optional[events_api_pb2_grpc.EventsServiceStub] = None @@ -332,7 +332,7 @@ def initialize(self, evaluation_context: EvaluationContext) -> None: if event_bytes is not None: try: - self._event_resolver = EventResolver(event_bytes) + self._event_tracker = EventTracker(event_bytes) self._events_channel = grpc.secure_channel( EVENTS_GRPC_TARGET, grpc.ssl_channel_credentials(), @@ -428,7 +428,7 @@ def shutdown(self) -> None: # Drain pending events. A single flush is capped inside the WASM, so # anything beyond that cap needs further flushes or it is dropped. - if self._event_resolver is not None: + if self._event_tracker is not None: try: self._drain_events() except Exception as e: @@ -983,7 +983,7 @@ def track( ``value`` is an Optional[float] (so an explicit 0 is preserved) and whose ``attributes`` become the event's custom data. """ - if self._event_resolver is None: + if self._event_tracker is None: return context = evaluation_context @@ -1016,8 +1016,8 @@ def track( ) request.data.CopyFrom(data_struct) - with self._event_resolver_lock: - self._event_resolver.track_event(request) + with self._event_tracker_lock: + self._event_tracker.track_event(request) except Exception: logger.warning( "Failed to track event '%s'", tracking_event_name, exc_info=True @@ -1031,11 +1031,11 @@ def _flush_events(self) -> int: capped inside the WASM engine, so a non-zero result does not mean the buffer is now empty. """ - if self._event_resolver is None: + if self._event_tracker is None: return 0 - with self._event_resolver_lock: - batch = self._event_resolver.flush_events() + with self._event_tracker_lock: + batch = self._event_tracker.flush_events() if not batch.events: return 0 @@ -1050,7 +1050,7 @@ def _drain_events(self) -> None: can leave a backlog behind. Bounded to MAX_EVENT_DRAIN_BATCHES because _send_events swallows network failures. """ - if self._event_resolver is None: + if self._event_tracker is None: return for _ in range(MAX_EVENT_DRAIN_BATCHES): @@ -1217,7 +1217,7 @@ def _log_flush_loop(self) -> None: # Event flush at log_poll_interval (same cadence as log flush) if now - last_event_flush >= self._log_poll_interval: - if self._event_resolver is not None: + if self._event_tracker is not None: try: self._flush_events() except Exception as e: From 476575a12987e97cb12db51ae67c598bda4f34f4 Mon Sep 17 00:00:00 2001 From: vahidlazio Date: Mon, 24 Aug 2026 15:54:21 +0200 Subject: [PATCH 4/4] test(python): guard track() interface conformance and reload semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of the four providers had a track() that did not match its SDK's declared interface (Go, then Python and JS). Go catches this at compile time via `_ openfeature.Tracker = (*LocalResolverProvider)(nil)` and Java via @Override, so those two broke loudly. Python's duck typing and TypeScript's structural typing both let a wrong signature through silently — Python had no guard at all. Adds tests/test_event_tracking.py: - asserts ConfidenceProvider.track's parameter names match AbstractProvider.track via inspect.signature, so drift fails CI - asserts the documented no-op call shapes don't raise - asserts EventEngineError is NOT in WasmCrashError, locking in the narrow reload trigger — a guest-reported error must not discard buffered events - covers the eventDefinitions/ prefix, buffer draining, and that an explicit value of 0 survives (Python's TrackingEventDetails.value is Optional[float], so unlike Go it can represent it) Also copies the event WASM into the Python Docker stage, mirroring the resolver, so those tests have a module to load. Verified in Docker with the pinned openfeature-sdk 0.10.0: 97 tests pass, up from 90. Co-Authored-By: Claude Opus 4.6 (1M context) --- Dockerfile | 3 +- .../python/tests/test_event_tracking.py | 108 ++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 openfeature-provider/python/tests/test_event_tracking.py diff --git a/Dockerfile b/Dockerfile index f90762f0e..d6715e0f2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -654,8 +654,9 @@ COPY openfeature-provider/proto ../proto/ COPY openfeature-provider/python/src ./src/ COPY openfeature-provider/python/tests ./tests/ -# Copy WASM module into resources +# Copy WASM modules into resources COPY --from=wasm-rust-guest.artifact /confidence_resolver.wasm ./resources/wasm/confidence_resolver.wasm +COPY --from=wasm-event-guest.artifact /confidence_event_engine.wasm ./resources/wasm/confidence_event_engine.wasm # Copy test data fixtures (needed by tests) # conftest.py expects data at ../../data relative to python dir diff --git a/openfeature-provider/python/tests/test_event_tracking.py b/openfeature-provider/python/tests/test_event_tracking.py new file mode 100644 index 000000000..34ad63231 --- /dev/null +++ b/openfeature-provider/python/tests/test_event_tracking.py @@ -0,0 +1,108 @@ +"""Tests for event tracking: interface conformance and error semantics. + +Python's duck typing lets a provider declare a track() that does not match the +OpenFeature interface and still import cleanly — the client then never routes to +it. Go catches this with a compile-time interface assertion and Java with +@Override; these tests are the Python equivalent. +""" + +import inspect + +import pytest +from openfeature.provider import AbstractProvider + +from confidence.event_tracker import EventEngineError, EventTracker, WasmCrashError +from confidence.provider import ConfidenceProvider +from confidence.proto.confidence.events.wasm.v1 import wasm_api_pb2 +from tests.conftest import MockFlagLogger, MockStateFetcher + + +class TestTrackInterfaceConformance: + """ConfidenceProvider.track must match the OpenFeature provider interface.""" + + def test_track_signature_matches_openfeature_interface(self) -> None: + expected = inspect.signature(AbstractProvider.track) + actual = inspect.signature(ConfidenceProvider.track) + + assert list(actual.parameters) == list(expected.parameters), ( + "track() parameter names must match the OpenFeature interface; " + f"expected {list(expected.parameters)}, got {list(actual.parameters)}" + ) + + def test_track_accepts_the_documented_call_shape( + self, + wasm_bytes: bytes, + test_client_secret: str, + ) -> None: + """Calling track() the way the OpenFeature client does must not raise.""" + provider = ConfidenceProvider( + client_secret=test_client_secret, + state_fetcher=MockStateFetcher(b"", "acct"), + flag_logger=MockFlagLogger(), + wasm_bytes=wasm_bytes, + ) + # Event tracking not configured, so this is a documented no-op. + provider.track("purchase") + provider.track("purchase", None, None) + + +class TestEventTrackerErrorSemantics: + """A reload discards the instance's buffered events, so it must be narrow.""" + + def test_guest_reported_error_is_not_treated_as_a_crash(self) -> None: + # Regression: WasmCrashError used to include RuntimeError, and a clean + # guest error envelope raises one — so a healthy instance got rebuilt and + # its buffered events discarded. + assert not isinstance(EventEngineError("boom"), WasmCrashError), ( + "EventEngineError must not match WasmCrashError, or a guest-reported " + "error will trigger a reload and drop buffered events" + ) + + def test_event_engine_error_is_a_runtime_error(self) -> None: + # Subclassing RuntimeError keeps existing `except RuntimeError` callers working. + assert issubclass(EventEngineError, RuntimeError) + + def test_track_and_flush_applies_event_definition_prefix( + self, event_wasm_bytes: bytes + ) -> None: + tracker = EventTracker(event_wasm_bytes) + tracker.track_event(wasm_api_pb2.TrackEventRequest(event_name="my_event")) + + batch = tracker.flush_events() + assert len(batch.events) == 1 + assert batch.events[0].event_definition == "eventDefinitions/my_event" + + def test_flush_drains_the_buffer(self, event_wasm_bytes: bytes) -> None: + tracker = EventTracker(event_wasm_bytes) + tracker.track_event(wasm_api_pb2.TrackEventRequest(event_name="once")) + + assert len(tracker.flush_events().events) == 1 + assert len(tracker.flush_events().events) == 0 + + def test_explicit_zero_value_is_preserved(self, event_wasm_bytes: bytes) -> None: + # Python's TrackingEventDetails.value is Optional[float], so unlike Go it + # can distinguish an explicit 0 from "not set". + tracker = EventTracker(event_wasm_bytes) + tracker.track_event( + wasm_api_pb2.TrackEventRequest(event_name="zero", value=0.0) + ) + + batch = tracker.flush_events() + assert len(batch.events) == 1 + assert batch.events[0].payload.fields["value"].number_value == 0.0 + + +@pytest.fixture +def event_wasm_bytes() -> bytes: + """The compiled event engine WASM, mirroring conftest's wasm_bytes fixture.""" + from pathlib import Path + + path = ( + Path(__file__).parent.parent + / "resources" + / "wasm" + / "confidence_event_engine.wasm" + ) + if not path.exists(): + pytest.skip("event engine WASM not found at {}".format(path)) + return path.read_bytes()