◉
24observe
DOCUMENTATION • LOG MANAGEMENT • 5 MIN READ

Send logs as JSON. Search them as JSON.

One bearer token, one HTTPS endpoint, the shape your code already produces. No collector daemon, no proprietary query language, no per-GB markup that doubles your bill the month traffic actually grows.

Start free → Quickstart

TL;DR

One bearer token, one HTTPS endpoint, the shape your code already produces. No collector daemon, no proprietary query language, no per-GB markup that doubles your bill the month traffic actually grows.

Docs · logs (v1)

Send logs as JSON. Search them as JSON.

One bearer token, one HTTPS endpoint, the shape your code already produces. No collector daemon, no proprietary query language, no per-GB markup that doubles your bill the month traffic actually grows.

Ship with curl, an OTel SDK, Vector, or Fluent Bit. Search by time + substring + service + level. Live-tail over SSE — events land in your terminal under 100 ms after they hit ingest. Pattern-match a threshold, get paged. Same auth, audit, and multi-tenancy as the rest of the API.

v1 scope. Ingest + search + live tail + log-based alerts + auto-facet extraction ship now. R2 archival, saved searches, and dashboards are queued for v1.1+.

Endpoints

  • POST /api/v1/logs/ingest — JSON object, JSON array (≤500), or NDJSON. PAT bearer auth.
  • GET /api/v1/logs/search — time-range + substring + service + level. Cursor-paginated.
  • GET /api/v1/logs/tail — Server-Sent Events. Same filters; tails forward from "now."
  • GET / POST / PATCH / DELETE /api/v1/log-alerts — manage alert rules.

Ingest — event shape

{
  "ts": "2026-05-10T12:00:00Z",         // optional — defaults to now if absent
  "level": "info",                       // trace | debug | info | warn | error | fatal
  "service": "checkout",                 // any string ≤ 255
  "source": "stdout",                    // optional; e.g. "stdout", "syslog", "file"
  "host": "web-1",                       // optional
  "message": "checkout completed",       // required, ≤ 8KB
  "attrs": { "user_id": 42, "amount": 19.99 }  // optional, ≤ 4KB serialized
}

Server stamps org_id from your PAT — agents cannot spoof an origin org by lying in the body. ingest_pat_id and client_ip are captured server-side for forensic queries.

PAT scopes

Ingest requires the logs:write scope; search + tail require logs:read. A wildcard PAT (scopes: ["*"]) covers both — that's the default for backward-compat. For agent least-privilege, mint a narrow PAT (see /docs/api):

curl -X POST https://api.24observe.com/api/v1/me/tokens \
  -H 'Authorization: Bearer obs_<admin>' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "aeoniti-log-shipper",
    "scopes": ["logs:write"],
    "dailyLogBytesLimit": 104857600
  }'
# Token only valid for /logs/ingest. Cap = 100 MB/day on this PAT.
# Wrong-scope call returns 403 PAT_SCOPE_INSUFFICIENT.

Limits

  • Per request body: 1 MB hard cap.
  • Per request events: ≤ 500 in the array form.
  • Per event message: ≤ 8 KB. Per event attrs (JSON-serialized): ≤ 4 KB.
  • Per IP rate: 300 ingest calls / minute (shared across API instances, not per-machine).
  • Per-org monthly volume cap (bytes): Free 1 GB · Startup 10 GB · Pro 100 GB. 429 PLAN_LIMIT_LOGS_VOLUME when exhausted.
  • Per-PAT daily volume cap (optional, set at PAT creation): dailyLogBytesLimit. 429 PAT_DAILY_LIMIT_EXCEEDED on trip. Resets UTC midnight.
  • Server retention: 30 days (v1 default; per-plan retention queued).

Wire compression

Set Content-Encoding: gzip, deflate, or br on the request and send the compressed body. The ingest endpoint decompresses natively — no per-customer plan change, no special header. Measured on a 400-event pino batch against production:

Compression is dramatic on structured logs — repetitive JSON keys and timestamps collapse hard, so a busy batch routinely arrives at a small fraction of its raw size. br (brotli) squeezes hardest, with gzip close behind and supported everywhere.

For a high-volume pipeline that's the difference between shipping tens of gigabytes a day and shipping a sliver of that — a meaningfully smaller bandwidth bill on your side, and batches that arrive faster end-to-end because there's simply less to upload.

