Self-Stored Logs
Status: design — not implemented.
BanyanDB self-stores its metrics into the _monitoring group but has no equivalent for its own
logs: pkg/logger writes to a single io.Writer fixed at logger.Init time, so logs leave the
process on stderr and are gone when a pod recycles. This document specifies a second, optional
destination for logs — native self-storage into a _monitoring_log stream group — alongside
normal logging, which is never disabled.
It follows the native metrics path where that path fits, and departs from it where logs are events rather than sampled state: a missed metric flush costs nothing because the next one carries the current value, while a dropped log line is data loss. That single difference is the source of most departures here.
1. Feature introduction and scope
BanyanDB already self-stores its own telemetry, but only half of it. Metrics have a native path into the database; logs do not.
| Metrics today | Logs today | |
|---|---|---|
| Destination | _monitoring group, one Measure per metric |
one io.Writer, fixed at logger.Init time |
| Turned on by | --observability-modes=native |
always on, no choice of destination |
| Queryable, retained | yes, with a TTL | no, stderr is the log driver’s problem |
During an incident the metrics are in the database and the logs are not. When a pod recycles, they are gone.
This feature adds a second, optional destination for logs: native self-storage into a new _monitoring_log stream group, behind --logging-native-enabled (bool, default false). Normal logging keeps its current behaviour — one zerolog writer, stderr today, though zerolog also supports file writers — and is never disabled.
| Roles | Rationale | |
|---|---|---|
| In scope | standalone, data, liaison | reach stream storage directly |
| In scope | lifecycle, backup | colocated producers, using the receiver on their host’s data node |
| In scope | FODC — the on-demand diagnostics collector, agents plus a proxy | a generic receiver/forwarder serves it, so BanyanDB imports nothing from FODC |
| Out of scope | restore, migration | they run when the data tier is unavailable |
The sections that follow go roles, approach, configuration, failure modes.
2. Role → function → log destination
One binary, several roles. One property decides the destination: whether the process owns a storage engine or must reach one.
| Role | Function | Log destination |
|---|---|---|
standalone |
full server | own storage, in-process (queue.Local()) — as native metrics already do |
data |
shards, writes, queries | own storage, in-process (TopicStreamWrite) |
liaison |
front door: validate, route, batch | one hop to a data node over its tier-2 client, the pool spelled tire2Client in the code |
backup, lifecycle, FODC agent beside data |
snapshot, migration, diagnostics | colocated data node’s cluster server, 127.0.0.1:17912 |
| FODC proxy, FODC agent beside a liaison | diagnostics, no colocated data node | liaison gRPC port (--grpc-port, default 17912) |
restore, migration |
offline tools | normal logging only |
A liaison has local disk: it runs a two-tier write queue whose normal
StreamService/Writeroute ends in part files. What it lacks is the storage engine, so it can hold a batch in flight but can never be where logs are read back from.
Three shapes: in-process; the liaison selecting a data node by shard; and every other producer publishing into the generic receiver, subscribed on data nodes’ cluster server and on the liaison.
Restore and migration use normal logging only by construction, not as a gap to close later: a tool that runs while the database is down cannot log into it. The native flags are registered only by the server, backup, lifecycle and FODC binaries.
3. Proposed approach
3.1 The writer seam
One event, two level-gated writers under zerolog’s MultiLevelWriter. The zerolog level becomes an admission floor — the more verbose of the enabled sinks — and each sink then applies its own threshold, letting native run at info while normal logging stays at error.
flowchart LR
E["log event"] --> F{"admission floor: min over enabled sinks"}
F -->|below| X["dropped"]
F -->|at or above| M["MultiLevelWriter"]
M --> C["gate: --logging-level"] --> S["stderr or file"]
M --> N["gate: --logging-native-level"] --> R["in-memory ring"]
R --> K["consumer"] --> P["shard and node selection"] --> D["stream log"]
Worked example, normal logging at error and native at info, so the floor is info.
| event | admitted | normal sink | native sink |
|---|---|---|---|
| debug | no | – | – |
| info / warn | yes | dropped by its own gate | stored |
| error | yes | printed | stored |
Per-module thresholds resolve once when a logger is built, with normal level, native level and exclusion cached on it, never by parsing JSON per line. Matching is by prefix, since an override truncates the module name it stamps: --logging-modules=liaison-grpc stamps LIAISON-GRPC.STREAM-T1 as LIAISON-GRPC.
3.2 Buffer and transport
| property | value |
|---|---|
| unit | one stream WriteRequest per event, drawn from and returned to a pool. Requests and their nested allocations are reused; a request returns to the pool only after its publish completes or it is dropped, with retained references cleared and oversized buffers discarded rather than kept |
| payload | values carried in tags. The data:fields tag holds only keys with no tag of their own — no duplicated body |
| bounds | --logging-native-max-bytes 32mb, --logging-native-max-event-bytes 64kb (proposed defaults, section 4) |
| triggers | --logging-native-flush-interval 1s and --logging-native-flush-size 100 |
| durability | none — no queue files, no WAL, no disk fallback |
The consumer is a dedicated goroutine selecting over the closer, the flush ticker and the ring, so both triggers fire.
When native is enabled the ring is live from logger initialisation; service start only begins draining it, so no startup line is stranded in a buffer nobody drains.
3.3 Routing and shard selection
Shard IDs are deterministic and real, computed as the normal write path computes them — entity locator from the fetched schema, then hash modulo shard count — not the hardcoded shard 0 of native metrics. The default is --logging-native-shard-num=2; at 1 every event keys to one shard and the whole cluster funnels onto a single data node. The count can be raised later through the group schema, though lowering it silently hides shards already written.
Decided: keep the selection, replace the transport. Reusing the liaison’s existing shard-based routing and never touching disk cannot both hold, because that route terminates in the disk-backed write queue and writes part files. The liaison therefore keeps the shard and node selection and publishes directly to the selected data node over its tier-2 client, bypassing the write queue entirely. The new native log topic serves the receiver’s foreign producers, not the liaison’s own route. Two facts make this modest rather than novel: nothing enforces shard ownership on ingest, and
lifecyclealready publishes straight into a colocated data node in production today.
The receiver is deliberately thin.
| Rule | Behaviour |
|---|---|
| Validation | InvalidArgument on a nil or non-millisecond timestamp, empty element_id or node_id, oversized body, out-of-enum level, oversized batch |
| Back-pressure | ResourceExhausted when its own ring is over budget; never blocks |
| Filtering | no local level, no module override — what arrives is what is stored |
| Echo | received events never enter its own logging path |
| Identity | element_id and identity tags pass through unchanged; identity must live in the element |
--logging-native-enabled governs only a process’s own collection: a node with it false still accepts, routes and persists forwarded writes.
3.4 Schema
| object | value |
|---|---|
| group | _monitoring_log, catalog STREAM, TTL --logging-native-ttl-days (7), Replicas 0 — matching _monitoring |
| stream | log, one shared by every role |
| entity | node_id, level |
| searchable tags | node_id, node_type, module, level, grpc_address, http_address, message, log_id |
| data tag | fields, binary — the leftover keys only, omitted when there are none |
| index rules | none in v1 |
level is an entity tag; entity tags accept only equality and set membership, so selecting several levels uses IN rather than a negation. module stays an ordinary tag — module strings splice in group, measure and task names, so entity membership would give unbounded series cardinality. Nothing stores the original line verbatim: known keys become tags, and only the remainder lands in fields.
4. Parameters and configuration
Two independent namespaces; neither inherits from the other.
| namespace | flags | environment | notes |
|---|---|---|---|
| normal logging | --logging-* |
BYDB_LOGGING_* |
unchanged; typically error in production |
| native stream | --logging-native-* |
BYDB_LOGGING_NATIVE_* |
default level info, always JSON |
Every dashed flag binds to BYDB_<UPPER_SNAKE>, and the env value applies only when the flag was not set on the command line, so an explicit flag wins.
| flag | default | meaning |
|---|---|---|
--logging-native-enabled |
false |
collect this process’s own logs natively |
--logging-native-level |
info |
root level of the native sink |
--logging-native-modules / --logging-native-levels |
nil |
per-module overrides, prefix-matched, length-checked |
--logging-native-exclude-modules |
built-in prefix set | modules never sent natively, breaking the write-path feedback loop; replaces, not appends |
--logging-native-flush-interval |
1s |
longest a buffered event waits |
--logging-native-flush-size |
100 |
events that trigger a batch |
--logging-native-write-timeout |
5s |
per-batch publish timeout |
--logging-native-drain-timeout |
5s |
bound on the final drain at shutdown |
--logging-native-max-bytes * |
32mb |
configured ring cap |
--logging-native-max-event-bytes * |
64kb |
oversize events dropped whole |
--logging-native-memory-fraction * |
0.02 |
fraction of available memory in the adaptive budget |
--logging-native-memory-reserve * |
64mb |
reserve subtracted before the fraction |
--logging-native-shard-num |
2 |
shards of _monitoring_log; raisable later through the group schema |
--logging-native-ttl-days |
7 |
retention |
--logging-native-receiver-enabled |
false |
accept forwarded writes, wherever hosted; independent of collection |
* Names and defaults for the four starred flags are proposals, not settled. Level semantics are in section 3.1; the four existing --logging-* flags keep their names and defaults.
With
--logging-native-enabled=false— the default — behaviour is exactly as today: the ring is never allocated, no schema and no counters are created, and the cost is one atomic pointer load per event.
5. Failure modes
event -> producer -> buffer(ring) -> consumer -> schema -> selection -> publish -> destination
|
GracefulStop: stop admission, then bounded drain ----------------+
Every stage that can fail has a row, carrying one behaviour and one reason from the closed nine-value reason set on native_dropped_total.
| Stage | Situation | Behaviour | Counter reason |
|---|---|---|---|
| producer | event over --logging-native-max-event-bytes |
dropped whole, never truncated — a truncated JSON body is unparseable | oversize_event |
| buffer | ring over budget | drop the newest event, the writer still returning (len(p), nil); admission never waits for capacity and queued events are never evicted, so a burst keeps its head |
buffer_full / memory_pressure |
| consumer | stalled in a slow publish | the ring absorbs it; batches capped at 25% of budget, one in flight, so a stall cannot pin admission | – gauge native_buffer_bytes{state=in_flight} |
| schema | create fails for anything but AlreadyExists, or the group is dropped at runtime |
retry next flush, batch in hand dropped | schema_unavailable |
| schema | existing log stream is incompatible |
refuse, one error through normal logging, sink stays disabled | schema_incompatible |
| selection | Locate errors or returns an empty node ID |
count and return; never publish to an empty node | locate_failed |
| local publish | listener unhealthy | the bus skips it and drops the payload while returning an error, so any error means full batch loss | publish_failed |
| remote publish | error, breaker open, admission timeout, auth rejection | batch dropped, never re-queued; classified from the per-node error map from Close |
publish_failed |
| destination | local topic not yet subscribed | retain, N retries, then drop | destination_unready |
| read path | a data node has not yet caught up on the schema | its query processor returns stream-not-exist and the distributed planner discards the whole response, blanking the query. Pre-existing for any group created while the cluster is running, user groups included — not introduced here. Mitigation is a one-site change in the planner, out of scope for this feature | – |
| shutdown | buffer still full at the deadline | remainder dropped once --logging-native-drain-timeout expires; teardown never blocked |
shutdown_deadline |
No row blocks, retries forever, or falls back to disk. Loss is bounded and always counted.
5.1 Memory budget
budget = min(max-bytes, memory-fraction * max(0, availableBytes - memory-reserve))
availableBytes < 0 -> budget = max-bytes # unknown is not unlimited
All three terms are native flags whose defaults are proposals. The budget is recomputed every 5s, counting queued and in-flight bytes alike. The sink reads AvailableBytes() only — never the blocking AcquireResource on the logging path, never waiting, never reclaiming bytes in flight; over budget it drops and counts. Where no protector is registered the cap alone applies; the adaptive term binds only where one is.
| Process | Adaptive term | Why |
|---|---|---|
| standalone, data | binds | protector registered as a run unit |
| everything else | cap only | availability reports -1, or no protector is registered. We leave the liaison as it is today rather than registering one, since that would arm load shedding that has never run |
That gap is pre-existing. The liaison’s protector is deliberately left unregistered here, because registering it would also arm liaison load shedding that has never run in production, which does not belong in a logging change. The sink needs no change if it is registered later — the same formula simply begins to adapt — so the cap is sized to be safe on its own.
Normal logging is never degraded by the sink. Native is the more verbose sink, so at normal
errorand nativeinfoa dropped nativeinfoevent has no normal copy anywhere — the drop counters are the only record it existed.
With
--observability-modes=nativealone those counters travel the same transport as the logs, so the same outage loses both. The sink therefore also emits a periodic drop summary straight to stderr, and we recommend keepingprometheusenabled alongside.