Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>` 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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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."
Expand Down
2 changes: 1 addition & 1 deletion conformance/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

51 changes: 38 additions & 13 deletions conformance/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
];
Expand Down Expand Up @@ -151,6 +159,20 @@ fn properties(args: &Map<String, Value>, key: &str) -> BTreeMap<String, Value> {
}
}

/// 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<string,string>`, so a driver must never
/// forward a non-string flag value.
fn feature_flags(args: &Map<String, Value>) -> BTreeMap<String, String> {
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<String, Value>, step: &str) -> Result<Option<OffsetDateTime>, Failure> {
match args.get("timestamp").and_then(Value::as_str) {
Some(raw) => OffsetDateTime::parse(raw, &Rfc3339).map(Some).map_err(|e| {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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(()) => {}
Expand Down
14 changes: 14 additions & 0 deletions src/buffer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub(crate) struct EventPatch {
pub model: String,
pub properties: BTreeMap<String, Value>,
pub attachments: Vec<Attachment>,
pub feature_flags: BTreeMap<String, String>,
pub is_pending: Option<bool>,
pub timestamp: Option<OffsetDateTime>,
}
Expand All @@ -47,6 +48,13 @@ pub(crate) struct TrackPartialPayload {
pub ai_data: Option<AiDataPayload>,
pub properties: BTreeMap<String, Value>,
pub attachments: Vec<Attachment>,
/// 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<String, String>,
pub is_pending: bool,
}

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -382,6 +395,7 @@ fn build_track_partial_payload(
ai_data: None,
properties,
attachments,
feature_flags: patch.feature_flags.clone(),
is_pending,
};

Expand Down
5 changes: 5 additions & 0 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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()
Expand Down
50 changes: 50 additions & 0 deletions src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ pub struct Event {
pub properties: BTreeMap<String, Value>,
/// Attachments to ship with the event.
pub attachments: Vec<Attachment>,
/// 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<String, String>,
}

/// An AI event (model invocation).
Expand All @@ -85,6 +89,10 @@ pub struct AiEvent {
pub properties: BTreeMap<String, Value>,
/// Attachments.
pub attachments: Vec<Attachment>,
/// 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<String, String>,
}

/// Options for [`Client::begin`].
Expand All @@ -108,6 +116,10 @@ pub struct BeginOptions {
pub properties: BTreeMap<String, Value>,
/// Initial attachments.
pub attachments: Vec<Attachment>,
/// 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<String, String>,
}

/// Options for [`Interaction::patch`] / [`Client::patch`].
Expand All @@ -131,6 +143,11 @@ pub struct PatchOptions {
pub properties: BTreeMap<String, Value>,
/// Attachments to append.
pub attachments: Vec<Attachment>,
/// 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<String, String>,
/// Override the `is_pending` flag.
pub is_pending: Option<bool>,
}
Expand All @@ -148,6 +165,12 @@ pub struct FinishOptions {
pub properties: BTreeMap<String, Value>,
/// Final attachments to append.
pub attachments: Vec<Attachment>,
/// 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<String, String>,
}

/// In-progress interaction returned by [`Client::begin`]. Holds an `event_id` and forwards
Expand Down Expand Up @@ -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<String, String>) -> 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<String>,
value: impl Into<String>,
) -> 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<String>) -> Result<()> {
self.patch(PatchOptions {
Expand Down
Loading
Loading