Per-shipper config — one line each.

Vector (TOML):

[sinks.observe24]
type        = "http"
inputs      = ["my_logs"]
uri         = "https://api.24observe.com/api/v1/logs/ingest"
compression = "gzip"
[sinks.observe24.encoding]
codec = "json"
[sinks.observe24.auth]
strategy = "bearer"
token    = "obs_..."

Fluent Bit (HTTP output):

[OUTPUT]
    Name      http
    Match     *
    Host      api.24observe.com
    Port      443
    URI       /api/v1/logs/ingest
    Format    json
    compress  gzip
    Header    Authorization Bearer obs_...
    tls       on

OpenTelemetry Collector (otlphttp exporter):

exporters:
  otlphttp/24observe:
    endpoint: https://api.24observe.com/api/v1/otlp
    encoding: json
    compression: gzip
    headers:
      authorization: "Bearer obs_..."

curl:

gzip -c batch.json | curl -X POST https://api.24observe.com/api/v1/logs/ingest \
  -H 'Authorization: Bearer obs_...' \
  -H 'Content-Type: application/json' \
  -H 'Content-Encoding: gzip' \
  --data-binary @-

If the body is truncated or corrupt, the response is 400 BAD_COMPRESSED_BODY — retry with a fresh payload. The observe24-collector ships with compression: gzip enabled by default; no configuration needed if you're using it.

curl — single event

curl -X POST https://api.24observe.com/api/v1/logs/ingest \
  -H 'Authorization: Bearer obs_...' \
  -H 'Content-Type: application/json' \
  -d '{"level":"info","service":"my-app","message":"hello from my agent"}'

# Response (202)
{ "accepted": 1, "rejected": [], "bytesAccounted": 87 }

curl — batch

curl -X POST https://api.24observe.com/api/v1/logs/ingest \
  -H 'Authorization: Bearer obs_...' \
  -H 'Content-Type: application/json' \
  -d '[
    {"level":"info","service":"web","message":"GET /healthz 200"},
    {"level":"warn","service":"web","message":"slow query 1.2s","attrs":{"query":"select_users"}},
    {"level":"error","service":"worker","message":"job failed","attrs":{"job_id":"abc123"}}
  ]'

curl — NDJSON (newline-delimited)

curl -X POST https://api.24observe.com/api/v1/logs/ingest \
  -H 'Authorization: Bearer obs_...' \
  -H 'Content-Type: application/x-ndjson' \
  --data-binary $'{"level":"info","service":"web","message":"line1"}\n{"level":"warn","service":"web","message":"line2"}'

TypeScript (Node 22)

const PAT = process.env.OBSERVE24_PAT!;

async function ship(events: object[]) {
  const r = await fetch('https://api.24observe.com/api/v1/logs/ingest', {
    method: 'POST',
    headers: {
      authorization: `Bearer ${PAT}`,
      'content-type': 'application/json',
    },
    body: JSON.stringify(events),
  });
  if (!r.ok) throw new Error(`ingest ${r.status}: ${await r.text()}`);
  return r.json();
}

await ship([{ level: 'info', service: 'my-app', message: 'started', attrs: { pid: process.pid } }]);

Python

import os, httpx

PAT = os.environ['OBSERVE24_PAT']

def ship(events):
    r = httpx.post(
        'https://api.24observe.com/api/v1/logs/ingest',
        headers={'authorization': f'Bearer {PAT}', 'content-type': 'application/json'},
        json=events,
        timeout=10,
    )
    r.raise_for_status()
    return r.json()

ship([{'level': 'info', 'service': 'my-app', 'message': 'started'}])

OpenTelemetry (OTLP/HTTP)

Every official OTel SDK can ship logs to us directly — no agent, no translation layer. Point your SDK at the OTLP endpoint and you're done.

