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..d6715e0f2 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) # ============================================================================== @@ -555,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 @@ -748,6 +848,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 +866,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..c588181ba --- /dev/null +++ b/confidence-event-engine/README.md @@ -0,0 +1,89 @@ +# 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. 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 +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 000000000..f0fd87ca5 Binary files /dev/null and b/openfeature-provider/go/confidence/internal/event_tracking/assets/confidence_event_engine.wasm differ 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..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'; @@ -20,6 +27,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 +39,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 +72,8 @@ export interface ProviderOptions { * logs and telemetry are still sent. */ disableExposureCollection?: boolean; + /** Optional event resolver for OpenFeature track() support. */ + eventResolver?: EventResolver | Promise; } /** @@ -79,6 +95,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 +161,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 +178,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 +213,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 +246,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(trackingEventName: string, context?: EvaluationContext, details?: TrackingEventDetails): void { + if (!this.eventResolver) return; + + const { value, ...customData } = details ?? {}; + const trackRequest: TrackEventRequest = { + eventName: trackingEventName, + 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..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"] +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_tracker.py b/openfeature-provider/python/src/confidence/event_tracker.py new file mode 100644 index 000000000..582748b55 --- /dev/null +++ b/openfeature-provider/python/src/confidence/event_tracker.py @@ -0,0 +1,228 @@ +"""Event engine WASM tracker for Confidence event tracking. + +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.events.wasm.v1 import wasm_api_pb2 +from confidence.proto.confidence.wasm import messages_pb2 + +logger = logging.getLogger(__name__) + +# 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 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 + 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 tracker. + + 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. + + Raises: + EventEngineError: If the guest reported an error. + """ + 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 EventEngineError("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: + 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 EventEngineError("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 EventTracker: + """Event tracker with crash recovery. + + 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 tracker. + + Args: + wasm_bytes: The compiled event engine WASM binary bytes. + """ + self._wasm_bytes = wasm_bytes + self._delegate = _UnsafeEventWasmTracker(wasm_bytes) + + def track_event(self, request: wasm_api_pb2.TrackEventRequest) -> None: + """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 = _UnsafeEventWasmTracker(self._wasm_bytes) + + def flush_events(self) -> wasm_api_pb2.FlushEventsResponse: + """Flush pending events. On a WASM fault, reloads and returns empty. + + Returns: + 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 = _UnsafeEventWasmTracker(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..419e777d4 100644 --- a/openfeature-provider/python/src/confidence/provider.py +++ b/openfeature-provider/python/src/confidence/provider.py @@ -4,20 +4,26 @@ 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 openfeature.track import TrackingEventDetails +from confidence.event_tracker import EventTracker from confidence.flag_logger import ( FlagLogger, MultiDestinationFlagLogger, @@ -35,6 +41,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 +64,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 +186,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 +205,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 +240,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_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 + 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 +317,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_tracker = EventTracker(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 +426,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_tracker 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 +963,156 @@ def _flush_assigned(self) -> None: except Exception as e: logger.error("Failed to flush assigned logs: %s", e) + def track( + self, + tracking_event_name: str, + evaluation_context: Optional[EvaluationContext] = None, + tracking_event_details: Optional[TrackingEventDetails] = None, + ) -> None: + """Track an event for the Confidence events API. + + 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: + 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_tracker 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 = tracking_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_tracker_lock: + self._event_tracker.track_event(request) + except Exception: + 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. + + 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_tracker is None: + return 0 + + with self._event_tracker_lock: + batch = self._event_tracker.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_tracker 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 +1195,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 +1215,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_tracker 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/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() 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")); + } +}