> ## Documentation Index
> Fetch the complete documentation index at: https://raindrop.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Rust (Beta)

> The Raindrop SDK allows you to track user events and AI interactions in your app. This documentation provides a brief overview of how to use the Rust SDK.

<Info>
  **Beta.** The Rust SDK is at `0.0.8`. 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 tag in your `Cargo.toml`.
</Info>

## Installation

The Rust SDK is hosted on GitHub (not on crates.io). Add it to your `Cargo.toml`:

```toml theme={null}
[dependencies]
raindrop-ai = { git = "https://github.com/raindrop-ai/raindrop-rust", tag = "v0.0.8" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
serde_json = "1"
```

Smallest possible program — drops in as `src/main.rs` and runs:

```rust theme={null}
use raindrop::Client;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Empty / missing key → SDK becomes a no-op (zero HTTP), so you can integrate
    // the SDK code first and add the key later without crashing your app.
    let client = Client::builder()
        .write_key(std::env::var("RAINDROP_WRITE_KEY").unwrap_or_default())
        .build()?;

    // ... use client ...

    client.close().await?;
    Ok(())
}
```

Source code and releases live in [raindrop-ai/raindrop-rust](https://github.com/raindrop-ai/raindrop-rust).

<Info>
  The Rust SDK requires Rust **1.88+** (MSRV). It is `async`-first and uses `tokio`. Most fallible methods return `Result<_, Error>` — `track_ai`, `track_event`, `identify`, `track_signal`, the `Interaction` mutators (`set_input`, `set_property`, `set_properties`, `add_attachments`, `patch`, `finish`), and `Client::flush` / `Client::close`. Propagate errors with `?` as you would for any fallible call. The two constructors — `Client::begin(...).await` and `Client::resume_interaction(...)` — are **infallible**: they always return an `Interaction` (a no-op handle when the client is disabled), so don't put a `?` on those.
</Info>

***

## Quick Start: Interaction API

The Interaction API uses a simple three-step pattern:

1. **`begin()`** – Create an interaction and log the initial user input
2. **Update** – Optionally call `set_property`, `set_properties`, `set_input`, or `add_attachments`
3. **`finish()`** – Record the AI's final output and close the interaction

### Example: Chat Completion

```rust theme={null}
use std::collections::BTreeMap;

use raindrop::{BeginOptions, Client, FinishOptions};
use serde_json::json;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::builder()
        .write_key(std::env::var("RAINDROP_WRITE_KEY").unwrap_or_default())
        .build()?;

    let mut props = BTreeMap::new();
    props.insert("system_prompt".into(), json!("you are a helpful..."));

    // 1. Start the interaction
    let interaction = client
        .begin(BeginOptions {
            event_id: "evt_123".into(),
            user_id: "user-123".into(),
            event: "chat_message".into(),
            input: "Can you suggest a calm Saturday morning in San Francisco?".into(),
            model: "gpt-4o".into(),
            convo_id: "conv-123".into(),
            properties: props,
            ..Default::default()
        })
        .await;

    // 2. Make the LLM call
    let output = call_llm().await;

    // 3. Finish the interaction
    interaction
        .finish(FinishOptions {
            output,
            ..Default::default()
        })
        .await?;

    client.close().await?;
    Ok(())
}

async fn call_llm() -> String {
    // Your LLM call here.
    String::new()
}
```

### Updating an Interaction

Update an interaction at any point using `set_property`, `set_properties`, `set_input`, or `add_attachments`:

```rust theme={null}
use raindrop::Attachment;
use serde_json::json;

interaction.set_property("stage", "retrieving").await?;
interaction
    .set_properties(BTreeMap::from([
        ("surface".into(), json!("chat")),
        ("region".into(), json!("us-west")),
    ]))
    .await?;
interaction.set_input("Can you make it a little more local?").await?;
interaction
    .add_attachments(vec![Attachment {
        kind: "text".into(),
        role: "input".into(),
        name: "preferences".into(),
        value: "Prefers coffee, a quiet walk, and no museum stops.".into(),
        ..Default::default()
    }])
    .await?;
```

### Resuming an Interaction

If you no longer have the interaction object returned from `begin()`, resume it with `resume_interaction()`:

```rust theme={null}
let interaction = client.resume_interaction("evt_123");
interaction.set_property("stage", "follow-up").await?;
interaction
    .finish(FinishOptions {
        output: "Here is a shorter version of that itinerary.".into(),
        ..Default::default()
    })
    .await?;
```

<Warning>
  `resume_interaction()` recovers an active in-memory interaction created by `begin()` in the same
  process. It is not a cross-process restore mechanism. If the event ID is not found in memory, a
  new interaction handle is created for that ID.
</Warning>

***

## Single-Shot Tracking (`track_ai`)

For simple request-response interactions, you can use `track_ai()` directly:

```rust theme={null}
use raindrop::AiEvent;

client
    .track_ai(AiEvent {
        user_id: "user-123".into(),
        event: "chat_message".into(),
        input: "Who won the 2023 AFL Grand Final?".into(),
        output: "Collingwood by four points!".into(),
        model: "gpt-4o".into(),
        convo_id: "conv-123".into(),
        properties: BTreeMap::from([
            ("ai.usage.prompt_tokens".into(), json!(10)),
            ("ai.usage.completion_tokens".into(), json!(5)),
        ]),
        ..Default::default()
    })
    .await?;
```

> We recommend using `begin()` → `finish()` for new code to take advantage of partial-event buffering and tracing.

Use `track_event()` for non-AI events:

```rust theme={null}
use raindrop::Event;

client
    .track_event(Event {
        user_id: "user-123".into(),
        event: "session_started".into(),
        properties: BTreeMap::from([("entrypoint".into(), json!("dashboard"))]),
        ..Default::default()
    })
    .await?;
```

***

## Tracking Signals (Feedback)

Signals capture quality ratings on AI events. Use `track_signal()` with the same event ID from `begin()` or `track_ai()`:

| Field           | Type                  | Description                                                                                               |
| --------------- | --------------------- | --------------------------------------------------------------------------------------------------------- |
| `event_id`      | `String`              | The ID of the AI event you're evaluating                                                                  |
| `name`          | `String`              | Signal name (e.g. `"thumbs_up"`, `"thumbs_down"`)                                                         |
| `kind`          | `String`              | One of `SignalKind::{DEFAULT, STANDARD, FEEDBACK, EDIT, AGENT, AGENT_INTERNAL}`. Defaults to `"default"`. |
| `sentiment`     | `String`              | `"POSITIVE"` or `"NEGATIVE"`                                                                              |
| `comment`       | `String`              | Merged into `properties.comment` for `feedback` signals                                                   |
| `after`         | `String`              | Merged into `properties.after` for `edit` signals                                                         |
| `attachment_id` | `String`              | Optional attachment ID to associate the signal with                                                       |
| `properties`    | `BTreeMap<String, _>` | Additional metadata                                                                                       |

```rust theme={null}
use raindrop::{Signal, SignalKind};

client
    .track_signal(Signal {
        event_id: "evt_123".into(),
        name: "thumbs_down".into(),
        kind: SignalKind::FEEDBACK.into(),
        sentiment: "NEGATIVE".into(),
        comment: "Answer was off-topic".into(),
        ..Default::default()
    })
    .await?;
```

***

## Identifying Users

```rust theme={null}
use raindrop::User;

client
    .identify(User {
        user_id: "user-123".into(),
        traits: BTreeMap::from([
            ("name".into(), json!("Jane")),
            ("email".into(), json!("jane@example.com")),
            ("plan".into(), json!("paid")), // we recommend "free", "paid", "trial"
        ]),
    })
    .await?;
```

***

## Attachments

Attachments let you include additional context — documents, images, code, or embedded content — with your events. They work with both `begin()` interactions and `track_ai()` calls.

| Field           | Type     | Description                                                                                                                                |
| --------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `kind`          | `String` | `"code"`, `"text"`, `"image"`, or `"iframe"` (serialized as `type` on the wire)                                                            |
| `role`          | `String` | `"input"` or `"output"`                                                                                                                    |
| `name`          | `String` | Optional display name                                                                                                                      |
| `value`         | `String` | Content or URL                                                                                                                             |
| `language`      | `String` | Programming language (only meaningful for `"code"` attachments)                                                                            |
| `attachment_id` | `String` | Optional UUID. Backend auto-assigns one if empty. Set explicitly to round-trip with [`Signal::attachment_id`](#tracking-signals-feedback). |

```rust theme={null}
use raindrop::Attachment;

interaction
    .add_attachments(vec![
        Attachment {
            kind: "code".into(),
            role: "input".into(),
            language: "rust".into(),
            name: "example.rs".into(),
            value: "println!(\"hello\");".into(),
            ..Default::default()
        },
        Attachment {
            kind: "text".into(),
            role: "input".into(),
            name: "Additional Info".into(),
            value: "Some extra text".into(),
            ..Default::default()
        },
        Attachment {
            kind: "image".into(),
            role: "output".into(),
            value: "https://example.com/image.png".into(),
            ..Default::default()
        },
        Attachment {
            kind: "iframe".into(),
            role: "output".into(),
            value: "https://example.com/embed".into(),
            ..Default::default()
        },
    ])
    .await?;
```

<Info>
  The dashboard's attachment viewer renders `text`, `image`, and `iframe` attachments. `code`
  attachments survive ingestion and are searchable, but are not currently displayed in the visual
  attachments tab.
</Info>

***

## Configuration

```rust theme={null}
use std::time::Duration;

let client = Client::builder()
    .write_key(std::env::var("RAINDROP_WRITE_KEY").unwrap_or_default())
    .debug(std::env::var("ENV").as_deref() != Ok("production"))
    .partial_flush_interval(Duration::from_secs(1))
    .trace_flush_interval(Duration::from_secs(1))
    .build()?;
```

| Builder method                      | Description                                                                                          | Default                       |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------- | ----------------------------- |
| `.write_key(&str)`                  | Your Raindrop API key. Empty/missing key → SDK becomes a no-op.                                      | —                             |
| `.project_id(&str)`                 | Send events to a specific [project](/docs/platform/projects); omit for the default **Production** project | `"default"`                   |
| `.endpoint(&str)`                   | Override the API endpoint                                                                            | `https://api.raindrop.ai/v1/` |
| `.debug(bool)`                      | Verbose debug logging via `tracing`                                                                  | `false`                       |
| `.partial_flush_interval(Duration)` | Periodic event flush. `Duration::ZERO` disables periodic flush                                       | `1s`                          |
| `.trace_flush_interval(Duration)`   | Periodic span flush. `Duration::ZERO` disables periodic flush                                        | `1s`                          |
| `.trace_max_batch_size(usize)`      | Max spans per trace export request                                                                   | `50`                          |
| `.trace_max_queue_size(usize)`      | Max spans buffered before back-pressuring                                                            | `5000`                        |
| `.max_attempts(u32)`                | HTTP retries. `1` disables retries.                                                                  | `3`                           |
| `.base_delay(Duration)`             | Backoff base (exponential, ±20% jitter)                                                              | `1s`                          |
| `.jitter_fraction(f64)`             | Backoff jitter fraction (0.0–1.0)                                                                    | `0.2`                         |
| `.service_name(&str)`               | OTLP `resource.service.name`                                                                         | `"raindrop.rust-sdk"`         |
| `.library_name(&str)`               | `$context.library.name` reported with each event                                                     | `"raindrop-rust"`             |
| `.library_version(&str)`            | `$context.library.version`                                                                           | crate version                 |
| `.http_client(reqwest::Client)`     | Inject a custom `reqwest::Client`                                                                    | new client w/ 10s timeout     |
| `.local_workshop_url(&str)`         | Mirror cloud-bound posts to a local Workshop daemon                                                  | auto-detected localhost       |
| `.disable_local_workshop()`         | Disable env/probe-based local Workshop mirroring                                                     | —                             |

Call `client.close().await?` before your process exits to flush buffered events and spans. If `write_key` is empty and no local Workshop is configured, the client becomes a no-op (zero HTTP calls) instead of failing.

Local Workshop mirroring is enabled when `RAINDROP_LOCAL_DEBUGGER` is set to a URL, when `RAINDROP_WORKSHOP` is a truthy value or URL, or when the SDK can connect to the default Workshop daemon at `http://localhost:5899/v1/`.

***

## Projects

Pass `.project_id(...)` on the builder to scope every event from a client to a specific [project](/docs/platform/projects). Under the hood this sets the `X-Raindrop-Project-Id` header on each request.

```rust theme={null}
let client = Client::builder()
    .write_key(std::env::var("RAINDROP_WRITE_KEY").unwrap_or_default())
    .project_id("support-prod")
    .build()?;
```

A project slug is up to 63 lowercase letters, digits, and hyphens, and it must start and end with a letter or digit (`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`).

Omitting `.project_id(...)` (or passing `"default"`) sends to the default **Production** project, which is the existing behavior, so single-project orgs need nothing here. See [Projects](/docs/platform/projects) for isolation, archival, and the full behavior table.

***

## Tracing

Tracing captures detailed execution information from your AI pipelines — multi-model interactions, chained prompts, and tool calls. This helps you:

* Visualize the full execution flow of your AI application
* Debug and optimize prompt chains
* Understand the intermediate steps that led to a response

### Manual Spans

Use manual spans for workflow, task, retrieval, or any other work that is not specifically an LLM generation or tool call. Build span trees by passing a parent into `SpanOptions::parent`:

```rust theme={null}
use raindrop::{Attribute, SpanOptions};

let parent = client.start_span(SpanOptions {
    name: "agent.run".into(),
    event_id: "evt_123".into(),
    operation_id: "ai.workflow".into(),
    ..Default::default()
});

let retrieval = client.start_span(SpanOptions {
    name: "rag.retrieve".into(),
    event_id: "evt_123".into(),
    operation_id: "ai.task".into(),
    parent: Some(parent.clone()),
    attributes: vec![Attribute::string("retrieval.index", "docs")],
    ..Default::default()
});
retrieval.set_attributes([
    Attribute::int("retrieval.results", 4),
]);
retrieval.end();

parent.end();
```

Spans started from an `Interaction` automatically inherit its `user_id`, `convo_id`, and `event` as `traceloop.association.properties.*` attributes, so the dashboard groups them under the same user, conversation, and event:

```rust theme={null}
let interaction = client
    .begin(BeginOptions {
        user_id: "user-123".into(),
        convo_id: "conv-456".into(),
        event: "agent_run".into(),
        ..Default::default()
    })
    .await;

let span = interaction.start_span(SpanOptions {
    name: "rag.retrieve".into(),
    ..Default::default()
});
// ... do work ...
span.end();
```

<Info>
  Plain `client.start_span(...)` calls only need `name` + `event_id`. The SDK automatically emits
  `traceloop.association.properties.event_id` so the span survives the backend's ingestion filter.
  For non-event-bound spans, set `operation_id` (e.g. `"ai.workflow"`) or pass `properties` so the
  span has at least one of `ai.operationId`, `traceloop.span.kind`, `traceloop.workflow.name`,
  `traceloop.association.properties.*`, or `gen_ai.*` — otherwise it will be dropped server-side.
</Info>

### LLM Spans

Use `start_llm_span` for model calls. `LlmSpan` emits the attributes that Raindrop's backend and frontend understand for prompt messages, response text, model/provider, and token usage.

```rust theme={null}
use raindrop::{LlmMessage, LlmOptions, SpanOptions};

let agent = interaction.start_span(SpanOptions {
    name: "agent.run".into(),
    operation_id: "ai.workflow".into(),
    ..Default::default()
});

let llm = interaction.start_llm_span(
    "openai.responses",
    LlmOptions {
        parent: Some(agent.clone()),
        provider: "openai".into(),
        model: "gpt-4o".into(),
        messages: vec![
            LlmMessage::system("You are concise."),
            LlmMessage::user("What is the weather in San Francisco?"),
        ],
        ..Default::default()
    },
);

let output = call_llm().await;

llm.set_output(output);
llm.set_token_usage("gpt-4o", /* input */ 47, /* output */ 11);
llm.end();
agent.end();
```

`messages` is the chat-shaped prompt representation. Use it when your provider call takes a role/content array instead of a single text prompt:

```rust theme={null}
let llm = interaction.start_llm_span(
    "openai.chat",
    LlmOptions {
        model: "gpt-4o".into(),
        provider: "openai".into(),
        messages: vec![
            LlmMessage::system("You answer in one sentence."),
            LlmMessage::user("Summarize this support ticket."),
            LlmMessage::assistant("The user is asking about billing."),
            LlmMessage::user("Add the next action."),
        ],
        ..Default::default()
    },
);

llm.set_output("Ask the user for the invoice number.");
llm.end();
```

`LlmMessage::system`, `LlmMessage::user`, and `LlmMessage::assistant` are convenience constructors; use `LlmMessage::new(role, content)` for provider-specific roles. If both `input` and `messages` are set in `LlmOptions`, `messages` wins. The backend uses the last user message as the span's `input_payload`, while the frontend renderer can show the full message array.

If you only know the messages after creating the span, use `set_messages`. It replaces any previous `input` or prompt-message attributes:

```rust theme={null}
let llm = interaction.start_llm_span(
    "anthropic.messages",
    LlmOptions {
        provider: "anthropic".into(),
        model: "claude-sonnet-4-5".into(),
        ..Default::default()
    },
);

llm.set_messages([
    LlmMessage::system("You answer using the provided tool result."),
    LlmMessage::user("What is the weather in San Francisco?"),
    LlmMessage::assistant("I will call get_weather."),
    LlmMessage::user("The tool returned 67°F and windy."),
]);
llm.set_output("It is 67°F and windy in San Francisco.");
llm.end();
```

You can also seed the LLM span from `LlmOptions`:

```rust theme={null}
let llm = interaction.start_llm_span(
    "anthropic.messages",
    LlmOptions {
        provider: "anthropic".into(),
        model: "claude-sonnet-4-5".into(),
        input: Some("Summarize this ticket.".into()),
        output: Some("The user needs help with billing.".into()),
        input_tokens: 120,
        output_tokens: 24,
        ..Default::default()
    },
);
llm.end();
```

`LlmSpan` exposes `set_model`, `set_provider`, `set_input`, `set_messages`, `set_output`, `set_io`, `set_token_usage`, `set_error`, `end`, and `end_at`.

#### Closure-style helpers

If you prefer scoped instrumentation, `with_span` runs a closure inside a span and automatically marks the span as failed on `Err`:

```rust theme={null}
interaction
    .with_span::<_, _, _, std::io::Error>(
        SpanOptions { name: "summarize".into(), ..Default::default() },
        |span| async move {
            span.set_attributes([Attribute::string("phase", "draft")]);
            Ok::<_, std::io::Error>(())
        },
    )
    .await?;
```

### Tool Spans

Tool spans use the dedicated wire format (`traceloop.span.kind=tool`) so they surface in the dashboard's `event.toolCalls[]` array.

```rust theme={null}
use raindrop::ToolOptions;
use serde_json::json;

let tool = interaction.start_tool_span("weather_lookup", ToolOptions {
    input: Some(json!({ "location": "San Francisco" })),
    ..Default::default()
});
tool.set_output(&json!({ "forecast": "sunny" }));
tool.end();
```

For retroactive logging of an already-completed call:

```rust theme={null}
use raindrop::TrackToolOptions;
use std::time::Duration;

interaction.track_tool(TrackToolOptions {
    name: "web_search".into(),
    input: Some(json!({ "query": "weather in NYC" })),
    output: Some(json!({ "results": ["Sunny, 72°F"] })),
    duration: Some(Duration::from_millis(150)),
    properties: BTreeMap::from([("engine".into(), json!("google"))]),
    ..Default::default()
});

// Failed tool calls (status=ERROR on the dashboard, with the message in output_payload)
interaction.track_tool(TrackToolOptions {
    name: "database_query".into(),
    input: Some(json!({ "query": "SELECT * FROM users" })),
    duration: Some(Duration::from_millis(50)),
    error: Some("connection timeout".into()),
    ..Default::default()
});
```

| Field        | Type                     | Description                                          |
| ------------ | ------------------------ | ---------------------------------------------------- |
| `name`       | `String`                 | Tool name                                            |
| `input`      | `Option<Value>`          | JSON input                                           |
| `output`     | `Option<Value>`          | JSON output                                          |
| `duration`   | `Option<Duration>`       | Total duration                                       |
| `start_time` | `Option<OffsetDateTime>` | When the tool started (defaults to `now - duration`) |
| `end_time`   | `Option<OffsetDateTime>` | When the tool ended                                  |
| `error`      | `Option<String>`         | Error message; sets `status=ERROR`                   |
| `parent`     | `Option<Span>`           | Parent span for nesting                              |
| `properties` | `BTreeMap<String, _>`    | Additional metadata                                  |

For functional wrapping, the SDK exposes `with_tool` and `with_tool_async` free helpers that run a closure inside a tool span and JSON-serialize the result onto `traceloop.entity.output`:

```rust theme={null}
let result = raindrop::with_tool::<_, _, std::io::Error>(
    &interaction,
    "park_check",
    ToolOptions {
        input: Some(json!({ "location": "Dolores Park" })),
        ..Default::default()
    },
    || Ok(json!({ "recommendation": "yes" })),
)?;
```

### Standalone Tracer

Use `Client::tracer()` for batch jobs or non-conversation work where you still want spans, LLM spans, and tool traces:

```rust theme={null}
let tracer = client.tracer(BTreeMap::from([("job_id".into(), json!("batch-123"))]));

let span = tracer.start_span(SpanOptions { name: "embed".into(), ..Default::default() });
span.end();

let llm = tracer.start_llm_span(
    "batch.summarize",
    LlmOptions {
        model: "gpt-4o-mini".into(),
        input: Some("Summarize the batch.".into()),
        ..Default::default()
    },
);
llm.set_output("Batch summary complete.");
llm.end();

tracer.track_tool(TrackToolOptions {
    name: "vector_lookup".into(),
    input: Some(json!({ "query": "mission coffee" })),
    output: Some(json!({ "winner": "Ritual Coffee Roasters" })),
    properties: BTreeMap::from([("step".into(), json!("retrieve"))]),
    ..Default::default()
});
```

### Span Attributes

The SDK provides typed helpers for OTLP-compatible attributes:

```rust theme={null}
span.set_attributes([
    Attribute::string("ai.model.id", "gpt-4o"),
    Attribute::int("ai.usage.prompt_tokens", 150),
    Attribute::float("ai.latency_seconds", 1.23),
    Attribute::bool("ai.stream", true),
    Attribute::string_array("ai.tools", vec!["search".into(), "calculator".into()]),
]);
```

***

## Known Limitations

* **No automatic LLM-client instrumentation.** Unlike the Python and TypeScript SDKs, the Rust SDK does not auto-hook into LLM frameworks. Create spans manually via `start_span`, `start_llm_span`, `start_tool_span`, `with_span`, `with_tool`, or `track_tool`.
* **No PII redaction.** The Python SDK exposes `set_redact_pii` and the TypeScript SDK has `redactPii`. The Rust SDK does not yet implement client-side redaction. Redact at the call site or upstream of `track_ai` / `track_event` if needed.
* **Oversized payload guard.** Payloads larger than 1 MiB after JSON serialization are dropped client-side (matching the JS / Python SDKs) to avoid 413s on the gateway. The drop is logged via `tracing::warn!` so production callers can detect it.

***

That's it! You're ready to explore your events in the Raindrop dashboard. Ping us on Slack or [email us](mailto:founders@raindrop.ai) if you get stuck!