# Any OTel-instrumented service (Java / Python / Node / Go / .NET / Ruby / Rust)
export OTEL_EXPORTER_OTLP_ENDPOINT=https://api.24observe.com/api/v1/otlp
export OTEL_EXPORTER_OTLP_HEADERS=authorization=Bearer obs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
export OTEL_EXPORTER_OTLP_PROTOCOL=http/json
export OTEL_LOGS_EXPORTER=otlp
  • Endpoint: POST /api/v1/otlp/v1/logs (the /v1/logs suffix is appended by the SDK).
  • Content-Type: application/json only in v1. For http/protobuf, run the 24observe collector as a translator.
  • Body limit: 4 MB per export (matches OTel Collector default).
  • Auth: PAT with logs:write scope; same quota + per-PAT byte cap as /logs/ingest.
  • Response: empty {} on full success; {"partialSuccess":{"rejectedLogRecords":N,"errorMessage":"..."}} when some records were dropped (e.g. attrs > 4KB). HTTP 200 in both cases per OTLP spec.
  • Severity mapping: OTLP severityNumber 1-4 → trace, 5-8 → debug, 9-12 → info, 13-16 → warn, 17-20 → error, 21-24 → fatal. Falls back to severityText if number is missing.
  • Resource attributes promoted to top-level columns: service.name → service, host.name → host, telemetry.sdk.name → source. Everything else lands in attrs.
  • Trace context preserved: traceId + spanId flow into attrs.trace_id / attrs.span_id so you can correlate logs with traces (when we ship Tracing v1).

24observe collector (Syslog · Fluent Bit)

For sources that don't speak OTLP natively — legacy syslog appliances or Fluent Bit / Fluentd pipelines — run our pre-configured collector image. Single Docker container, forwards everything as OTLP to the endpoint above.

Heroku log drains

Heroku ships every app log line — dyno output, router events, deploys — to a drain URL of your choice. Add ours to start receiving them; no SDK install, no buildpack changes.

  1. Mint a dedicated PAT in Settings → API tokens with scope logs:write only and a sensible daily byte cap. A drain URL leaking into an ops doc shouldn't grant full account access.
  2. Add the drain on your Heroku app:
heroku drains:add \
  https://api.24observe.com/api/v1/logs/ingest/heroku/obs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
  --app my-heroku-app

Heroku starts forwarding immediately. Inside ~2 seconds you'll see events arrive with source=heroku, service=<your heroku app name>, and the dyno identifier (web.1, worker.2, etc.) in attrs.proc_id.

  • Format: octet-counted RFC 5424 syslog (application/logplex-1) — parsed server-side, no setup on your end.
  • Severity mapping: syslog severity → log level (debug/info/warn/error/fatal). Heroku platform messages (router 5xx, dyno crashes) come in at the right severity automatically.
  • Auth: the PAT in the URL is the only credential — protect it like a password. Revoke via the dashboard if it leaks; a new drain URL replaces it cleanly.
  • Quota: same monthly + per-PAT-daily byte caps as the regular ingest endpoint.

Vercel log drains

Pull every Next.js / SvelteKit / Astro deployment's request logs, function logs, edge logs, and build output into 24observe with one URL. No edge function to deploy, no custom hooks.

  1. Mint a dedicated PAT with scope logs:write and a daily byte cap (same advice as Heroku above).
  2. In the Vercel dashboard → Team Settings → Log Drains → Add Log Drain, choose NDJSON as the delivery format and paste:
https://api.24observe.com/api/v1/logs/ingest/vercel/obs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Pick which projects + sources (static / lambda / edge / build) the drain should cover, hit save, and Vercel starts batching events to you within a minute. Events arrive with source=vercel, service=<your-project-name>, and the following Vercel metadata promoted to attrs so you can facet on it:

  • request_id — Vercel's x-vercel-id per request; correlate function logs with the originating HTTP request.
  • status_code — for runtime/edge logs that wrap a response.
  • vercel_source — one of lambda, edge, static, build, external.
  • deployment_id — pin a log to a specific Vercel deployment.
  • path — the URL path that produced the log line.

Level inference: status ≥ 500 → error, status ≥ 400 → warn, message starting with ERROR / Error / FATAL → error, everything else → info. Override server-side by emitting structured JSON from your function and parsing the level client-side via a saved KQL search.

AWS CloudWatch Logs

