Quick Start
Prerequisites
- Docker (recommended), or
- Rust toolchain (1.75+) for building from source
Run with Docker
docker run -d --name bloop \
-p 5332:5332 \
-v bloop_data:/data \
-e BLOOP__AUTH__HMAC_SECRET=your-secret-here \
ghcr.io/jaikoo/bloop:latest
Build from Source
git clone https://github.com/jaikoo/bloop.git
cd bloop
cargo build --release
./target/release/bloop --config config.toml
Send Your First Error
# Compute HMAC signature
BODY='{"timestamp":1700000000,"source":"api","environment":"production","release":"1.0.0","error_type":"RuntimeError","message":"Something went wrong"}'
SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "your-secret-here" | awk '{print $2}')
# Send to bloop
curl -X POST http://localhost:5332/v1/ingest \
-H "Content-Type: application/json" \
-H "X-Signature: $SIG" \
-d "$BODY"
Open http://localhost:5332 in your browser, register a passkey, and view your error on the dashboard.
Configuration
Bloop reads from config.toml in the working directory. Every value can be overridden via environment variables using double-underscore separators: BLOOP__SECTION__KEY.
Full Reference
# ── Server ──
[server]
host = "0.0.0.0"
port = 5332
# ── Database ──
[database]
path = "bloop.db" # SQLite file path
pool_size = 4 # deadpool-sqlite connections
# ── Ingestion ──
[ingest]
max_payload_bytes = 32768 # Max single request body
max_stack_bytes = 8192 # Max stack trace length
max_metadata_bytes = 4096 # Max metadata JSON size
max_message_bytes = 2048 # Max error message length
max_batch_size = 50 # Max events per batch request
channel_capacity = 8192 # MPSC channel buffer size
# ── Pipeline ──
[pipeline]
flush_interval_secs = 2 # Flush timer
flush_batch_size = 500 # Events per batch write
sample_reservoir_size = 5 # Sample occurrences kept per fingerprint
# ── Retention ──
[retention]
raw_events_days = 7 # Raw event TTL
prune_interval_secs = 3600 # How often to run cleanup
# ── Auth ──
[auth]
hmac_secret = "change-me-in-production"
rp_id = "localhost" # WebAuthn relying party ID
rp_origin = "http://localhost:5332" # WebAuthn origin
session_ttl_secs = 604800 # Session lifetime (7 days)
# ── Rate Limiting ──
[rate_limit]
per_second = 100
burst_size = 200
# ── Alerting ──
[alerting]
cooldown_secs = 900 # Min seconds between re-fires
Environment Variables
| Variable | Overrides | Example |
|---|---|---|
BLOOP__SERVER__PORT | server.port | 8080 |
BLOOP__DATABASE__PATH | database.path | /data/bloop.db |
BLOOP__AUTH__HMAC_SECRET | auth.hmac_secret | my-production-secret |
BLOOP__AUTH__RP_ID | auth.rp_id | errors.myapp.com |
BLOOP__AUTH__RP_ORIGIN | auth.rp_origin | https://errors.myapp.com |
BLOOP_SLACK_WEBHOOK_URL | (direct) | Slack incoming webhook URL |
BLOOP_WEBHOOK_URL | (direct) | Generic webhook URL |
Note: BLOOP_SLACK_WEBHOOK_URL and BLOOP_WEBHOOK_URL are read directly from the environment (not through the config system), so they use single underscores.
Architecture
Bloop is a single async Rust process. All components run as Tokio tasks within one binary.
Storage Layers
| Layer | Retention | Purpose |
|---|---|---|
| Raw events | 7 days (configurable) | Full event payloads for debugging |
| Aggregates | Indefinite | Error counts, first/last seen, status |
| Sample reservoir | Indefinite | 5 sample occurrences per fingerprint |
Fingerprinting
Every ingested error gets a deterministic fingerprint. The algorithm:
- Normalize the message: strip UUIDs → strip IPs → strip all numbers → lowercase
- Extract top stack frame: skip framework frames (UIKitCore, node_modules, etc.), strip line numbers
- Hash:
xxhash3(source + error_type + route + normalized_message + top_frame)
This means "Connection refused at 10.0.0.1:5432" and "Connection refused at 192.168.1.2:3306" produce the same fingerprint. You can also supply your own fingerprint field to override.
Backpressure
The ingestion handler pushes events into a bounded MPSC channel (default capacity: 8192). If the channel is full:
- The event is dropped
- The client still receives
200 OK - A warning is logged server-side
Bloop never returns 429 to your clients. Mobile apps and APIs should not retry errors — if the buffer is full, the event wasn't critical enough to block on.
SDK: TypeScript / Node.js
IngestEvent Payload
interface IngestEvent {
timestamp: number; // Unix epoch seconds
source: "ios" | "android" | "api";
environment: string; // "production", "staging", etc.
release: string; // Semver or build ID
error_type: string; // Exception class name
message: string; // Error message
app_version?: string; // Display version
build_number?: string; // Build number
route_or_procedure?: string; // API route or RPC method
screen?: string; // Mobile screen name
stack?: string; // Stack trace
http_status?: number; // HTTP status code
request_id?: string; // Correlation ID
user_id_hash?: string; // Hashed user identifier
device_id_hash?: string; // Hashed device identifier
fingerprint?: string; // Custom fingerprint (overrides auto)
metadata?: Record<string, unknown>; // Arbitrary extra data
}
Send an Error
import { createHmac } from "crypto";
const BLOOP_URL = "https://errors.myapp.com";
const HMAC_SECRET = process.env.BLOOP_HMAC_SECRET!;
async function sendError(event: IngestEvent): Promise<void> {
const body = JSON.stringify(event);
const signature = createHmac("sha256", HMAC_SECRET)
.update(body)
.digest("hex");
await fetch(`${BLOOP_URL}/v1/ingest`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Signature": signature,
},
body,
});
}
// Usage
sendError({
timestamp: Math.floor(Date.now() / 1000),
source: "api",
environment: "production",
release: "1.2.0",
error_type: "ValidationError",
message: "Invalid email format",
route_or_procedure: "POST /api/users",
http_status: 422,
});
For batch sending, POST an array of events to /v1/ingest/batch with the same HMAC signature computed over the full JSON array body. Max 50 events per batch.
SDK: Swift (iOS)
import Foundation
import CommonCrypto
struct BloopClient {
let url: URL
let secret: String
func send(event: [String: Any]) async throws {
let body = try JSONSerialization.data(withJSONObject: event)
let signature = hmacSHA256(data: body, key: secret)
var request = URLRequest(url: url.appendingPathComponent("/v1/ingest"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(signature, forHTTPHeaderField: "X-Signature")
request.httpBody = body
let (_, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse,
http.statusCode == 200 else {
return // Fire and forget — don't crash the app
}
}
private func hmacSHA256(data: Data, key: String) -> String {
let keyData = key.data(using: .utf8)!
var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
keyData.withUnsafeBytes { keyBytes in
data.withUnsafeBytes { dataBytes in
CCHmac(CCHmacAlgorithm(kCCHmacAlgSHA256),
keyBytes.baseAddress, keyData.count,
dataBytes.baseAddress, data.count,
&digest)
}
}
return digest.map { String(format: "%02x", $0) }.joined()
}
}
// Usage
let client = BloopClient(
url: URL(string: "https://errors.myapp.com")!,
secret: "your-hmac-secret"
)
try await client.send(event: [
"timestamp": Int(Date().timeIntervalSince1970),
"source": "ios",
"environment": "production",
"release": "2.1.0",
"error_type": "NetworkError",
"message": "Request timed out",
"screen": "HomeViewController",
])
SDK: Kotlin (Android)
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
class BloopClient(
private val baseUrl: String,
private val secret: String,
) {
private val client = OkHttpClient()
private val json = "application/json".toMediaType()
fun send(event: JSONObject) {
val body = event.toString()
val signature = hmacSha256(body, secret)
val request = Request.Builder()
.url("$baseUrl/v1/ingest")
.post(body.toRequestBody(json))
.addHeader("X-Signature", signature)
.build()
// Fire and forget on background thread
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {}
override fun onResponse(call: Call, response: Response) {
response.close()
}
})
}
private fun hmacSha256(data: String, key: String): String {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(key.toByteArray(), "HmacSHA256"))
return mac.doFinal(data.toByteArray())
.joinToString("") { "%02x".format(it) }
}
}
// Usage
val bloop = BloopClient("https://errors.myapp.com", "your-hmac-secret")
bloop.send(JSONObject().apply {
put("timestamp", System.currentTimeMillis() / 1000)
put("source", "android")
put("environment", "production")
put("release", "3.0.1")
put("error_type", "IllegalStateException")
put("message", "Fragment not attached to activity")
put("screen", "ProfileFragment")
})
SDK: Python
import hashlib, hmac, json, time, traceback
import requests
BLOOP_URL = "https://errors.myapp.com"
HMAC_SECRET = "your-hmac-secret"
def send_error(error_type: str, message: str, **kwargs):
event = {
"timestamp": int(time.time()),
"source": "api",
"environment": "production",
"release": "1.0.0",
"error_type": error_type,
"message": message,
**kwargs,
}
body = json.dumps(event, separators=(",", ":"))
signature = hmac.new(
HMAC_SECRET.encode(),
body.encode(),
hashlib.sha256,
).hexdigest()
try:
requests.post(
f"{BLOOP_URL}/v1/ingest",
data=body,
headers={
"Content-Type": "application/json",
"X-Signature": signature,
},
timeout=5,
)
except Exception:
pass # Don't let error reporting crash the app
# Usage
try:
risky_operation()
except Exception as e:
send_error(
error_type=type(e).__name__,
message=str(e),
stack=traceback.format_exc(),
route_or_procedure="POST /api/process",
)
SDK: Ruby
require "net/http"
require "json"
require "openssl"
module Bloop
BLOOP_URL = URI("https://errors.myapp.com")
HMAC_SECRET = ENV["BLOOP_HMAC_SECRET"]
def self.send_error(error_type:, message:, **opts)
event = {
timestamp: Time.now.to_i,
source: "api",
environment: ENV["RACK_ENV"] || "production",
release: ENV["APP_VERSION"] || "0.0.0",
error_type: error_type,
message: message,
}.merge(opts)
body = event.to_json
signature = OpenSSL::HMAC.hexdigest("SHA256", HMAC_SECRET, body)
uri = URI.join(BLOOP_URL, "/v1/ingest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["X-Signature"] = signature
req.body = body
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
http.request(req)
end
rescue => e
# Silently fail — don't disrupt the app
nil
end
end
# Usage
begin
risky_operation
rescue => e
Bloop.send_error(
error_type: e.class.name,
message: e.message,
stack: e.backtrace&.join("\n"),
route_or_procedure: "POST /api/orders",
)
end
API Reference
Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /health |
None | Health check (DB status, buffer usage) |
| POST | /v1/ingest |
HMAC | Ingest a single error event |
| POST | /v1/ingest/batch |
HMAC | Ingest up to 50 events |
| GET | /v1/errors |
Session | List aggregated errors |
| GET | /v1/errors/{fingerprint} |
Session | Get error detail |
| GET | /v1/errors/{fingerprint}/occurrences |
Session | List sample occurrences |
| POST | /v1/errors/{fingerprint}/resolve |
Session | Mark error as resolved |
| POST | /v1/errors/{fingerprint}/ignore |
Session | Mark error as ignored |
| GET | /v1/releases/{release}/errors |
Session | Errors for a specific release |
| GET | /v1/stats |
Session | Overview stats (totals, top routes) |
| GET | /v1/alerts |
Session | List alert rules |
| POST | /v1/alerts |
Session | Create alert rule |
| DELETE | /v1/alerts/{id} |
Session | Delete alert rule |
IngestEvent Schema
| Field | Type | Required | Description |
|---|---|---|---|
timestamp | integer | Yes | Unix epoch seconds |
source | string | Yes | "ios", "android", or "api" |
environment | string | Yes | Deployment environment |
release | string | Yes | Release version or build ID |
error_type | string | Yes | Exception class or error category |
message | string | Yes | Error message (max 2048 bytes) |
app_version | string | No | Display version string |
build_number | string | No | Build number |
route_or_procedure | string | No | API route or RPC method |
screen | string | No | Mobile screen / view name |
stack | string | No | Stack trace (max 8192 bytes) |
http_status | integer | No | HTTP status code |
request_id | string | No | Correlation/trace ID |
user_id_hash | string | No | Hashed user identifier |
device_id_hash | string | No | Hashed device identifier |
fingerprint | string | No | Custom fingerprint (skips auto-generation) |
metadata | object | No | Arbitrary JSON (max 4096 bytes) |
Query Parameters for /v1/errors
| Param | Type | Description |
|---|---|---|
release | string | Filter by release version |
environment | string | Filter by environment |
source | string | Filter by source (ios, android, api) |
route | string | Filter by route/procedure |
status | string | Filter by status (active, resolved, ignored) |
since | integer | Unix timestamp lower bound |
until | integer | Unix timestamp upper bound |
sort | string | Sort field |
limit | integer | Results per page (default: 50, max: 200) |
offset | integer | Pagination offset |
Alerting
Bloop supports alert rules that fire webhooks when conditions are met.
Alert Rule Types
| Type | Fires when | Config fields |
|---|---|---|
new_issue |
A new fingerprint is seen for the first time | environment (optional filter) |
threshold |
Error count exceeds N in a time window | fingerprint, route, threshold, window_secs |
spike |
Error rate spikes vs rolling baseline | multiplier, baseline_window_secs, compare_window_secs |
Create a Rule
curl -X POST http://localhost:5332/v1/alerts \
-H "Content-Type: application/json" \
-H "Cookie: session=YOUR_SESSION_TOKEN" \
-d '{
"name": "New production issues",
"config": {
"type": "new_issue",
"environment": "production"
}
}'
Webhook Payload
When an alert fires, Bloop sends a POST to the configured webhook URLs with:
{
"alert_rule": "New production issues",
"fingerprint": "a1b2c3d4e5f6",
"error_type": "RuntimeError",
"message": "Something went wrong",
"environment": "production",
"source": "api",
"timestamp": 1700000000
}
Slack Integration
Set BLOOP_SLACK_WEBHOOK_URL to your Slack incoming webhook URL. Bloop formats the payload as a Slack message automatically.
Cooldown
After an alert fires, it enters a cooldown period (default: 900 seconds / 15 minutes) before it can fire again. Configure via alerting.cooldown_secs in config.toml.
Deploy to Railway
- Create a new project on railway.app and add a service from your Git repo.
- Build settings: Railway auto-detects the Dockerfile. No changes needed.
-
Environment variables:
env
BLOOP__AUTH__HMAC_SECRET=your-production-secret BLOOP__AUTH__RP_ID=errors.yourapp.com BLOOP__AUTH__RP_ORIGIN=https://errors.yourapp.com BLOOP__DATABASE__PATH=/data/bloop.db BLOOP_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/XXX RUST_LOG=bloop=info -
Persistent volume: Add a volume mounted at
/datafor SQLite persistence. -
Custom domain: Add your domain (e.g.,
errors.yourapp.com) in the service's networking settings. Railway handles TLS automatically.
Important: Set rp_id and rp_origin to match your custom domain. WebAuthn registration will fail if these don't match the browser's origin.
Deploy to Dokploy
- Add application: In your Dokploy dashboard, create a new application from your Git repository.
- Build configuration: Select "Dockerfile" as the build method. Dokploy will use the Dockerfile in the repo root.
- Environment variables: Add the same variables as Railway (above) in the Environment tab.
-
Volume mount: Add a persistent volume:
yaml
Host path: /opt/dokploy/volumes/bloop Container path: /data -
Domain & SSL: Add your domain in the Domains tab. Enable "Generate SSL" for automatic Let's Encrypt certificates. Set the container port to
5332.
Both Railway and Dokploy support health checks. Point them at /health — it returns {"status":"ok","db_ok":true,"buffer_usage":0.0}.