Skip to content
Merged
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
10 changes: 9 additions & 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: caa57a1661f3176cd59957ef0576628d96530214 # DEV-1673 candidate @ 2026-09-15 (complete Git gate, actual retries and browser config)
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 Expand Up @@ -198,6 +198,14 @@ jobs:
echo "exit_code=$code" >> "$GITHUB_OUTPUT"
exit "$code"

- name: Require application Git regressions
if: always() && steps.gate.outputs.run == 'true'
# Keep legacy non-blocking transport checks unchanged; Git regressions
# introduced by DEV-1673 must not be hidden by continue-on-error.
run: |
node --test harness/scripts/check-app-git-results.test.mjs
node harness/scripts/check-app-git-results.mjs "$RUNNER_TEMP/report.json"

- name: Stop fault server
if: always() && steps.gate.outputs.run == 'true'
run: kill "$FAULT_SERVER_PID" 2>/dev/null || true
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.

4 changes: 2 additions & 2 deletions 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 All @@ -27,7 +27,7 @@ native-tls = ["reqwest/native-tls"]

[dependencies]
reqwest = { version = "0.13", default-features = false, features = ["json"] }
tokio = { version = "1.36", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
tokio = { version = "1.36", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process", "io-util"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "1.7", features = ["v4"] }
Expand Down
60 changes: 58 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ The official Rust SDK for [Raindrop AI](https://raindrop.ai) — track AI events

📖 **Full documentation:** [docs.raindrop.ai/sdk/rust](https://docs.raindrop.ai/sdk/rust). This README is the quick reference; the docs page is the canonical narrative tour.

> **Beta.** The crate is `0.0.6`. The wire contract against the Raindrop ingestion API is stable and verified end-to-end against the live backend on every push, but the crate API may still change in minor ways before `0.1.0`. We recommend pinning the git revision in your `Cargo.toml` and reviewing the [Known Limitations](#known-limitations) before using it in production.
> **Beta.** The crate is `0.0.9`. The wire contract against the Raindrop ingestion API is stable and verified end-to-end against the live backend on every push, but the crate API may still change in minor ways before `0.1.0`. We recommend pinning the git revision in your `Cargo.toml` and reviewing the [Known Limitations](#known-limitations) before using it in production.

## Installation

> The crate is not yet published to crates.io. Install via git for now:

```toml
[dependencies]
raindrop-ai = { git = "https://github.com/raindrop-ai/raindrop-rust", tag = "v0.0.6" }
raindrop-ai = { git = "https://github.com/raindrop-ai/raindrop-rust", tag = "v0.0.9" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
serde_json = "1"
```
Expand Down Expand Up @@ -370,6 +370,62 @@ process exit.
| `http_client` | new `reqwest::Client` | Bring your own connection-pooled HTTP client |
| `local_workshop_url` | auto-detected localhost | Mirror cloud-bound posts to a local Workshop |
| `disable_local_workshop` | — | Disable env/probe-based local Workshop mirroring |
| `app_git` | automatic | Configure application Git provenance |
| `disable_app_git` | — | Disable application Git provenance |

### Application Git provenance

Events and OTLP spans report the instrumented application's commit as
`raindrop.app.commit_sha`, with optional `raindrop.app.commit_dirty` and
`raindrop.app.branch`. This is application provenance; it does not reinterpret
the SDK's library version or OTLP `service.version`.

```rust
use raindrop::{AppGitConfig, Client};

let client = Client::builder()
.write_key("rk_...")
.app_git(
AppGitConfig::new()
.commit_sha("0123456789abcdef0123456789abcdef01234567")
.commit_dirty(false)
.branch("main"),
)
.build()?;
# Ok::<(), raindrop::Error>(())
```

Automatic local Git discovery is enabled by default, runs once per client in a
bounded background task, and uses the application's current directory. The
first operations may omit provenance while discovery is pending. Set
`source_directory` when the application source is elsewhere, opt into automatic
branch lookup with `detect_branch(true)`, disable only automatic sources with
`auto_detect(false)`, or disable all reporting with `disable_app_git()`.
Repository-selector and Git config-injection environment variables are removed
from the discovery subprocess only, so inherited observer-repository settings
cannot redirect `source_directory`; the application process environment is not
modified.

Explicit canonical event/span properties always win, including empty or
otherwise invalid values. Configuration takes precedence over
`RAINDROP_COMMIT_SHA`, `RAINDROP_COMMIT_DIRTY`, and `RAINDROP_BRANCH`.
`RAINDROP_GIT_SOURCE_DIRECTORY` selects the discovery directory,
`RAINDROP_GIT_DETECT_BRANCH=true` opts into branch discovery, and
`RAINDROP_GIT_AUTO_DETECT=false` disables automatic sources without disabling
explicit config or explicit Raindrop environment values. Automatically detected
SHAs must be full 40- or 64-character hexadecimal values; unavailable metadata
is omitted. A nonempty `source_directory` or `RAINDROP_GIT_SOURCE_DIRECTORY`
exclusively identifies the application repository: deployment and CI metadata
are not used if that selection is unavailable. Without a selected directory,
automatic precedence is deployment metadata, current-directory Git, then
contextual CI metadata. For interaction lifecycles, canonical application Git
properties are frozen per `event_id`; later raw canonical overrides persist
across pending flushes, resumed handles, and child spans until the terminal
event flush completes. If more than 10,000 operation ids are active at once and
the internal operation registry cannot retain a new id, existing operation
contexts are not evicted; unknown ids omit automatic application Git metadata
for the remainder of that client, while caller-supplied canonical properties
still pass through.

## Architecture

Expand Down
23 changes: 22 additions & 1 deletion conformance/Cargo.lock

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

1 change: 1 addition & 0 deletions conformance/failures.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
ai-fields-nested-not-toplevel@fault: missing_feature # SDK ships track_ai via events/track_partial (bare object), never events/track — 0 attempts recorded on the ratified route (found by DEV-1145) (DEV-1149)
app-git-properties-authoritative@fault: missing_feature # New strict plain-track scenario exposes the same pre-existing events/track route gap (DEV-1149); raw null/empty Git preservation is separately verified on the public partial path.
batching-close-drains-without-flush@fault: missing_feature # SDK never POSTs events/track: the scenario's assertions target the ratified batched route, which the SDK lacks — behavior ships via single-object events/track_partial (DEV-1149)
batching-double-flush-no-duplicate@fault: missing_feature # SDK never POSTs events/track: the scenario's assertions target the ratified batched route, which the SDK lacks — behavior ships via single-object events/track_partial (DEV-1149)
batching-flush-cycle-resets-queue@fault: missing_feature # SDK never POSTs events/track: the scenario's assertions target the ratified batched route, which the SDK lacks — behavior ships via single-object events/track_partial (DEV-1149)
Expand Down
114 changes: 92 additions & 22 deletions conformance/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@
//! * An unsupported step prints `unsupported:<step>` as the last stdout line
//! and exits 3.
//!
//! Client configuration comes only from the environment:
//! Transport configuration comes only from the environment:
//!
//! * `RAINDROP_SINK_URL` — ingest base URL (the driver appends `/v1/`).
//! * `RAINDROP_WRITE_KEY` — bearer write key.
//! * `RAINDROP_PROJECT_ID` — optional project slug.
//!
//! The `init.app_git` step argument is mapped through the public
//! `ClientBuilder::app_git` / `disable_app_git` API.

use std::collections::BTreeMap;
use std::io::Read;
Expand All @@ -28,8 +31,8 @@ use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;

use raindrop::{
AiEvent, Attachment, BeginOptions, Client, Event, FinishOptions, Interaction, PatchOptions,
Signal, User,
AiEvent, AppGitConfig, Attachment, BeginOptions, Client, ClientBuilder, Event, FinishOptions,
Interaction, PatchOptions, Signal, User,
};

const DRIVER_VERSION: &str = "1.0.0";
Expand All @@ -42,13 +45,15 @@ const SDK_NAME: &str = "raindrop-rust";
/// * `events.track` — `Client::track_event` (plain, non-AI event).
/// * `events.track_ai` — `Client::track_ai`.
/// * `events.track_partial` — `Client::begin` / `Interaction::patch` /
/// `Interaction::finish`.
/// `Interaction::finish`.
/// * `identify` — `Client::identify`.
/// * `signal` — `Client::track_signal` (DEV-1201): the step's
/// event_id/name land on signals/track as
/// event_id/signal_name with signal_type defaulting
/// to "default".
/// event_id/name land on signals/track as event_id/signal_name with
/// signal_type defaulting to "default".
const CAPABILITIES: &[&str] = &[
"app_git.config",
"app_git.environment",
"app_git.contextual_auto_detection",
"events.track",
"events.track_ai",
// Factually true delivery mode: track_event/track_ai ship begin-style to
Expand Down Expand Up @@ -213,7 +218,7 @@ impl Driver {
async fn execute(&mut self, name: &str, args: &Value) -> Result<(), StepError> {
let args = clean(args);
match name {
"init" => self.step_init().map_err(StepError::from),
"init" => self.step_init(&args).map_err(StepError::from),
"track" => self.step_track(&args).await.map_err(StepError::from),
"track_ai" => self.step_track_ai(&args).await.map_err(StepError::from),
"begin" => self.step_begin(&args).await.map_err(StepError::from),
Expand All @@ -230,7 +235,7 @@ impl Driver {

// -- lifecycle -------------------------------------------------------- //

fn step_init(&mut self) -> Result<(), Failure> {
fn step_init(&mut self, args: &Map<String, Value>) -> Result<(), Failure> {
let sink_url = std::env::var("RAINDROP_SINK_URL").unwrap_or_default();
let sink_url = sink_url.trim().trim_end_matches('/').to_string();
// A missing sink must never fall through to the SDK's production
Expand Down Expand Up @@ -264,6 +269,7 @@ impl Driver {
builder = builder.project_id(project_id);
}
}
builder = configure_app_git(builder, args)?;
let client = builder
.build()
.map_err(|e| Failure(format!("init: cannot build client: {e}")))?;
Expand Down Expand Up @@ -416,6 +422,72 @@ impl Driver {
}
}

fn configure_app_git(
builder: ClientBuilder,
args: &Map<String, Value>,
) -> Result<ClientBuilder, Failure> {
let Some(value) = args.get("app_git") else {
return Ok(builder);
};
if value == &Value::Bool(false) {
return Ok(builder.disable_app_git());
}
let object = value
.as_object()
.ok_or_else(|| Failure("init: `app_git` must be false or an object".to_string()))?;
const KNOWN: &[&str] = &[
"commit_sha",
"commit_dirty",
"branch",
"source_directory",
"detect_branch",
"auto_detect",
];
let unknown: Vec<&str> = object
.keys()
.map(String::as_str)
.filter(|key| !KNOWN.contains(key))
.collect();
if !unknown.is_empty() {
return Err(Failure(format!(
"init: unknown app_git options: {}",
unknown.join(", ")
)));
}

let mut config = AppGitConfig::new();
for key in ["commit_sha", "branch", "source_directory"] {
if let Some(value) = object.get(key) {
let value = value
.as_str()
.filter(|value| !value.is_empty())
.ok_or_else(|| {
Failure(format!("init: app_git.{key} must be a non-empty string"))
})?;
config = match key {
"commit_sha" => config.commit_sha(value),
"branch" => config.branch(value),
"source_directory" => config.source_directory(value),
_ => unreachable!(),
};
}
}
for key in ["commit_dirty", "detect_branch", "auto_detect"] {
if let Some(value) = object.get(key) {
let value = value
.as_bool()
.ok_or_else(|| Failure(format!("init: app_git.{key} must be a boolean")))?;
config = match key {
"commit_dirty" => config.commit_dirty(value),
"detect_branch" => config.detect_branch(value),
"auto_detect" => config.auto_detect(value),
_ => unreachable!(),
};
}
}
Ok(builder.app_git(config))
}

async fn run_steps(raw: &str) -> ExitCode {
let steps: Vec<Value> = match serde_json::from_str(raw) {
Ok(Value::Array(steps)) => steps,
Expand All @@ -431,19 +503,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
Loading
Loading