Pipe every Lambda function, ECS task, EKS pod, RDS instance, or anything else logging to CloudWatch directly into 24observe via a Kinesis Firehose subscription. No SDK install, no agent on the instance — CloudWatch already has your logs, Firehose forwards them to a URL.

  1. Mint a dedicated PAT with scope logs:write only and a daily byte cap (CloudWatch can be high-volume; protect the blast radius).
  2. Create a Kinesis Data Firehose delivery stream in the AWS console (or via aws firehose create-delivery-stream):
    • Source: Direct PUT
    • Destination: HTTP Endpoint
    • HTTP endpoint URL: https://api.24observe.com/api/v1/logs/ingest/aws-firehose/<your-PAT>
    • Content encoding: GZIP
    • Access key / S3 backup bucket: configure per AWS best practices.
  3. On the CloudWatch log group you want to ship, add a subscription filter:
    aws logs put-subscription-filter \
      --log-group-name "/aws/lambda/my-function" \
      --filter-name "ship-to-24observe" \
      --filter-pattern "" \
      --destination-arn "arn:aws:firehose:us-east-1:<account>:deliverystream/observe24-stream" \
      --role-arn "arn:aws:iam::<account>:role/CloudWatchLogsFirehoseRole"

Events arrive with source=aws-cloudwatch, service=<your log group name>, the log stream in host, plus these CloudWatch attrs:

  • cloudwatch_event_id — CloudWatch's per-event id for dedup.
  • aws_account_id — owning account.
  • log_stream — duplicated from host for KQL search ergonomics.

Level inference: messages prefixed with ERROR / WARN / FATAL / DEBUG (case-insensitive) get the matching level; everything else lands as info. Lambda runtime's standard prefixes work out of the box.

Docker daemon logs

Docker's built-in syslog driver ships every container's stdout/stderr to the 24observe collector — no per-container agent, no log file scraping. The collector's RFC5424 receiver listens on TCP 5414.

Daemon-wide (every container on the host): in /etc/docker/daemon.json:

{
  "log-driver": "syslog",
  "log-opts": {
    "syslog-address": "tcp://collector.local:5414",
    "syslog-format":  "rfc5424",
    "tag":            "{{.Name}}/{{.ImageName}}"
  }
}

Per-container (selective opt-in, no daemon restart):

docker run --log-driver=syslog \
  --log-opt syslog-address=tcp://collector.local:5414 \
  --log-opt syslog-format=rfc5424 \
  --log-opt tag="my-app" \
  my-app:latest

Container name + image flow through as the syslog tag and surface in service on the ingested rows. Restart of the Docker daemon required for the daemon.json variant; the per-container flag takes effect on the next docker run.

systemd-journald

For systemd hosts, the OTel Collector Contrib distribution (which our collector image is built on) ships with a journald receiver — point it at /var/log/journal and every unit's output flows in with severity, unit name, and PID preserved as structured attrs.

Drop this override config alongside the bundled config:

# /etc/otelcol/journald.yaml
receivers:
  journald:
    directory: /var/log/journal
    # Optional: filter to specific systemd units. Omit to ship everything.
    units:
      - nginx
      - my-app
    priority: info

service:
  pipelines:
    logs:
      receivers: [otlp, syslog/rfc5424, syslog/rfc3164, fluentforward, journald]

Then run the collector with the journal mounted and both configs:

# Pre-create the persistent-queue directory with the collector's UID
# so the file_storage extension can write to it (the container runs
# as non-root; the volume inherits root ownership otherwise).
mkdir -p /var/lib/observe24-collector && chown 10001:10001 /var/lib/observe24-collector

docker run -d --name observe24-collector \
  -v /var/log/journal:/var/log/journal:ro \
  -v /etc/machine-id:/etc/machine-id:ro \
  -v /etc/otelcol/journald.yaml:/etc/otelcol-contrib/override.yaml:ro \
  -v /var/lib/observe24-collector:/var/lib/otelcol/file_storage \
  -e OBSERVE24_PAT=obs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
  -p 5414:5414 \
  observe24/collector:latest \
  --config /etc/otelcol-contrib/config.yaml \
  --config /etc/otelcol-contrib/override.yaml

The file_storage volume keeps a persistent on-disk queue so a collector restart doesn't lose buffered events. ~512 MiB is plenty for normal workloads.

The /etc/machine-id mount is required by journald to identify the host. Read-only mounts are sufficient — the collector never writes to the journal.

Vector

See infra/vector/example.toml in the repo for a working example. Key snippet:

[sources.app_logs]
type = "file"
include = ["/var/log/myapp/*.log"]

[transforms.parse_json]
type = "remap"
inputs = ["app_logs"]
source = """
. = parse_json!(string!(.message))
.service = "myapp"
"""

