OculoDocumentation docs.mdllms.txtOpenAPIOpen dashboard

Oculo documentation

Oculo is a self-hosted platform for logs and errors. Your apps send log events, and Oculo stores them, makes them searchable, groups errors into issues, and alerts you when something breaks. People use the web dashboard. Scripts and LLM agents (Claude, ChatGPT, Cursor…) use the same data through a REST API, a CLI and an MCP server.

These docs are written for both people and LLMs. A running server shows all of them as one page at /docs, serves the same content as raw Markdown at /docs.md, and has a shorter agent guide at /llms.txt.

#Quick start

  1. Start the server (Node 20+):
    npm install
    npm run dev          # https://oculo.funonymous.in
  2. Create the first account. Open the dashboard. The first account becomes the instance admin. (An admin API key is also printed on first start and saved to server/data/admin-key.txt.)
  3. Create a project on the Projects page. You get an ingest key (to send logs) and a read key (to query, which is safe to give to an LLM).
  4. Send a log:
    curl -X POST https://oculo.funonymous.in/api/v1/ingest \
      -H "Authorization: Bearer oc_ingest_…" -H "Content-Type: application/json" \
      -d '[{"level":"error","message":"Payment failed","service":"api","attributes":{"orderId":8812}}]'
  5. Look at it. It shows up within a second on Logs and Live, and as an Issue because it's an error.

For demo data, run npm run seed while the server is running. It loads about 24 hours of realistic logs across two releases.

Next: see everything Oculo can do in Features, run it for real with Deployment, and when volume grows, read Scaling.

#Features

A tour of everything Oculo does. Each item links to the page with the details.

#Collect

#Find

#Understand errors

#Get told

#Work with LLMs and scripts

#Run it your way

#Concepts

ThingWhat it is
ProjectA container for one app or service's logs, issues, alerts and keys. Most people create one per app.
Log eventOne line your app logged: a timestamp, level, message, and optional service, environment, release, host, trace id, user, attributes and exception.
IssueA group of identical errors, such as "TypeError: Cannot read properties of undefined". Oculo fingerprints each error, so a thousand occurrences become one issue with a count, users, releases and a timeline.
PatternA template for similar non-error messages, such as Charging order <*>, so a million info logs read as a few dozen lines.
ReleaseThe version string your app reports. It powers "new in this release" and deploy markers.
HeartbeatAn expected check-in from a cron job. A missed check-in triggers an alert.

#Levels

trace < debug < info < warn < error < fatal. Common aliases are understood: warning, err, critical, pino numbers 10–60, and OTel severity numbers. Anything at error or above, or carrying an exception, becomes part of an issue.

#Keys, accounts and roles

There are two ways in, and both end up with a role on a project.

