Reference
Limits and behavior
What an acknowledgement means, how long logs, spans and metrics live, how to change that, how much disk they need, and the knobs a self-hosted install has.
The honest list of what Bilis promises and what it does not.
An acknowledgement is not durability
Rows are inserted with ClickHouse's asynchronous insert path:
async_insert=1
wait_for_async_insert=0
The server acknowledges as soon as the batch is in its insert buffer, and flushes to disk shortly after. So:
202/200means queued, not stored. A ClickHouse crash in the window between the acknowledgement and the flush loses that buffer.- The trade is throughput: small frequent inserts are what a log pipeline produces, and they are exactly what ClickHouse handles badly without this.
- Retries are safe.
non_replicated_deduplication_window = 1000makes an identical re-sent batch idempotent, so a collector retrying after a timeout does not double your log lines.
If a line matters more than that, keep a local copy too.
Retention
Logs are dropped after 30 days by default, by a table TTL on the event
timestamp. Expiry is a partition drop rather than a rewrite
(ttl_only_drop_parts = 1 with daily partitions), so it costs almost nothing.
Spans are dropped after 30 days on the same terms. Trace summaries — one row per trace carrying its root operation, duration, span count and error count — are kept for 90 days, so a trace outlives its own detail: after 30 days it still appears in the trace list, but its waterfall can no longer be drawn. The interface says so rather than showing an empty chart.
Metric data points are dropped after 30 days too, on the same terms as logs: a TTL on the point's time, daily partitions, whole-partition drops.
Retention is a property of the ClickHouse tables, not a per-project setting.
Changing it means altering the TTL on otel_logs, otel_traces or
trace_summary — see Changing retention below.
Sizing
Roughly 80–120 bytes per log line on disk after ZSTD compression. The formula worth remembering:
bytes/day ≈ lines/second × 86400 × 100
Which gives, at 30-day retention:
| Lines/second | Per day | 30 days |
|---|---|---|
| 10 | ~86 MB | ~2.6 GB |
| 100 | ~864 MB | ~26 GB |
| 1,000 | ~8.6 GB | ~260 GB |
| 10,000 | ~86 GB | ~2.6 TB |
Spans cost roughly 70 bytes each after compression, and an instrumented application emits far more spans than log lines — ten times as many is a reasonable planning assumption, so traces tend to dominate the disk budget. At 30-day span retention:
| Spans/second | Per day | 30 days |
|---|---|---|
| 100 | ~600 MB | ~18 GB |
| 1,000 | ~6 GB | ~180 GB |
| 10,000 | ~60 GB | ~1.8 TB |
The 90-day trace_summary is one short row per trace and does not move these
numbers. If spans are the problem, in order of what to try first: sample at the
Collector, shorten the span TTL, or drop span events.
Volume is the variable you actually control, so measure your own rate rather than trusting a tier label.
Changing retention
Retention is a TTL on each table, and a TTL is changed with an online ALTER
against ClickHouse directly. Set materialize_ttl_after_modify = 0 first: with
it, the ALTER is a metadata change and existing parts pick up the new TTL as
they are next merged; without it, ClickHouse rewrites every existing part
immediately, which on a large table is hours of I/O for no benefit.
SET
materialize_ttl_after_modify = 0;
ALTER TABLE otel_logs MODIFY TTL toDateTime(Timestamp) + toIntervalDay(30);
ALTER TABLE otel_traces MODIFY TTL toDateTime(Timestamp) + toIntervalDay(30);
ALTER TABLE trace_summary MODIFY TTL toDateTime(Start) + toIntervalDay(90);
The three values shown are the defaults; change the day counts. Keep the
expression as it is — otel_logs and otel_traces expire by the event
timestamp, trace_summary by the trace's start.
Two things worth knowing:
otel_logsandotel_tracesare partitioned by day and expire withttl_only_drop_parts = 1, so shortening a window drops whole partitions at the next TTL merge and costs almost nothing.trace_summaryhas no partitions — it cannot, because a trace's start is an aggregate that a merge can move across midnight — so its TTL is a row-level delete-merge. It is one row per trace, so that is affordable, but it is not free the way the other two are.php artisan clickhouse:migratecreates tables it does not find and never rewrites one it does, so anALTERyou make survives every deploy. A fresh install, though, gets the defaults fromdatabase/clickhouse/*.sql; keep the statement in your own runbook.
Note: a full disk turns ClickHouse read-only and takes the whole product down with it. Alert on disk at 70% — that will prevent more downtime than any amount of replication.
Rate limits
Ingest is throttled per API key, so one runaway client cannot starve the others:
| Requests | Limit | Counted per |
|---|---|---|
With a valid Authorization header |
1,200 requests/minute | API key |
| Without one | 60 requests/minute | client IP |
Two things worth being precise about:
- The limit counts HTTP requests, not log lines. A batch of 500 lines in one POST is one request. If you are anywhere near the limit, the fix is almost always batching — every OTLP exporter and the Bilis shipper batch by default, so hitting it usually means something is misconfigured to send one line per request.
- A rejection is a
429withRetry-After, never silent. OTLP exporters treat it as retryable and back off, so a brief burst over the limit delays delivery rather than losing it. A client that ignoresRetry-Afterand keeps hammering will lose whatever it fails to retry — that is the client's bug, not a Bilis behavior.
Self-hosted installs can change both knobs, 0 disables a limit entirely:
BILIS_INGEST_RATE_LIMIT=1200
BILIS_INGEST_RATE_LIMIT_UNAUTHENTICATED=60
The rate limit shapes request rate; it does not cap stored volume. A
well-batched client can fill the disk without ever seeing a 429 — see the
sizing table above, and the quota note below.
Volume control belongs to the sender
Bilis stores what arrives. There is no server-side sampling, no ingest-side downsampling, and none is planned: dropping data you deliberately sent would be a surprising way to protect a disk you own.
That makes volume control your job, and the right place for it is before the log leaves your infrastructure — in the SDK or collector, where the decision is cheap and nothing is transmitted just to be thrown away:
- Don't ship what you won't read. Raising the minimum severity for shipping (while keeping debug in local files) is the single highest-leverage knob.
- Filter in the collector. The OpenTelemetry Collector's
filterprocessor drops records by severity, body match or attribute before export. - Sample the repetitive stuff. Health-check hits and other high-frequency, low-information lines can be probabilistically sampled at the collector; keep errors at 100%.
If a collector-side rule sampled your logs, the counts you see in Bilis are counts of what was shipped — Bilis does not extrapolate.
- One node. Plain
MergeTree, no replication. Replication needs Keeper, and a replicated table with unreachable Keeper goes read-only — on a single box that adds a failure mode without adding redundancy. - Replication is not a backup either. Back the table up to object storage
from day one; it is the only thing that protects against a bad
ALTER. - Full-text search is token-based over the log body, backed by a text index. It matches whole tokens, case-insensitively. A search term that is not a single token falls back to a slower substring "contains" match, which still reads the index but prunes less.
- No enforced per-project ingest quota. The per-key rate limit shapes request rate, but nothing caps how much a well-batched project can store — a noisy project can still fill the disk. On the hosted service the daily event and metric data point allowances below are shown and warned about and never enforced: no record is ever refused or dropped for being over either. On a self-hosted install there is no allowance at all.
The hosted Free plan
These apply to bilis.app only. An instance you run yourself has no plan and no limits — nothing in this section is read by it.
| Limit | Free |
|---|---|
| Projects per team | 3 |
| Members per team (owner included) | 5 |
| Events per day (log records + spans, per team, from 00:00 UTC) | 100,000 |
| Metric data points per day (per team, from 00:00 UTC) | 1,000,000 |
| Retention for logs, spans and metrics | 30 days |
| Ingest requests per minute, per API key | 1,200 |
Every one of them is soft except the last. Going over the projects,
members, events or metric data points allowance drops nothing, rejects nothing
and blocks nothing: the dashboard's "Plan & usage" card shows where the team
stands, starts saying so at 80%, and someone gets in touch. The per-minute request limit is the one real
ceiling, and it is a retryable 429 with Retry-After rather than a rejection of
the payload — the "ingest never returns 400" rule above still holds.
A metric data point is one row: one gauge reading, one counter value, or one histogram point however many buckets it carries. Points are counted on their own meter, never as events — a service exporting every ten seconds writes thousands a day without anything happening, and that should not spend a log budget.
More room than that is a conversation rather than a checkout: there is no self-serve billing. See pricing, or write to us at /contact?topic=upgrade.
Self-hosting
Bilis talks to ClickHouse over its HTTP interface, so there is no driver or PHP extension to install:
ClickHouse 26.2 or newer is required. The log body index is a text index,
which older servers do not have. Check with SELECT version() before upgrading
Bilis; on an older server, body search would silently stop using its index.
CLICKHOUSE_SCHEME=http
CLICKHOUSE_HOST=127.0.0.1
CLICKHOUSE_PORT=8123
CLICKHOUSE_DATABASE=bilis
CLICKHOUSE_USERNAME=default
CLICKHOUSE_PASSWORD=
CLICKHOUSE_TIMEOUT=10
CLICKHOUSE_CONNECT_TIMEOUT=3
Timeouts are deliberately short so an overloaded cluster fails fast and ingest
returns a retryable 503 instead of holding a PHP worker.
Create or update the tables with the idempotent migration command:
php artisan clickhouse:migrate
It applies database/clickhouse/*.sql in filename order. That directory, and
database/clickhouse/SCHEMA.md alongside it, are the source of truth for the
otel_logs, otel_traces and trace_summary tables — the column names and
types of the first two are the upstream OpenTelemetry exporter's, which is what
keeps the storage readable by tools other than Bilis.
Upgrading an install that predates the text body index
If you have been running Bilis since before the ClickHouse 26.2 floor, your
otel_logs still carries the old token bloom filter index. clickhouse:migrate
swaps the index definition for you — instantly, and without interrupting search.
Rows written before the swap keep answering body searches by full scan until you
rebuild their index files:
php artisan clickhouse:materialize-index
Run it once, deliberately, when the box has I/O to spare. It is deliberately not
part of clickhouse:migrate, which runs on every container start.