[sinks.observe24]
type = "http"
inputs = ["parse_json"]
uri = "https://api.24observe.com/api/v1/logs/ingest"
encoding.codec = "ndjson"
request.headers.Authorization = "Bearer ${OBSERVE24_PAT}"
request.headers.Content-Type = "application/x-ndjson"
batch.max_events = 500
batch.timeout_secs = 2
curl 'https://api.24observe.com/api/v1/logs/search?q=timeout&service=web&level=error&limit=200' \
  -H 'Authorization: Bearer obs_...'

# Response
{
  "events": [
    { "ts": "2026-05-10T12:00:00.123Z", "level": "error", "service": "web",
      "host": "web-1", "source": "stdout",
      "message": "upstream timeout after 30s",
      "attrs": { "request_id": "abc" } }
  ],
  "nextCursor": "1715342400123:web",
  "tookMs": 47
}

# Paginate by passing nextCursor as ?cursor=...

Structured query (KQL-lite)

For anything beyond a single-string substring, pass ?query=... instead of (or alongside) q. KQL-lite is a small grammar an agent can emit programmatically: field:value, AND / OR / NOT, parentheses for grouping, wildcards, and quoted values for terms with spaces. Whitelisted fields: service, level, host, source, message.

# All 5xx errors in the checkout service over the last 24h
?query=level:error AND service:checkout
   &from=2026-05-24T00:00:00Z

# Error OR warn in any "api"-prefixed service, EXCLUDING the health probe
?query=(level:error OR level:warn) AND service:api* NOT message:"health check"

# Bare terms = substring on message (same as ?q=...)
?query=ECONNREFUSED

# Quote values with spaces, escape with \\ inside a CLI
?query=message:"connection refused"

# Wildcards on field values (LIKE on the column)
?query=host:web-*

# Combine with ?service= / ?level= / ?q= — they all AND together

Parse errors return 400 KQL_PARSE_ERROR with a message indicating the position of the bad token. Unknown fields are rejected at parse time (you can't query org_id or attrs.* — those are intentionally outside the language).

Default time window: 1h back from to (or now) when from is omitted. Hard 5s query timeout. Cursor-paginated to skip the cost of OFFSET.

Error tracking

Stack-trace fingerprinting on log events. Stash a million occurrences of TypeError: x is undefined as one row with a count, first-seen, last-seen, and a sample message — Sentry-lite, bundled, no extra meter.

The scheduler scans the last 6 min of logs every 5 min, extracts a stable signature from each stack trace (normalized header + 1-3 frames, FNV-1a 64-bit hashed), and upserts one row per (org, signature). Languages auto-detected: JS / Node, Python tracebacks, Java / Kotlin / JVM, Go panic, and generic ERROR / FATAL prefixes.

# List open errors
curl 'https://api.24observe.com/api/v1/log-errors?status=open&limit=20' \
  -H 'Authorization: Bearer obs_...'

# Response
[
  {
    "id": 7,
    "errorType": "TypeError",
    "signature": "TypeError | at processUser (user.js) | at handle (server.js)",
    "service": "api",
    "sampleMessage": "TypeError: Cannot read properties of undefined ...",
    "totalCount": 14821,
    "firstSeen": "2026-05-27T08:14:00.000Z",
    "lastSeen":  "2026-05-28T13:55:12.000Z",
    "resolvedAt": null, "resolvedByUserId": null, "ignored": false
  },
  ...
]

# Mark resolved
curl -X PATCH https://api.24observe.com/api/v1/log-errors/7 \
  -H 'Authorization: Bearer obs_...' -H 'Content-Type: application/json' \
  -d '{"resolved": true}'

# Mark known noise (hides from default Open list)
curl -X PATCH https://api.24observe.com/api/v1/log-errors/7 \
  -H 'Authorization: Bearer obs_...' -H 'Content-Type: application/json' \
  -d '{"ignored": true}'
  • Signature stability — line numbers, column numbers, and absolute paths are stripped. Same logical error from different deploys / runs / stack-depths collapses to one row.
  • No auto-resolve — same rule as log alerts. Operators decide when an error is fixed; new occurrences increment the count but do NOT clear a resolved row.
  • Ignore — for known noise (e.g. health-check 4xx, intentional test errors). Hidden from the default Open list, still counted.
  • Filters — ?status=open|resolved|ignored|all, ?service=..., ?limit=... (max 200).
  • Audit log — every resolve / ignore mutation is recorded with actor + IP via the standard audit pipeline.

Log-derived metrics

Save any log query as a chartable, alertable time-series. The metric is just count(matching events per bucket) — computed on demand against the same store the search endpoint queries, so it can't lag behind real log data.

# Create a metric
curl -X POST https://api.24observe.com/api/v1/log-metrics \
  -H 'Authorization: Bearer obs_...' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "5xx in checkout",
    "query": "5xx",
    "service": "checkout",
    "level": "error",
    "bucketSec": 300
  }'