CredentialUse it forRole
Ingest key oc_ingest_…Sending logs from apps, OTel collectors and heartbeats. It's safe to ship in app code.Can only send
Read key oc_read_…Scripts, the CLI, LLM agents and MCPviewer (read-only)
Read/write keyAgents that may also resolve issues or upload source mapsmember (but can't manage alerts)
Admin key oc_admin_…Bootstrap and automation across all projectsadmin
Account (email/password or GitHub)People using the dashboardviewer, member or owner, per project

Send keys as Authorization: Bearer <key>. The X-Oculo-Key header and the ?key= query parameter also work. The dashboard uses an HttpOnly session cookie instead of keys when you sign in with an account.

#Sending logs

Every SDK is a thin wrapper around one endpoint, so a language without an SDK can still send logs over plain HTTP.

#HTTP (any language)

POST /api/v1/ingest with an ingest key. The body can be a JSON array of events, a single object, {"events": […]}, or NDJSON. It may be gzip, deflate or br encoded. The limits are 5,000 events and 10 MB (decompressed) per request.

{
  "timestamp": "2026-11-14T16:20:01.123Z",
  "level": "error",
  "message": "Payment failed for order 8812",
  "logger": "billing.stripe",
  "service": "api", "environment": "prod", "release": "1.4.2", "host": "web-3",
  "trace_id": "abc123", "span_id": "def456",
  "user": { "id": "u_42", "email": "…" },
  "attributes": { "orderId": 8812, "amount": 499, "route": "/checkout" },
  "exception": { "type": "CardError", "value": "card_declined", "stacktrace": "…" },
  "fingerprint": ["optional", "custom", "grouping"]
}

The response is 202 {"accepted": n, "dropped": m}. Events are dropped only by the project's sampling and drop rules. With the Redis queue on, the response is 202 {"accepted": n, "dropped": 0, "queued": true} (rules run later), and a full queue answers 503 with Retry-After; see Scaling.

#JavaScript / TypeScript

import { Oculo } from '@gouravsrf/oculo';   // npm install @gouravsrf/oculo  (Node 18+ and browsers, ESM + CommonJS)

const log = Oculo.init({ key: 'oc_ingest_…', url: 'https://oculo.funonymous.in', env: 'prod', release: '1.4.2', service: 'api' });

log.info('User signed in', { userId: 42 });
log.error('Payment failed', { orderId }, err);
log.captureException(err);
const db = log.child({ logger: 'db' });          // child logger with extra context
log.setUser({ id: 'u_42' }); log.setTag('region', 'ap-south');

// Express: request logs, a request id + trace id on every log, and per-request breadcrumbs
app.use(log.expressMiddleware());
app.use(log.expressErrorHandler());

// Any other code path: give a unit of work its own breadcrumbs + context
await log.withScope(() => handleJob(job), { job_id: job.id });

#Python

import oculo                     # pip install /path/to/oculo/sdk-python  (3.8+, standard library only)

log = oculo.init(key="oc_ingest_…", url="https://oculo.funonymous.in", env="prod", release="1.4.2", service="worker")
log.info("User signed in", user_id=42)
try:
    charge()
except Exception:
    log.exception("Payment failed", order_id=8812)

import logging
logging.getLogger().addHandler(oculo.OculoHandler())        # bridge the stdlib logging module

with oculo.scope(request_id="r1", trace_id="abc"):          # per-request breadcrumbs + context (thread/asyncio safe)
    handle()

# FastAPI / Starlette:  app.add_middleware(oculo.middleware.OculoASGIMiddleware)
# Django:               MIDDLEWARE = ["oculo.middleware.OculoDjangoMiddleware", ...]

It behaves like the JS SDK: a background sender thread, batching, gzip, retries, flush at exit, and capture through sys.excepthook and threading.excepthook.

#OpenTelemetry (Go, Java, .NET, Rust, …)

Point any OTLP log exporter at Oculo. Both protobuf (the default) and JSON are accepted:

OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=https://oculo.funonymous.in/v1/logs
OTEL_EXPORTER_OTLP_LOGS_PROTOCOL=http/protobuf        # or http/json
OTEL_EXPORTER_OTLP_LOGS_HEADERS=Authorization=Bearer%20oc_ingest_…

Resource attributes map to service (service.name), release (service.version), environment (deployment.environment.name) and host (host.name). Trace and span ids become hex, and exception.* attributes become the exception.

#Heartbeats from cron jobs

# at the end of the job (creates the heartbeat on first ping when ?period is given)
curl -fsS -X POST "https://oculo.funonymous.in/api/v1/heartbeats/nightly-backup/ping?period=1d" -H "Authorization: Bearer oc_ingest_…"
# or with the CLI
oculo heartbeat nightly-backup

#Searching: the query language

One search bar, used everywhere: the dashboard, the API (q=), the CLI, MCP tools and alert queries.

payment failed level:>=warn env:prod,staging -healthcheck
@status:>=500 @route:/checkout* release:1.4* trace:abc123 user:u_42
"connection refused" has:exception since:2h until:30m
SyntaxMeaning
payment failedFull-text prefix match over message, exception and attributes (pay matches "payment"). Terms are ANDed.
"exact phrase"An exact phrase
-term, -field:valueExcludes matches
field:valueAn exact value. , means OR (env:prod,staging); * is a wildcard (release:1.4*)
field:>=v > <= < !=Comparisons; numeric when the value is a number
@path.to.attr:valueAttribute filter, nested (@user.plan:pro) or flat OTel keys (@http.status:503). Unknown field names are treated as attributes.
has:exception has:user has:trace has:release has:@attrThe field is present
since:2h until:30mA time window: relative (s m h d w), ISO or epoch ms

Built-in fields:

Free text is case- and accent-insensitive (cafe finds "Café"). Queries return the same results whether logs are stored in SQLite or ClickHouse (see Scaling).

Every value is a bound SQL parameter or a quoted full-text phrase, so queries can't inject anything. Malformed queries return 400 with a hint.

#The dashboard

Keyboard shortcuts:

The query and time range live in the URL, so any view can be shared as a link. There are dark and light themes, relative or absolute times in your local timezone, and the layout works on phones.

#Issues and grouping

Each error gets a fingerprint built from:

Library frames (node_modules, site-packages, the JVM and so on) are skipped. So the same bug on different hosts, for different orders, groups into one issue.

#Log patterns

Oculo turns each message into a template by replacing values with <*>: numbers, ids, UUIDs, IPs, URLs, quoted strings, path segments containing digits, and the values in key=value pairs. So Charging order 8761 and Charging order 9283 become one pattern, Charging order <*>.

On the Logs page, switch to Patterns. It respects your current query and time range. Click a pattern to see its logs (pattern:<id>), or exclude it (-pattern:<id>). Patterns are capped at 5,000 per project.

#Metrics

Charts computed from your logs, with no separate metrics pipeline:

Presets include "p95 latency by route" and "errors by service". You can click a series in the legend to hide it, and the table under the chart links to each group's logs.

API: GET /api/v1/metrics?fn=p95&field=@duration_ms&group_by=@route&q=service:api&since=24h. Percentiles scan at most the 500k most recent rows, and the response says when it was truncated. Use GET /api/v1/fields to discover attribute names and types.

#Alerts and heartbeats

When an alert fires:

Where it's sent:

A cooldown stops repeats, Send test checks the wiring, and History shows every delivery and its result. Secrets (bot tokens, routing keys) are masked whenever an alert is read back.

Heartbeats live on the Alerts page:

  1. Create one with a slug, an expected period and a grace time.
  2. Copy its curl command into the end of your job.
  3. Add a Heartbeat missed alert.

#Releases and source maps

Set release in your SDK. The Releases page shows the error rate per release and compares two releases. Deploy markers appear on every chart.

For minified JavaScript, upload source maps per release. Stack traces are then rewritten to the original file, line and function before grouping. The original minified stack is kept, and the dashboard can show it.

oculo sourcemaps upload --release 1.4.2 ./dist          # uploads every *.js.map under ./dist
await Oculo.uploadSourceMap({ url: 'https://oculo.funonymous.in', key: 'oc_read_…(read/write)', release: '1.4.2', file: 'app.3f9a1c.js', map });

Uploading needs a read/write key or a member account; ingest keys can't upload. The release must match what the app reports.

#Using Oculo from an LLM

A good agent workflow:

  1. Call project_summary.
  2. Call list_issues sort=freq.
  3. Call get_issue on the top issue.
  4. Use search_logs with issue:<id> or trace:<id> to see what happened around it.
  5. Use get_log_context on one event.
  6. Use compare_releases to check whether it started with a deploy.

#CLI

npm run build && npm install -g ./cli      # provides the `oculo` command (or: node cli/dist/index.js)
oculo login --url https://oculo.funonymous.in --key oc_read_…  # saved to ~/.config/oculo/config.json (mode 600)
oculo search 'level:>=warn service:api' --since 1h -n 50
oculo tail 'level:error'                    # live; reconnects automatically
oculo issues --sort freq
oculo summary
oculo sourcemaps upload --release 1.4.2 ./dist
oculo heartbeat nightly-backup

--json prints one JSON object per line, for piping into jq. Output is coloured on a terminal and plain when piped. OCULO_URL, OCULO_KEY and OCULO_PROJECT override the saved config.

#API reference

The base URL is https://oculo.funonymous.in. Errors look like {"error": "...", "hint": "..."}. Read endpoints accept format, fields and, for admin keys and users with several projects, project=<slug>.

Method and pathWhoWhat
POST /api/v1/ingestingest keySend events (JSON array, object, NDJSON; gzip OK)
POST /v1/logsingest keyOpenTelemetry OTLP/HTTP logs (protobuf or JSON)
GET /api/v1/summaryviewerProject health (best first call)
GET /api/v1/logs/search?q&since&until&limit&cursor&orderviewerSearch logs
GET /api/v1/logs/:id, /:id/context?n&scopeviewerOne log; with surrounding logs and its trace
GET /api/v1/logs/histogram?q&since&until&bucketsviewerCounts over time by level, plus deploy markers
GET /api/v1/logs/facets?q&facetsviewerTop values per field
GET /api/v1/logs/export?q&as=ndjson|csvviewerStreamed export
GET /api/v1/traces/:trace_idviewerEvery log in a trace
GET /api/v1/tail?q&ticketviewerLive tail (Server-Sent Events)
POST /api/v1/tail/ticket?projectviewerOne-time, 60 s ticket for the tail URL. The ticket remembers its project, so open GET /api/v1/tail?ticket=…&q=… without a key or project
GET /api/v1/patterns?q&since&sortviewerLog templates with counts
GET /api/v1/metrics?fn&field&group_by&q&since&intervalviewerMetric series
GET /api/v1/fieldsviewerAttribute keys and types
GET /api/v1/issues?status&sort&q&release&sinceviewerList issues
GET /api/v1/issues/:id, /:id/eventsviewerIssue detail, events
PATCH /api/v1/issues/:id · POST …/resolve, …/ignore, …/unresolvememberChange status or assignee
POST /api/v1/issues/:id/explain?refresh=1viewerAI explanation (when enabled)
GET /api/v1/releases, /releases/compare?a&bviewerReleases; what changed
GET/POST/DELETE /api/v1/saved-searchesviewer / memberSaved searches
GET/POST/PATCH/DELETE /api/v1/alerts…, POST …/:id/test, GET …/:id/eventsmember account or admin keyAlert rules
GET/POST/PATCH/DELETE /api/v1/heartbeats…viewer / memberHeartbeats
POST /api/v1/heartbeats/:slug/ping?period&graceingest key or memberCheck in
GET/POST/DELETE /api/v1/sourcemaps?releaseviewer / memberSource maps
GET /api/v1/projects, /projects/:idanyProjects you can see, with your role
POST /api/v1/projectsadminCreate a project (returns keys once)
PATCH/DELETE /api/v1/projects/:idownerSettings, delete
GET/POST /api/v1/projects/:id/keys, DELETE /api/v1/keys/:idownerAPI keys
GET /api/v1/projects/:id/members, PATCH/DELETE …/members/:userIdviewer / ownerTeam
POST/GET/DELETE /api/v1/projects/:id/invitesownerInvite links
GET /api/v1/invites/:token, POST …/acceptpublic / signed inJoin a project
POST /api/v1/auth/signup, /login, /logout; GET /api/v1/auth/config; GET /api/v1/auth/github/startpublicAccounts and GitHub sign-in
GET /api/v1/meanyWho am I
GET /healthzpublicLiveness, storage mode (sqlite/clickhouse), queue mode, and outbox_rows / queue_backlog when those are on

The machine-readable version is /openapi.json.

#Configuration

Set these as environment variables on the server.

VariableDefaultPurpose
PORT, HOST4318, 0.0.0.0Listen address
OCULO_DBserver/data/oculo.dbSQLite file (WAL mode): metadata always, and log rows unless ClickHouse is on
PUBLIC_URLfrom the Host headerUsed in alert links, invite links, OAuth redirect and docs
INGEST_URLPUBLIC_URLWhere apps should send logs, if different from the dashboard address (for example dashboard on Vercel, API on oculo-api.example.com). Used in setup snippets and heartbeat ping URLs.
OCULO_ADMIN_KEYgenerated on first bootYour own bootstrap admin key
RETENTION_DAYS / ROLLUP_RETENTION_DAYS14 / 400Default raw-log retention / hourly rollups
INGEST_RATE_LIMIT0 (off)Events per minute per key
MAX_BODY_BYTES10 MBIngest body limit (decompressed)
PRODUCT_NAMEOculoDisplay name
OCULO_OPEN_SIGNUPoff1 lets anyone create an account (otherwise invite only after the first)
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET–Enable "Continue with GitHub". The callback is PUBLIC_URL/api/v1/auth/github/callback.
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM, SMTP_SECURE–Email alerts
ANTHROPIC_API_KEY (or OCULO_EXPLAIN=1 with another credential), OCULO_EXPLAIN_MODEL– / claude-opus-5-5Enable ✨ Explain
CLICKHOUSE_URL, CLICKHOUSE_USER, CLICKHOUSE_PASSWORD, CLICKHOUSE_DATABASEoff, default, empty, oculoStore log rows in ClickHouse. See Scaling.
REDIS_URLoffPut a Redis Streams queue in front of ingest. See Scaling.
OCULO_QUEUE_STREAM, OCULO_QUEUE_GROUP, OCULO_QUEUE_BATCH, OCULO_QUEUE_MAX_BACKLOG, OCULO_QUEUE_CLAIM_IDLE_MSoculo:ingest, oculo, 100, 50000, 60000Queue tuning
TELEGRAM_API_BASE, PAGERDUTY_EVENTS_URL, GITHUB_OAUTH_BASE, GITHUB_API_BASE, ANTHROPIC_BASE_URLthe real servicesOverride service URLs (proxies, testing)

Per-project settings (in Settings, or PATCH /api/v1/projects/:id):

#Scaling: ClickHouse and Redis

Out of the box Oculo keeps everything in one SQLite file. That is fast and needs no setup, and it is the right choice for most teams: on a laptop it ingests about 20,000 events per second.

Two optional pieces take it further. Each one is turned on by a single environment variable, and they can be used separately or together:

PieceTurn on withWhat it does
ClickHouseCLICKHOUSE_URLStores the raw log rows in a ClickHouse table instead of SQLite. Columnar compression and fast scans keep search and metrics quick on tables far larger than SQLite handles comfortably.
Redis StreamsREDIS_URLPuts a durable queue between the HTTP endpoint and processing. Ingest answers immediately; bursts wait in Redis instead of slowing requests down.

Everything else (projects, keys, accounts, issues and their counters, rollups, patterns, alerts) always stays in SQLite. The dashboard, the query language, the API, the CLI and MCP behave the same in every mode; the test suite runs in all four combinations and a parity test checks that SQLite and ClickHouse return identical results for the same data and queries.

#When to use them

For reference, on one laptop with 8 concurrent senders posting batches of 500 events, time until all 100,000 events were searchable:

ModeEvents per second
SQLite~21,000
SQLite + Redis~23,000
ClickHouse~22,000
ClickHouse + Redis~32,000

These numbers measure ingest only. ClickHouse's main advantage, cheap storage and fast scans over very large tables, shows up at volumes far beyond this test.

#Turning on ClickHouse

  1. Run ClickHouse (tested with 24.8). With Docker:
    docker run -d --name oculo-clickhouse -p 127.0.0.1:8123:8123 \
      -e CLICKHOUSE_USER=oculo -e CLICKHOUSE_PASSWORD=change-me \
      -e CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 \
      -v oculo-clickhouse:/var/lib/clickhouse clickhouse/clickhouse-server:24.8
  2. Start Oculo with:
    CLICKHOUSE_URL=http://localhost:8123 CLICKHOUSE_USER=oculo CLICKHOUSE_PASSWORD=change-me npm run dev

    Oculo creates the database (CLICKHOUSE_DATABASE, default oculo) and the logs table on first start. The startup line says logs: clickhouse, and GET /healthz reports "storage": "clickhouse".

If ClickHouse can't be reached at startup, Oculo exits with a clear error instead of silently falling back, so a typo in the URL is obvious.

#How ClickHouse mode works

#Existing data

Turning ClickHouse on does not move logs already in SQLite: new logs go to ClickHouse and old ones stay in the SQLite file (they are no longer shown). Issues, counts, timelines, rollups and patterns are kept, because they live in SQLite, and new log ids continue above the old ones. An issue whose latest event is still in SQLite shows no latest event until it happens again. There is no built-in tool to copy old rows into ClickHouse; keep the SQLite file until they would have aged out under retention anyway.

Use one ClickHouse database per Oculo installation (CLICKHOUSE_DATABASE). Pointing a brand-new SQLite database at a ClickHouse database that already holds another installation's logs would show those rows under the new installation's project ids.

#Turning on the Redis queue

  1. Run Redis (tested with 7.4; 6.2 is the minimum for the stream commands used). Turn on persistence (AOF) so queued batches survive a Redis restart:
    docker run -d --name oculo-redis -p 127.0.0.1:6379:6379 -v oculo-redis:/data redis:7.4 redis-server --appendonly yes
  2. Start Oculo with REDIS_URL=redis://localhost:6379. The startup line says queue: redis.

#How the queue works

#Things to know about the queue

#Both together

With CLICKHOUSE_URL and REDIS_URL set, a batch goes HTTP → Redis stream → worker (one SQLite transaction for counters + outbox) → ClickHouse. This is the highest-throughput setup and the one used by the Docker Compose file in Deployment.

#Settings

VariableDefaultPurpose
CLICKHOUSE_URL(off)ClickHouse HTTP endpoint, e.g. http://localhost:8123
CLICKHOUSE_USER / CLICKHOUSE_PASSWORDdefault / emptyCredentials
CLICKHOUSE_DATABASEoculoDatabase, created if missing
REDIS_URL(off)e.g. redis://localhost:6379 or rediss://user:pass@host:6380
OCULO_QUEUE_STREAMoculo:ingestStream name (dead letters go to <stream>:dead)
OCULO_QUEUE_GROUPoculoConsumer group
OCULO_QUEUE_BATCH100Messages applied per transaction
OCULO_QUEUE_MAX_BACKLOG50000Above this many waiting batches, ingest answers 503
OCULO_QUEUE_CLAIM_IDLE_MS60000Reclaim messages a crashed worker left unacknowledged

#Checking it

curl -s https://oculo.funonymous.in/healthz
# {"ok":true,"name":"Oculo","storage":"clickhouse","queue":"redis","outbox_rows":0,"queue_backlog":0}

In ClickHouse, SELECT count() FROM oculo.logs shows the stored rows. In Redis, XINFO GROUPS oculo:ingest shows the consumer group's lag and pending messages, and XLEN oculo:ingest:dead shows dead-lettered batches.

#Deployment

Oculo is one Node.js process. Pick the setup that matches your volume; you can move between them later.

SetupGood forNeeds
Node + SQLiteMost teams, single serverNode 20+
Docker, SQLiteSame, as a containerDocker
Docker Compose: Oculo + ClickHouse + RedisHigh volume, long retentionDocker Compose

#Node + SQLite

git clone <your fork of this repository> oculo && cd oculo
npm ci
npm run build -w server
PUBLIC_URL=https://logs.example.com OCULO_DB=/var/lib/oculo/oculo.db node server/dist/index.js

#Docker (SQLite)

docker build -t oculo .
docker run -d --name oculo -p 4318:4318 -v oculo-data:/data -e PUBLIC_URL=https://oculo.funonymous.in oculo
docker exec oculo cat /data/admin-key.txt      # the bootstrap admin key

The image runs as the unprivileged node user, stores the database in the /data volume and has a health check on /healthz.

#Docker Compose: Oculo + ClickHouse + Redis

The repository's docker-compose.yml starts all three, with ClickHouse holding log rows and Redis Streams in front of ingest (see Scaling):

CLICKHOUSE_PASSWORD=pick-a-strong-password docker compose up -d --build
docker compose logs -f oculo        # look for: logs: clickhouse, queue: redis

Then open https://oculo.funonymous.in. Data lives in three named volumes (oculo-data, clickhouse-data, redis-data). ClickHouse and Redis are not published on host ports; only Oculo is.

#Behind a reverse proxy (HTTPS)

Terminate TLS in nginx, Caddy or a load balancer and forward to port 4318.

Caddy example:

logs.example.com {
  reverse_proxy 127.0.0.1:4318 {
    flush_interval -1
  }
}

#Backups

#Upgrades

  1. Back up the SQLite file.
  2. Pull the new version, npm ci, npm run build -w server (or rebuild the image).
  3. Restart. Database migrations run automatically on start (PRAGMA user_version); the ClickHouse table is created if missing.

With the Redis queue on, a restart doesn't lose ingest: batches wait in the stream and are applied when the server is back. Without it, SDKs buffer and retry while the server restarts.

#Sizing notes

#Security and privacy

#Development and testing

#Layout

PathWhat
server/The server: HTTP API, ingest pipeline, storage, alerts, dashboard (server/public, no build step)
sdk-js/JavaScript/TypeScript SDK (ESM + CommonJS builds)
sdk-python/Python SDK (standard library only)
mcp/MCP server for LLM clients (stdio)
cli/The oculo command
docs/This documentation (served at /docs and /docs.md)
scripts/Seed data, test suites, benchmarks

#Architecture

apps / SDKs / OTel ──► POST /api/v1/ingest ──► [Redis stream]* ──► worker
                                                                     │  one SQLite transaction:
                                                                     │  normalize, scrub, source maps,
                                                                     │  fingerprint → issues, rollups,
                                                                     │  patterns, log rows (SQLite)
                                                                     │  or outbox (ClickHouse)*
                                                                     ▼
                                                     SQLite (metadata, issues, …)  ──outbox──►  ClickHouse*
dashboard / CLI / MCP / agents ──► REST API ──► query compiler ──► SQLite FTS5 or ClickHouse SQL
                                                                                   * optional

#Running locally

npm install
npm run dev            # server on https://oculo.funonymous.in with auto-reload
npm run seed           # (another terminal) ~24h of demo logs

#Tests

CommandWhat it checks
npm run e2eBoots a server on a temp database and runs ~190 API checks: keys and roles, accounts and OAuth (stubbed), query safety, grouping, patterns, metrics, source maps, OTLP (real OpenTelemetry exporter), every alert channel (stubbed), heartbeats, digest, docs
npm run e2e:allThe same suite in every storage mode: SQLite, SQLite+Redis, ClickHouse, ClickHouse+Redis (the last three when the env vars below are set)
npm run test:paritySends identical data to a SQLite server and a ClickHouse server and checks ~100 queries, metrics, facets, histograms, patterns, issue pages and exports return identical results
npm run test:resilienceFailure modes against real ClickHouse and Redis: ClickHouse down, crash between insert and acknowledgement, duplicate delivery, crashed consumer, poison message vs. outage, full queue, Redis down, a 200,000-event backlog (event loop must stay responsive), switching an existing SQLite database to ClickHouse, bad startup config
npm run test:sdkJS and Python SDK lifecycle: exit flush, uncaught errors, outage recovery, batching
npm run build -w mcp && OCULO_KEY=<read key> npm run mcp:smokeThe MCP server over stdio

ClickHouse and Redis tests need running servers:

docker run -d --name oculo-clickhouse -p 127.0.0.1:8123:8123 -e CLICKHOUSE_USER=oculo -e CLICKHOUSE_PASSWORD=oculo-dev \
  -e CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 clickhouse/clickhouse-server:24.8
docker run -d --name oculo-redis -p 127.0.0.1:6379:6379 redis:7.4

export E2E_CLICKHOUSE_URL=http://localhost:8123 E2E_CLICKHOUSE_USER=oculo E2E_CLICKHOUSE_PASSWORD=oculo-dev
export E2E_REDIS_URL=redis://localhost:6379
npm run e2e:all && npm run test:parity && npm run test:resilience

Each run uses its own ClickHouse database and Redis stream and removes them afterwards.

External services (GitHub, SMTP, Telegram, PagerDuty, Anthropic) are always replaced by local stubs in tests; no test talks to a real third-party service.

#Docs

Each page in docs/ is plain Markdown. The server stitches them into one page in the order listed in server/src/docs.ts (DOC_ORDER), so a new page must be added there. Links between pages ([Metrics](#metrics)) become in-page anchors, and https://oculo.funonymous.in is replaced with the server's own address.

#Troubleshooting

SymptomCheck
401 Invalid or revoked API keyThe key is wrong or revoked. Sign in, then create a new one in Settings → API keys.
403 Ingest keys can only send logsUse a read key (or an account) to query. Ingest keys are write-only by design.
400 Pass ?project=<slug>Admin keys and users in several projects must say which project.
Nothing shows upIs the timestamp in the selected time range? Try 30d. Do sampling or drop rules match? Check the ingest response: dropped.
Errors grouped too much or too littleSend a custom fingerprint.
Stack traces still minifiedThe release on the event must equal the release the map was uploaded for, and the frame's file name must match the bundle name.
Email alerts failSet the SMTP_* variables, then use Send test and check History for the error.
GitHub button missingSet GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET, and register the callback URL.
✨ Explain missingSet ANTHROPIC_API_KEY on the server and restart.
Can't use ClickHouse at … on startupCheck CLICKHOUSE_URL, user and password; curl http://host:8123/ping should answer Ok.
Can't use Redis at … on startupCheck REDIS_URL; redis-cli -u $REDIS_URL ping should answer PONG.
503 Ingest queue is fullThe worker is behind by more than OCULO_QUEUE_MAX_BACKLOG batches. Senders retry automatically; check the server log and SQLite disk speed.
Just-sent logs not searchable yet (queue or ClickHouse mode)Ingest is asynchronous there. GET /healthz shows queue_backlog and outbox_rows; both drop to 0 within milliseconds normally. If outbox_rows stays up, ClickHouse is unreachable (rows are kept and retried).
Messages in oculo:ingest:deadA batch failed on its own (the error is stored with it). Fix the cause, then re-send the events.