Quick Start

Prerequisites

Run with Docker

bash
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

bash
git clone https://github.com/jaikoo/bloop.git
cd bloop
cargo build --release
./target/release/bloop --config config.toml

Send Your First Error

bash
# 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

toml
# ── 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

VariableOverridesExample
BLOOP__SERVER__PORTserver.port8080
BLOOP__DATABASE__PATHdatabase.path/data/bloop.db
BLOOP__AUTH__HMAC_SECRETauth.hmac_secretmy-production-secret
BLOOP__AUTH__RP_IDauth.rp_iderrors.myapp.com
BLOOP__AUTH__RP_ORIGINauth.rp_originhttps://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.

Client
SDK / curl
Middleware
HMAC Auth
Validate
Payload Check
Fingerprint
xxhash3
Buffer
MPSC (8192)
Flush
Batch Writer
Store
SQLite WAL

Storage Layers

LayerRetentionPurpose
Raw events7 days (configurable)Full event payloads for debugging
AggregatesIndefiniteError counts, first/last seen, status
Sample reservoirIndefinite5 sample occurrences per fingerprint

Fingerprinting

Every ingested error gets a deterministic fingerprint. The algorithm:

  1. Normalize the message: strip UUIDs → strip IPs → strip all numbers → lowercase
  2. Extract top stack frame: skip framework frames (UIKitCore, node_modules, etc.), strip line numbers
  3. 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:

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

typescript
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

typescript
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)

swift
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)

kotlin
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

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

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

MethodPathAuthDescription
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

FieldTypeRequiredDescription
timestampintegerYesUnix epoch seconds
sourcestringYes"ios", "android", or "api"
environmentstringYesDeployment environment
releasestringYesRelease version or build ID
error_typestringYesException class or error category
messagestringYesError message (max 2048 bytes)
app_versionstringNoDisplay version string
build_numberstringNoBuild number
route_or_procedurestringNoAPI route or RPC method
screenstringNoMobile screen / view name
stackstringNoStack trace (max 8192 bytes)
http_statusintegerNoHTTP status code
request_idstringNoCorrelation/trace ID
user_id_hashstringNoHashed user identifier
device_id_hashstringNoHashed device identifier
fingerprintstringNoCustom fingerprint (skips auto-generation)
metadataobjectNoArbitrary JSON (max 4096 bytes)

Query Parameters for /v1/errors

ParamTypeDescription
releasestringFilter by release version
environmentstringFilter by environment
sourcestringFilter by source (ios, android, api)
routestringFilter by route/procedure
statusstringFilter by status (active, resolved, ignored)
sinceintegerUnix timestamp lower bound
untilintegerUnix timestamp upper bound
sortstringSort field
limitintegerResults per page (default: 50, max: 200)
offsetintegerPagination offset

Alerting

Bloop supports alert rules that fire webhooks when conditions are met.

Alert Rule Types

TypeFires whenConfig 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

bash
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:

json
{
  "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

  1. Create a new project on railway.app and add a service from your Git repo.
  2. Build settings: Railway auto-detects the Dockerfile. No changes needed.
  3. 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
  4. Persistent volume: Add a volume mounted at /data for SQLite persistence.
  5. 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

  1. Add application: In your Dokploy dashboard, create a new application from your Git repository.
  2. Build configuration: Select "Dockerfile" as the build method. Dokploy will use the Dockerfile in the repo root.
  3. Environment variables: Add the same variables as Railway (above) in the Environment tab.
  4. Volume mount: Add a persistent volume:
    yaml
    Host path:      /opt/dokploy/volumes/bloop
    Container path: /data
  5. 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}.