# Fetch the series (last 6h by default, zero-filled buckets)
curl 'https://api.24observe.com/api/v1/log-metrics/42/series?from=2026-05-28T00:00:00Z' \
  -H 'Authorization: Bearer obs_...'

# Response
{
  "metricId": 42,
  "bucketSec": 300,
  "points": [
    { "ts": "2026-05-28T00:00:00.000Z", "value": 12 },
    { "ts": "2026-05-28T00:05:00.000Z", "value": 8 },
    ...
  ],
  "tookMs": 41
}
  • Bucket sizes: 60 / 300 / 900 / 1800 / 3600 seconds. Picked at create time, overridable per /series call.
  • Zero-fill: empty buckets return value: 0 rather than being absent — charts stay flat instead of jagged on quiet windows.
  • Default window: 6h back from to when from is omitted.
  • Cost: CRUD is free; /series is rate-limited at 60/min/IP and 5s hard timeout. The dashboard auto-refreshes each chart every 60s.
  • Alerting: roadmap. Today you can mirror the metric's filter into a log alert (same query/service/level fields). Direct "alert when this metric > N" is a follow-up.

Pattern grouping

Group log messages by template, not exact string. The dashboard's Patterns tab (or GET /api/v1/logs/patterns) returns the top message templates by count after normalizing high-cardinality tokens — so 10,000 lines that differ only in a timestamp and a UUID collapse to one row.

curl 'https://api.24observe.com/api/v1/logs/patterns?service=checkout&limit=20' \
  -H 'Authorization: Bearer obs_...'

# Response (top-K templates by count, descending)
{
  "patterns": [
    {
      "templateHash": "14782...",
      "template": "ERROR DB query took <NUM>ms for user_id=<NUM>",
      "sample": "ERROR DB query took 2451ms for user_id=8821",
      "service": "checkout",
      "level": "error",
      "count": 11423,
      "firstSeen": "2026-05-27 12:00:00.000",
      "lastSeen": "2026-05-28 11:59:31.420"
    },
    ...
  ],
  "tookMs": 1830,
  "scannedRows": 14210
}

