Raindrop SDK for Swift (iOS 16+, macOS 13+, tvOS, watchOS, visionOS and Linux).
Status: beta (
0.0.7), ready for production use. Events, AI events, interactions (begin/finish/resume), signals, identify, manual tracing, offline storage and app lifecycle handling are all supported. The API may still change before0.1.0, so pin a version. Full docs: raindrop.ai/docs/sdk/swift.
// Package.swift
.package(url: "https://github.com/raindrop-ai/raindrop-swift.git", from: "0.0.7")
// target dependency:
.product(name: "Raindrop", package: "raindrop-swift")import Raindrop
let raindrop = RaindropClient(writeKey: "<write key>", projectId: "my-app")
// Returns immediately: the event is queued and sent in the background.
let eventId = raindrop.trackAi(AiEvent(
userId: "user-123",
event: "chat_message",
input: "What's the status of order A1002?",
output: "It's processing.",
model: "claude-sonnet-4-5",
convoId: "conv-456",
properties: ["surface": "chat"]
))
raindrop.trackSignal(Signal(eventId: eventId, name: "thumbs_up", type: .feedback, sentiment: .positive))
// Long-running turns: begin → updates → finish, with model and tool spans linked to the event.
let turn = raindrop.begin(BeginOptions(
userId: "user-123",
event: "agent_turn",
input: question,
convoId: "conv-456"
))
let llm = turn.startLlmSpan(
"anthropic.messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
input: question
)
llm.setOutput(draft)
llm.setUsage(TokenUsage(inputTokens: 120, outputTokens: 40))
llm.end()
let order = try await turn.withTool("lookup_order", input: ["order_id": "A1002"]) {
try await lookupOrder("A1002")
}
// Inline subagent: part of this event. Spans started inside the closure (no `parent:`)
// nest under it automatically via the task-local current span.
let summary = try await turn.inlineSubagent("summarizer", input: .string(order)) {
try await summarize(order)
}
await turn.finish(output: answer)
// Detached subagent (experimental): its own event, linked to the turn by ids (same task,
// another task, or another process).
let handoff = turn.subagent(name: "researcher")
Task.detached {
let run = raindrop.beginSubagent(handoff, input: "research")
do {
await run.finish(output: try await research())
} catch {
await run.fail(error)
}
}
raindrop.identify(userId: "user-123", traits: ["plan": "paid"])
raindrop.trackEvent(Event(userId: "user-123", event: "session_started"))
// Before exit (or when a serverless handler returns): send everything, bounded at 10 s.
await raindrop.close()flush() sends without closing. validate() checks the write key and project against
POST /v1/auth without sending data. A nil or empty write key makes the client a no-op.
How delivery works:
| Hot path | trackAi, trackEvent, identify, trackSignal append to an in-memory queue under a lock and return. No network, sleeps or serialization on your thread. |
| Delivery | A background loop sends about once a second. events/track batches are one JSON array per request (≤ 100 items / ~900 KB). |
flush / close |
Durable on return, bounded by flushDeadline (10 s). |
| Retries | 429 and 5xx: honour Retry-After (capped at 30 s) or back off 0.5 s → 8 s with jitter, up to 4 attempts per cycle, then park and retry later. Other 4xx: dropped with a warning, never retried. Any 2xx counts as delivered. |
| Identical retries | Each batch is serialized once and resent byte-for-byte, keeping event ids stable. |
| Timeouts | 2 s idle timeout per request. A timed-out batch is retried on a later cycle, not inside the same flush. |
| Limits | input / output truncated at 1,000,000 codepoints with ...[truncated by raindrop]. No caps on properties or attachments. Unicode preserved byte-for-byte. |
| Bounded memory | 10,000 queued payloads per route and 100 parked batches; the oldest are dropped with a warning. |
| Never crashes the host | Nothing throws into your code. Invalid input and delivery failures are logged (rate-limited) and dropped. |
| Interactions | begin / updates queue; each send carries the full accumulated state to events/track_partial (latest wins per event id). Updates wait until there's input or output (Raindrop drops partials without either). The begin time is kept. finish is durable on return. resumeInteraction(eventId:beganAt:) needs only the id. |
| Tracing | Span / LlmSpan / ToolSpan exported as OTLP JSON batches to /v1/traces, linked to the event, user and conversation. Raw gen_ai.usage.* only (cost is computed server-side). |
| Apps | Offline storage in Application Support (5 MB / 7 days, excluded from backup, data protection set by storageProtection); flush on background under an expiring-activity assertion; retry when the network returns; PrivacyInfo.xcprivacy included. |
Hooks: beforeSend (edit or drop each payload), redactPii, appGit, localWorkshopURL
(Raindrop Workshop mirror), transport (custom HTTP), logHandler.
conformance/ holds the driver for Raindrop's cross-SDK conformance suite (a separate
package, so it never shows up in your dependency graph). CI runs it against a local
fault-injecting server and against real ingest with Query API readback. Known gaps are
ratcheted in conformance/failures.txt.
examples/RaindropTestApp runs the SDK inside a SwiftUI app on the iOS simulator: background
flush, kill and relaunch with ingest down, resuming an interaction after relaunch, main-actor
scoped spans, on-device PII redaction, hot-path cost, offline storage and the privacy
manifest. See its README; CI runs it on every change.
- Header-based context propagation for detached subagents (pass the handoff explicitly).
- Contextual Git detection on Vercel (set
appGitor the environment variables instead).
swift build
swift testWith Command Line Tools only (no Xcode), swift test can fail intermittently with
"plugin for module 'TestingMacros' not found". That's a toolchain issue: run
swift build --build-tests (retry if needed), then swift test --skip-build.
MIT