From 09187c3a9a0979ab87092dc5c45b1291c91d7b8a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 06:40:36 +0000 Subject: [PATCH] feat: public feature_flags surface on track/patch (DEV-1214) Add an optional feature_flags map (string->string) to the public event surfaces (AiEvent, Event, BeginOptions, PatchOptions, FinishOptions) plus Interaction::set_feature_flags/set_feature_flag. Flags serialize verbatim as a top-level feature_flags object on the wire (sibling of ai_data/properties), matching the JS event-shipper. Additive-only: omitted flags leave the request body byte-identical (skip_serializing_if on the buffer payload). Wire flags through EventPatch buffer + merge (last write wins per key). Declare the events.feature_flags conformance capability and map the harness feature_flags step arg through to the public API. Bump harness pin cf744e9 (#47) -> 1a646e1 (#53): the old pin predated 18 ratcheted scenarios (baseline exit 1) and the feature_flags capability/scenarios; Co-Authored-By: pavel #53 is the SHA this repo's failures.txt is authored against (baseline green). --- .github/workflows/conformance.yml | 2 +- CHANGELOG.md | 18 ++++ Cargo.lock | 2 +- Cargo.toml | 2 +- conformance/Cargo.lock | 2 +- conformance/src/main.rs | 51 ++++++++--- src/buffer.rs | 14 +++ src/client.rs | 5 + src/events.rs | 50 ++++++++++ tests/wire_format.rs | 146 ++++++++++++++++++++++++++++++ 10 files changed, 275 insertions(+), 17 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 1c9673e..693780c 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -74,7 +74,7 @@ env: CARGO_TERM_COLOR: always # invisible-tools/raindrop-sdk-harness — see "Harness pin" in the header. HARNESS_REPO: invisible-tools/raindrop-sdk-harness - HARNESS_REF: cf744e9c53185c1fd6c350888f64a016b46a10a3 # main @ 2026-07-14 (signal capability active + signal scenarios, DEV-1201) + HARNESS_REF: 1a646e15bcaaa6a2321665e2aab708b457c1027a # main @ 2026-07-15 (#53; feature_flags capability+scenarios (DEV-1210), sustained hot-path + shutdown-drain scenarios (DEV-1213) — the SHA this repo's failures.txt ratchet is authored against) SERVER_URL: http://127.0.0.1:8787 # Driver binary produced by `cargo build --manifest-path conformance/Cargo.toml`. # conformance/ is a standalone bin crate outside the workspace that depends on diff --git a/CHANGELOG.md b/CHANGELOG.md index 341f894..4194fb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to this crate are documented here. Format follows [Keep a Ch ## [Unreleased] +## [0.0.9] - 2026-07-16 + +### Added + +- **Public `feature_flags` surface on events.** `AiEvent`, `Event`, + `BeginOptions`, `PatchOptions`, and `FinishOptions` gain an optional + `feature_flags: BTreeMap` field, plus + `Interaction::set_feature_flags` / `set_feature_flag` convenience methods. + Flags serialize verbatim as a top-level `feature_flags` string→string object + on the wire — a sibling of `ai_data` / `properties`, matching the JS SDK's + event-shipper (dawn ingest `TrackEventSchema.feature_flags`). Flags supplied + across a `begin`→`patch`→`finish` lifecycle merge like `properties` (last + write wins per key). This is **additive-only**: callers that pass no flags + omit the key entirely, so their request bodies are byte-identical to before + (covered by `omitted_feature_flags_leave_body_unchanged`). The conformance + driver declares the `events.feature_flags` capability and maps the harness + `feature_flags` step arg through to the public API. (DEV-1214) + ## [0.0.8] - 2026-06-26 ### Added diff --git a/Cargo.lock b/Cargo.lock index 25d7bc8..afba1dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1082,7 +1082,7 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "raindrop-ai" -version = "0.0.8" +version = "0.0.9" dependencies = [ "base64", "rand 0.8.6", diff --git a/Cargo.toml b/Cargo.toml index 920083b..a918611 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "raindrop-ai" -version = "0.0.8" +version = "0.0.9" edition = "2021" rust-version = "1.88" description = "Raindrop AI observability SDK for Rust (Beta) — track AI events, signals, and OTLP-style traces." diff --git a/conformance/Cargo.lock b/conformance/Cargo.lock index f965769..870ef5c 100644 --- a/conformance/Cargo.lock +++ b/conformance/Cargo.lock @@ -704,7 +704,7 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "raindrop-ai" -version = "0.0.8" +version = "0.0.9" dependencies = [ "base64", "rand 0.8.7", diff --git a/conformance/src/main.rs b/conformance/src/main.rs index e10f895..3099d2e 100644 --- a/conformance/src/main.rs +++ b/conformance/src/main.rs @@ -56,6 +56,14 @@ const CAPABILITIES: &[&str] = &[ // scenarios against the route this SDK actually uses. "events.track_ai_partial", "events.track_partial", + // events.feature_flags (DEV-1214): the public event surfaces accept an + // optional feature_flags map (string→string) that ships verbatim as the + // top-level `feature_flags` wire key. NOTE: this SDK ships track_ai via + // events/track_partial (not events/track), so the request-shape scenario — + // which asserts the events/track route — is a route gap (DEV-1149), + // ratcheted like every other events/track scenario; the wire shape itself + // is proven by the SDK's unit tests. + "events.feature_flags", "identify", "signal", ]; @@ -151,6 +159,20 @@ fn properties(args: &Map, key: &str) -> BTreeMap { } } +/// Map the language-neutral `feature_flags` step arg onto the SDK's public +/// feature-flag surface (a string→string map). Non-string values are dropped: +/// the wire contract is `Record`, so a driver must never +/// forward a non-string flag value. +fn feature_flags(args: &Map) -> BTreeMap { + match args.get("feature_flags").and_then(Value::as_object) { + Some(map) => map + .iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect(), + None => BTreeMap::new(), + } +} + fn timestamp(args: &Map, step: &str) -> Result, Failure> { match args.get("timestamp").and_then(Value::as_str) { Some(raw) => OffsetDateTime::parse(raw, &Rfc3339).map(Some).map_err(|e| { @@ -295,6 +317,7 @@ impl Driver { timestamp: timestamp(args, "track")?, properties: properties(args, "properties"), attachments: attachments(args, "track")?, + feature_flags: feature_flags(args), }; self.client()? .track_event(event) @@ -314,6 +337,7 @@ impl Driver { convo_id: optional_str(args, "convo_id"), properties: properties(args, "properties"), attachments: attachments(args, "track_ai")?, + feature_flags: feature_flags(args), }; self.client()? .track_ai(event) @@ -368,6 +392,7 @@ impl Driver { convo_id: optional_str(args, "convo_id"), properties: properties(args, "properties"), attachments: attachments(args, "begin")?, + feature_flags: feature_flags(args), }; let interaction = self.client()?.begin(opts).await; self.interaction = Some(interaction); @@ -389,6 +414,7 @@ impl Driver { convo_id: optional_str(args, "convo_id"), properties: properties(args, "properties"), attachments: attachments(args, "patch")?, + feature_flags: feature_flags(args), is_pending: None, }; interaction @@ -408,6 +434,7 @@ impl Driver { model: optional_str(args, "model"), properties: properties(args, "properties"), attachments: attachments(args, "finish")?, + feature_flags: feature_flags(args), }; interaction .finish(opts) @@ -431,19 +458,17 @@ async fn run_steps(raw: &str) -> ExitCode { let mut driver = Driver::default(); for (index, step) in steps.iter().enumerate() { - let (name, args) = match step.as_object().and_then(|m| { - if m.len() == 1 { - m.iter().next() - } else { - None - } - }) { - Some((name, args)) => (name.clone(), args.clone()), - None => { - eprintln!("driver: step {index} is not a single-key object"); - return ExitCode::from(1); - } - }; + let (name, args) = + match step + .as_object() + .and_then(|m| if m.len() == 1 { m.iter().next() } else { None }) + { + Some((name, args)) => (name.clone(), args.clone()), + None => { + eprintln!("driver: step {index} is not a single-key object"); + return ExitCode::from(1); + } + }; let start = Instant::now(); match driver.execute(&name, &args).await { Ok(()) => {} diff --git a/src/buffer.rs b/src/buffer.rs index afe8508..8bad7e7 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -23,6 +23,7 @@ pub(crate) struct EventPatch { pub model: String, pub properties: BTreeMap, pub attachments: Vec, + pub feature_flags: BTreeMap, pub is_pending: Option, pub timestamp: Option, } @@ -47,6 +48,13 @@ pub(crate) struct TrackPartialPayload { pub ai_data: Option, pub properties: BTreeMap, pub attachments: Vec, + /// Feature flags carried verbatim as a top-level string→string object, + /// sibling to `ai_data` / `properties` — the ratified wire shape + /// (dawn ingest `TrackEventSchema.feature_flags: z.record(z.string())`, + /// matching the JS event-shipper). Omitted entirely when empty so requests + /// for callers that pass no flags are byte-identical to before. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub feature_flags: BTreeMap, pub is_pending: bool, } @@ -315,6 +323,11 @@ pub(crate) fn merge_event_patches(target: EventPatch, source: EventPatch) -> Eve if !source.attachments.is_empty() { out.attachments = merge_attachments(&out.attachments, &source.attachments); } + if !source.feature_flags.is_empty() { + for (k, v) in source.feature_flags { + out.feature_flags.insert(k, v); + } + } out } @@ -382,6 +395,7 @@ fn build_track_partial_payload( ai_data: None, properties, attachments, + feature_flags: patch.feature_flags.clone(), is_pending, }; diff --git a/src/client.rs b/src/client.rs index 51d49cd..4b18222 100644 --- a/src/client.rs +++ b/src/client.rs @@ -426,6 +426,7 @@ impl Client { model: String::new(), properties: event.properties, attachments: event.attachments, + feature_flags: event.feature_flags, is_pending: Some(false), timestamp: event.timestamp, }; @@ -455,6 +456,7 @@ impl Client { model: event.model, properties: event.properties, attachments: event.attachments, + feature_flags: event.feature_flags, is_pending: Some(false), timestamp: event.timestamp, }; @@ -487,6 +489,7 @@ impl Client { model: opts.model, properties: opts.properties, attachments: opts.attachments, + feature_flags: opts.feature_flags, is_pending: Some(true), timestamp: opts.timestamp, }; @@ -528,6 +531,7 @@ impl Client { model: opts.model, properties: opts.properties, attachments: opts.attachments, + feature_flags: opts.feature_flags, is_pending: opts.is_pending, timestamp: opts.timestamp, }; @@ -567,6 +571,7 @@ impl Client { model: opts.model, properties: opts.properties, attachments: opts.attachments, + feature_flags: opts.feature_flags, is_pending: Some(false), timestamp: opts.timestamp, ..Default::default() diff --git a/src/events.rs b/src/events.rs index f1bb374..ba5274a 100644 --- a/src/events.rs +++ b/src/events.rs @@ -60,6 +60,10 @@ pub struct Event { pub properties: BTreeMap, /// Attachments to ship with the event. pub attachments: Vec, + /// Feature flags active for this event (flag name → value). Serialized + /// verbatim as the top-level `feature_flags` string→string object on the + /// wire (matching the JS SDK's event-shipper). Empty → key omitted. + pub feature_flags: BTreeMap, } /// An AI event (model invocation). @@ -85,6 +89,10 @@ pub struct AiEvent { pub properties: BTreeMap, /// Attachments. pub attachments: Vec, + /// Feature flags active for this event (flag name → value). Serialized + /// verbatim as the top-level `feature_flags` string→string object on the + /// wire (matching the JS SDK's event-shipper). Empty → key omitted. + pub feature_flags: BTreeMap, } /// Options for [`Client::begin`]. @@ -108,6 +116,10 @@ pub struct BeginOptions { pub properties: BTreeMap, /// Initial attachments. pub attachments: Vec, + /// Feature flags active for this interaction (flag name → value). Serialized + /// verbatim as the top-level `feature_flags` string→string object on the + /// wire (matching the JS SDK's event-shipper). Empty → key omitted. + pub feature_flags: BTreeMap, } /// Options for [`Interaction::patch`] / [`Client::patch`]. @@ -131,6 +143,11 @@ pub struct PatchOptions { pub properties: BTreeMap, /// Attachments to append. pub attachments: Vec, + /// Feature flags to merge into the patch (flag name → value). Merged like + /// [`properties`](Self::properties) — last write wins per key. Serialized + /// verbatim as the top-level `feature_flags` string→string object on the + /// wire (matching the JS SDK's event-shipper). Empty → no change. + pub feature_flags: BTreeMap, /// Override the `is_pending` flag. pub is_pending: Option, } @@ -148,6 +165,12 @@ pub struct FinishOptions { pub properties: BTreeMap, /// Final attachments to append. pub attachments: Vec, + /// Feature flags to merge into the final patch (flag name → value). Merged + /// like [`properties`](Self::properties) — last write wins per key. + /// Serialized verbatim as the top-level `feature_flags` string→string + /// object on the wire (matching the JS SDK's event-shipper). Empty → no + /// change. + pub feature_flags: BTreeMap, } /// In-progress interaction returned by [`Client::begin`]. Holds an `event_id` and forwards @@ -260,6 +283,33 @@ impl Interaction { .await } + /// Merge feature flags (flag name → value) into the interaction. They ride + /// along on the next flushed patch as the top-level `feature_flags` + /// string→string object on the wire (matching the JS SDK's event-shipper). + /// An empty map is a no-op. + pub async fn set_feature_flags(&self, feature_flags: BTreeMap) -> Result<()> { + self.patch(PatchOptions { + feature_flags, + ..Default::default() + }) + .await + } + + /// Set a single feature flag (flag name → value). An empty key is a no-op. + pub async fn set_feature_flag( + &self, + key: impl Into, + value: impl Into, + ) -> Result<()> { + let key = key.into(); + if key.is_empty() { + return Ok(()); + } + let mut flags = BTreeMap::new(); + flags.insert(key, value.into()); + self.set_feature_flags(flags).await + } + /// Update the input. pub async fn set_input(&self, input: impl Into) -> Result<()> { self.patch(PatchOptions { diff --git a/tests/wire_format.rs b/tests/wire_format.rs index effec2d..8b9865d 100644 --- a/tests/wire_format.rs +++ b/tests/wire_format.rs @@ -192,6 +192,152 @@ async fn track_ai_attachments_serialize_with_type_role_and_optional_fields() { ); } +// ──────────────────────────────────────────────────────────────────────────────────── +// feature_flags wire shape (DEV-1214) +// +// `feature_flags` is a first-class ingest field: dawn's `TrackEventSchema` declares +// `feature_flags: z.record(z.string()).optional()` as a top-level sibling of `ai_data` / +// `properties`, and the JS SDK's event-shipper ships exactly that key as a +// `Record`. These tests pin that wire shape and the additive-only +// guarantee: a caller that passes no flags produces a byte-identical request to before. +// ──────────────────────────────────────────────────────────────────────────────────── + +/// `feature_flags` serialize on the wire as a top-level string→string object, verbatim +/// (UTF-8 multibyte values round-trip unmangled), NOT nested under `ai_data` or +/// `properties`. +#[tokio::test] +async fn feature_flags_serialize_as_top_level_string_map() { + let server = MockServer::start().await; + let recorder = mount_path(&server, "POST", "/events/track_partial").await; + let client = fast_client_builder(&server).build().expect("build"); + + client + .track_ai(AiEvent { + user_id: "flags-u1".into(), + event: "chat".into(), + input: "How do I enable reasoning?".into(), + output: "Toggle it in Settings.".into(), + model: "mock-gpt".into(), + feature_flags: BTreeMap::from([ + ("prompt-version".to_string(), "v2".to_string()), + ("locale-label".to_string(), "café-日本語".to_string()), + ]), + ..Default::default() + }) + .await + .expect("track_ai"); + client.close().await.expect("close"); + + let payload = recorder.requests()[0].json(); + + // Top-level `feature_flags` (snake_case), sibling to ai_data / properties. + let flags = &payload["feature_flags"]; + assert!( + flags.is_object(), + "feature_flags must be a top-level object" + ); + assert_eq!(flags["prompt-version"], "v2"); + assert_eq!( + flags["locale-label"], "café-日本語", + "UTF-8 verbatim round-trip" + ); + assert_eq!( + payload.get("featureFlags"), + None, + "MUST NOT use camelCase featureFlags" + ); + + // Flags must NOT leak into ai_data or properties. + assert_eq!(payload["ai_data"].get("feature_flags"), None); + assert_eq!(payload["properties"].get("feature_flags"), None); +} + +/// Omitting `feature_flags` leaves the wire body byte-identical to the pre-feature +/// request: the key is entirely absent (additive-only, no behavior change for existing +/// callers). +#[tokio::test] +async fn omitted_feature_flags_leave_body_unchanged() { + let server = MockServer::start().await; + let recorder = mount_path(&server, "POST", "/events/track_partial").await; + let client = fast_client_builder(&server).build().expect("build"); + + let event = AiEvent { + event_id: "evt-no-flags".into(), + user_id: "flags-u1".into(), + event: "chat".into(), + input: "hello".into(), + output: "hi".into(), + model: "mock-gpt".into(), + // feature_flags left at its Default (empty map). + ..Default::default() + }; + // The event must not carry any flags — this is the "existing caller" path. + assert!(event.feature_flags.is_empty()); + + client.track_ai(event).await.expect("track_ai"); + client.close().await.expect("close"); + + let payload = recorder.requests()[0].json(); + assert_eq!( + payload.get("feature_flags"), + None, + "omitted feature_flags must be absent from the wire body, not an empty object" + ); + // Byte-level guarantee: the serialized bytes contain no `feature_flags` token at all. + let raw = String::from_utf8(recorder.requests()[0].body.clone()).expect("utf8 body"); + assert!( + !raw.contains("feature_flags"), + "serialized body must not mention feature_flags when omitted" + ); +} + +/// Feature flags supplied across the begin → patch → finish lifecycle merge (last write +/// wins per key) and ship as the top-level object on the final payload — proving the +/// partial/patch surface carries flags too. +#[tokio::test] +async fn feature_flags_merge_across_partial_lifecycle() { + let server = MockServer::start().await; + let recorder = mount_path(&server, "POST", "/events/track_partial").await; + let client = fast_client_builder(&server).build().expect("build"); + + let interaction = client + .begin(BeginOptions { + event_id: "evt-flags-lifecycle".into(), + user_id: "flags-u1".into(), + event: "agent_run".into(), + input: "start".into(), + feature_flags: BTreeMap::from([ + ("prompt-version".to_string(), "v1".to_string()), + ("cohort".to_string(), "beta".to_string()), + ]), + ..Default::default() + }) + .await; + + // A patch overrides one key and adds another. + interaction + .set_feature_flag("prompt-version", "v2") + .await + .expect("set_feature_flag"); + + interaction + .finish(FinishOptions { + output: "done".into(), + feature_flags: BTreeMap::from([("locale".to_string(), "en".to_string())]), + ..Default::default() + }) + .await + .expect("finish"); + client.close().await.expect("close"); + + let requests = recorder.requests(); + let final_payload = requests.last().expect("at least one request").json(); + let flags = &final_payload["feature_flags"]; + assert_eq!(flags["prompt-version"], "v2", "last write wins per key"); + assert_eq!(flags["cohort"], "beta"); + assert_eq!(flags["locale"], "en"); +} + /// The full begin → patch → finish lifecycle merges patches such that the final shipped /// payload contains the SUM of all sticky data: input from begin, properties from patches, /// and output from finish.