Normalized tokens:

  • <TS> — ISO-8601 timestamps
  • <UUID> — RFC 4122 UUIDs
  • <IP> — IPv4 addresses
  • <HEX> — hex blobs (≥ 8 chars, common for ids / signatures)
  • <STR> — single- or double-quoted strings
  • <NUM> — plain integers (applied last so it doesn't eat parts of the above)

Same filter ergonomics as /search: ?service=..., ?level=..., ?q=... (pre-normalization substring match), ?from=... / ?to=.... Default window 1h back. Hard 8s server timeout; rate limit 30/min/IP (heavier per-call than search).

Auto-extracted facets

Every search response now includes a facets object — top-K key/value/count rollups over the visible result page. Top-level columns (level, service, host, source) plus any primitive-typed key in each row's attrs blob become a facet. No schema declaration, no index config — ship JSON, the keys auto-surface.

# Response shape (truncated)
{
  "events": [...],
  "nextCursor": "...",
  "tookMs": 47,
  "facets": {
    "level":   [{"value": "info", "count": 142}, {"value": "error", "count": 11}],
    "service": [{"value": "checkout", "count": 89}, {"value": "api", "count": 64}],
    "user_id": [{"value": "u_a8f", "count": 23}, {"value": "u_b14", "count": 9}, ...],
    "method":  [{"value": "POST", "count": 88}, {"value": "GET", "count": 65}],
    "status":  [{"value": "200", "count": 134}, {"value": "500", "count": 11}]
  }
}

Rules: top 20 keys (ranked by total occurrences), top 10 values per key, values capped at 256 chars. Fields where every row has the same value are suppressed (nothing to facet). Nested objects and arrays in attrs are skipped — only string / number / boolean primitives become chips. High-entropy IDs (trace_id, request_id) are block-listed so they don't pollute the panel.

Click a chip in the dashboard sidebar to filter by that key:value; the clause is appended to your KQL query with AND. Combine multiple chips to drill in.

Live tail (SSE)

# curl
curl -N 'https://api.24observe.com/api/v1/logs/tail?service=web&level=error' \
  -H 'Authorization: Bearer obs_...'

# JS / browser EventSource (no Authorization header support; use ?token=)
const es = new EventSource(`https://api.24observe.com/api/v1/logs/tail?token=${PAT}&service=web`);
es.addEventListener('log', (e) => console.log(JSON.parse(e.data)));
es.addEventListener('timeout', () => es.close());   // 30-min server cap

Real-time: events are pushed the moment they're accepted by /ingest — typically < 100 ms end-to-end. On connect, the last ~5 s of events are replayed from the event store so you see recent context immediately. A : hb SSE comment is sent every 15 s so intermediaries don't idle-close. Hard 30-minute connection cap; clients reconnect.

Tail filter (q / service / level) is applied subscriber-side using the same matching as /search. Requires logs:read scope.

Log-based alerts

Define a rule: "open an incident when N events match (q + service + level) within W seconds." Same alert routing as monitor alerts (email, webhook, Slack, Discord, MS Teams, Telegram). Rules are evaluated on a rolling 1-minute window.

curl -X POST https://api.24observe.com/api/v1/log-alerts \
  -H 'Authorization: Bearer obs_...' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "5xx surge in web service",
    "query": "5xx",
    "service": "web",
    "level": "error",
    "threshold": 10,
    "windowSec": 300,
    "alertSlackUrl": "https://hooks.slack.com/services/..."
  }'

Anomaly (spike vs baseline) alerts

Set kind: "anomaly" to alert when the rate is N× higher than a rolling baseline, instead of N events flat. Use when "normal" varies by service / time-of-day and no single threshold fits — common for "error rate suddenly spiked" rules where the magic number changes as traffic grows.

curl -X POST https://api.24observe.com/api/v1/log-alerts \
  -H 'Authorization: Bearer obs_...' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Checkout error spike",
    "kind": "anomaly",
    "service": "checkout",
    "level": "error",
    "windowSec": 300,
    "ratioThreshold": 3.0,
    "baselineHours": 168,
    "minBaselineEvents": 10,
    "alertSlackUrl": "https://hooks.slack.com/services/..."
  }'
  • ratioThreshold — fire when current count ≥ N × baseline avg. Default 3.0.
  • baselineHours — rolling lookback for the baseline. Default 168 (7 days — smooths weekday/weekend traffic differences).
  • minBaselineEvents — skip evaluation when baseline avg per bucket is below this floor. Prevents "3 vs 1 events" tripping a 3× rule on a tiny baseline. Default 10.
  • No ML, no model tuning. One ratio comparison per tick: count(now-window) / avg(count per same-sized bucket over baselineHours, excluding the current window) ≥ ratioThreshold.
  • Webhook payload extras when log_alert_kind: "anomaly": anomaly_ratio, ratio_threshold, baseline_hours.
  • Latches and incident lifecycle are identical to threshold alerts — the only thing that differs is the fire condition.
  • Error code LOG_ALERT_KIND_MISMATCH — 400 response when create/update mixes shapes: anomaly-only fields (ratioThreshold / baselineHours / minBaselineEvents) sent with kind: "threshold", or vice versa. Fix: pick a kind and send only its required fields.

Honest scope statements

  • v1 ships: ingest, search, KQL-lite, live tail, log alerts, dashboard tab, auto-facet extraction (primitive attrs keys), Vector example.
  • Not in v1: field extraction from unstructured text (regex/grok pipelines), R2 archival, saved searches, dashboards/visualisations, multi-source onboarding wizard, log pattern grouping.
  • Capacity: our v1 architecture handles ~5k events/sec comfortably; load-tested above that is unverified for v1. Talk to us if you're aiming for that.
  • Auto-resolve: log alerts open incidents but do not auto-resolve — there's no "recovery" signal in absence of error logs. Operators close manually.

Ready to build with 24Observe?

Explore live telemetry, zero-friction integration, and automated SOC investigation.

+ Get Free Trial / Demo