You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Proposal: give logs the same two-destination model metrics already have — console and self-storage — reusing the native-metrics machinery wherever it already solves the problem.
Non-goals: replacing console output; ingesting non-BanyanDB logs; changing the module tree, level filtering, or existing flags.
liaison and data are cluster-scoped peers. Everything else is a per-node companion glued to one node by 127.0.0.1 or a shared volume.
3. Role → function → log destination
Self-storage needs a process owning the storage engine — the component holding queryable groups on disk. Only data and standalone do. Everything else must reach one, and the hop count differs.
Role
Function
Storage engine
Log destination
standalone
all-in-one
✅
in-process — queue.Local(), own shard
data
stores & serves shards
✅
in-process — queue.Local(), own shard
liaison
routes writes/queries
❌
two tiers — liaison wqueue → part-sync → data node (ref)
lifecycle
hot→warm→cold migration
❌
one hop — pub → co-located 127.0.0.1:17912
backup
snapshots → S3/GCS/Azure
❌
one hop — pub → --grpc-addr; needs a schema-bootstrap decision
restore
remote backup → local dirs
❌
console only — init container, runs before its data node starts
migration
re-grid measure/stream data
❌
console only — runs with the data tier at replicas=0
Two corrections to a common mental model:
A liaison is not diskless. It has a write queue at --measure-data-path / --stream-data-path (banyand/measure/wqueue.go) where it buffers parts before syncing them out. It lacks the storage engine, so it can hold a log batch in flight but can never be where logs are read back from.
lifecycle and backup never go through a liaison.pub.NewWithoutMetadata(nil) defaults to ROLE_DATA, so they publish straight to their co-located data node — a strictly shorter path than the liaison's.
restore and migration are not gaps to fill later. A tool that runs while the database is down cannot log into that database — and for migration, a self-storing sink would violate its own precondition that nothing else writes to the target paths.
4. Proposed approach
4.1 The seam — one event, two writers
zerolog always encodes an event to JSON internally, and ConsoleWriter is itself an io.Writer that re-formats that JSON. So MultiLevelWriter hands the sink the fully-encoded line — module, level, timestamp, message, all structured fields — one source, one format, one set of parameters.
flowchart LR
A["call site<br/>logger.GetLogger(measure).Info()<br/>.Str(group, g).Msg(flushed part)"] --> B["zerolog encodes ONE JSON event"]
B --> CW["console writer<br/>stderr · ALWAYS ON"]
B --> SW["switchableWriter<br/>atomic.Pointer"]
SW --> S2["logSink — ONE ring buffer<br/>alive from Init()"]
SW --> S3["io.Discard — after GracefulStop"]
Loading
❌ zerolog.Hook rejected — hooks see level + message but not the accumulated structured fields.
The writer contract
Three constraints come from zerolog's own implementation (v1.34.0), not from BanyanDB:
zerolog internal
Constraint on the sink
Event.write() calls putEvent(e), returning e.buf to a sync.Pool
p is reused. Buffering it without append([]byte(nil), p...) yields a slice the next log line overwrites.
Always return (len(p), nil). A dropped line must still report a full write; drops surface via the counter, never the return value.
MultiLevelWriter type-switches on zerolog.LevelWriter before wrapping in LevelWriterAdapter
Implement WriteLevel(l zerolog.Level, p []byte) and zerolog hands over the level as an enum — so --logging-native-level filtering is an int compare with no JSON parsing on the hot path, and the level entity tag needs no extraction.
atomic.Pointer rather than a mutex: WriteLevel runs on every log line from every goroutine, while the pointer is swapped once per process (at GracefulStop). A lock-free load is the right trade at that ratio.
4.2 Lifecycle — deferred activation
Same deferral pendingMeasures / native.InitSchema already use for metric schemas — but applied to the consumer, not the buffer.
Init() install switchableWriter → logSink (buffer live, NO consumer, no I/O)
PreRun() register drop counters (no metadata)
Serve() 1. create group + stream schema (idempotent)
2. START the consumer goroutine (§4.3)
GracefulStop closer.CloseNotify() → consumer drains once and exits (bounded)
swap → io.Discard · close publisher
One buffer, two phases. The buffer is allocated at Init() and never replaced; "activation" only starts draining it. There is no second buffer and no hand-off, so a goroutine that loaded the writer just before activation cannot strand its line in an abandoned buffer. Producers see one unchanging target for the whole process lifetime.
4.3 Write workflow
The consumer is a dedicated goroutine, following accesslog.startConsumer — not a timestamp.Scheduler job as FlushMetrics uses. A scheduler job is a periodic callback: it fires on the tick and can do nothing between ticks, so it supports a time trigger and nothing else. The size trigger needs something that observes every push, which only a goroutine selecting on the buffer can do.
flowchart LR
R["buffer<br/>bounded · drop on full"] --> T{"consumer goroutine<br/>flush trigger"}
T -- "interval 5s" --> B["build InternalWriteRequest"]
T -- "size ≥ 1024" --> B
T -- "CloseNotify" --> B
B --> N{"nodeSelector"}
N -- "nil (data/standalone)" --> LOC["queue.Local() → own shard"]
N -- "set (liaison/lifecycle)" --> LO["Locate()"]
LO -- ok --> PUB["pub → TopicStreamWrite → data node"]
LO -- "fail" --> DROP["count no_node, drop<br/>(do NOT publish empty nodeID)"]
Loading
4.4 Schema
Derived field by field from the native-metrics schema in pkg/meter/native/provider.go. Each row names the proto field, so the diff against the existing implementation is explicit.
Running example — this line, emitted on node data-hot-0:
{"level":"warn","module":"MEASURE","group":"sw_metric","time":"2026-09-11T10:23:45.123Z","message":"flush took longer than expected"}
Group — common.v1.Group
Schema field
Metrics (_monitoring)
Logs (_monitoring_log)
Example value
metadata.name
_monitoring
_monitoring_log
"_monitoring_log"
catalog
CATALOG_MEASURE
CATALOG_STREAM
Catalog_CATALOG_STREAM (= 1)
resource_opts.shard_num
1
1 (configurable)
1
resource_opts.segment_interval
{UNIT_DAY, 1}
{UNIT_DAY, 1}
&IntervalRule{Unit: UNIT_DAY, Num: 1}
resource_opts.ttl
{UNIT_DAY, 1}
{UNIT_DAY, 7} (configurable)
&IntervalRule{Unit: UNIT_DAY, Num: 7}
Resource — metrics use database.v1.Measure, logs use database.v1.Stream
Not a rename.Measure and Stream are two distinct proto messages that coexist; nothing is renamed or modified. Logs simply instantiate a different existing type. "Resource" is BanyanDB's own umbrella term for what a group holds — docs/concept/data-model.md: "A group's catalog fixes which one kind of resource it holds (MEASURE, STREAM, TRACE, or PROPERTY)." There is no Resource type in code.
tag_families:
searchable: node_type, node_id, module, level, grpc_address, http_address, message
data: body TAG_TYPE_DATA_BINARY ← the complete original JSON line
grpc_address / http_address — searchable, not in the entity. Not cardinality (both follow from node_id) but address churn: a restarted pod keeps its node_id and gets a new IP, which would open a new series and fragment that node's history.
body holds the whole original line — no field lost to schema drift, byte-identical to the console.
element_id = <node_id>-<process_epoch>-<seq>. timestamp = the event's time, not flush time, so lines buffered before activation land correctly on the time axis.
4.5 Two things that differ from metrics
Metrics
Logs
Semantics
sampled state — a missed flush costs nothing, next flush carries the current value
events — a dropped line is gone. Needs bounded ring + explicit drop-oldest + drop counters
Recursion
gauge.Set() doesn't log
the write path logs.pub, sub, cluster-node-registry-* all call logger.GetLogger(...) → a naive sink feeds itself
Recursion guard, three layers: module denylist for write-path modules (primary) → sink never logs through pkg/logger, only rate-limited stderr → bounded ring caps amplification (backstop).
⚠️ The denylist is load-bearing for the liaison specifically: its two-tier path traverses the write queue and part-sync, both of which log.
Invariant across every failure mode: console output is never degraded by the sink. Self-storage is best-effort; the console is not.
4.6 Implementation phases
See this comment — delivery order, and why each phase adds exactly one new failure domain.
5. Parameters and configuration
Derived from the two existing surfaces — prefix from console logging, vocabulary from metrics:
metric — provider.go hardcodes ResourceOpts.ShardNum = 1. Load-bearing for logs, given the single-node funnel
Deliberately not copied from metrics:--observability-listener-addr (logs are push-only — no pull endpoint to scrape) and --observability-metrics-interval (that is the Prometheus collection tick; logs have no collection phase).
Unchanged: the four existing --logging-env / -level / -modules / -levels flags. They apply upstream of both writers; altering them is an explicit non-goal. Each new flag gets the standard BYDB_* env binding, as logger.RegisterFlags already provides. restore / migration accept --logging-modes but reject native at Validate().
6. Reading logs back
No new query surface — _monitoring_log is an ordinary stream group:
Each row is one stage of the write path, in path order. Derived by walking the pipeline and asking, at each stage, "what can fail here, and what happens when it does?"
producer ──► BUFFER ──► consumer ──► schema ──► Locate ──► publish ──► data node
│ │ │ │ │ │ │
│ │ │ │ │ │ └─ slow
│ │ │ │ │ └─ error / timeout
│ │ │ │ └─ no node available
│ │ │ └─ CreateGroup / CreateStream fails
│ │ └─ not started yet (pre-Serve)
│ └─ full
└─ never blocks ← the invariant, not a failure
Every stage that can fail gets exactly one row, one behaviour, one counter label. The review test is therefore not "are these cases interesting" but "does every arrow in that diagram have a row" — a stage with no row is a stage whose failure is unhandled.
Stage
Situation
Behaviour
producer
always
never blocks the caller; console unaffected — the invariant, not a failure
buffer
consumer goroutine not yet started (pre-Serve)
accumulates; drained once Serve() starts the consumer
buffer
fills before the consumer starts
drop, count not_ready; console still has everything
buffer
full during a burst
drop, count buffer_full
schema
CreateGroup / CreateStream fails
stderr once; the ring keeps buffering (eventually dropping as not_ready); retry on the flush tick
schema
group dropped at runtime (OnDelete)
writes begin failing downstream → surfaces as publish_error. After N consecutive publish errors, re-run the idempotent InitSchema rather than failing forever
schema
exists but incompatible (hand-edited _monitoring_log)
CreateStream returns AlreadyExists, so the sink proceeds and writes fail on tag mismatch. Count schema_mismatch, log expected-vs-actual once to stderr. Never auto-migrate
Locate
no data node (liaison partition / migration window)
count no_node, drop — don't publish an empty nodeID
publish
error / timeout
count publish_error, drop that batch, continue
publish
data node disk full
also publish_error — indistinguishable from a network fault at this layer. Diagnose via the existing banyandb_system_disk metric
data node
slow
drops (whichever bound fills first); never backpressure into the caller
shutdown
GracefulStop with a full buffer
closer.CloseNotify() makes the consumer goroutine drain once and exit; that drain is time-bounded, and whatever cannot ship is counted shutdown_drop so the loss is not silent
sink itself
any internal error
direct rate-limited stderr, never through pkg/logger
Drops are exported as banyandb_logging_dropped_total{reason} through the existing metrics factory — loss is visible in the system that is reliable. Labels: not_ready, buffer_full, schema_mismatch, no_node, publish_error, shutdown_drop.
8. References
See this comment — existing log implementation, the native-metrics template, per-role wiring, and the liaison's two-tier write path.
Related issues
None found.
Are you willing to submit a pull request to implement this on your own?
Yes I am willing to submit a pull request on my own!
Search before asking
Description
1. Feature introduction
BanyanDB self-stores its metrics, but not its logs.
meter.Provider(pluggable)io.Writerfactory→ every providermetricService_monitoringgroupThe entire log output story is one line, fixed at
logger.Init()time:Proposal: give logs the same two-destination model metrics already have — console and self-storage — reusing the native-metrics machinery wherever it already solves the problem.
Non-goals: replacing console output; ingesting non-BanyanDB logs; changing the module tree, level filtering, or existing flags.
2. Deployment architecture
flowchart TB C["clients (OAP, bydbctl)"] --> LB["gRPC load balancer :17912"] subgraph LT["Liaison tier — stateless, NO disk"] L0["liaison-0<br/>:17912 client · :18912 peer<br/>+ FODC agent"] L1["liaison-1<br/>+ FODC agent"] end subgraph DT["Data tier — OWNS THE DISK"] DH["data-hot-0 :17912<br/>+ FODC agent<br/>+ lifecycle sidecar<br/>+ backup sidecar<br/>+ restore init container<br/>PVC: measure/stream/trace/property"] DW["data-warm-0"] DC["data-cold-0"] end FP["fodc-proxy · 1 per cluster<br/>:17913 /metrics · /cluster/topology"] LB --> L0 & L1 L0 & L1 -- "write / query :17912" --> DH DH -- "hot→warm→cold" --> DW --> DC L0 & L1 -. "gRPC register" .-> FP DH & DW & DC -. "gRPC register" .-> FP FP --> P["Prometheus → Grafana"]Pairing rules
liaison:18912datalifecycle127.0.0.1:17912backuprestoremigrationreplicas=03. Role → function → log destination
Self-storage needs a process owning the storage engine — the component holding queryable groups on disk. Only
dataandstandalonedo. Everything else must reach one, and the hop count differs.standalonequeue.Local(), own sharddataqueue.Local(), own shardliaisonlifecyclepub→ co-located127.0.0.1:17912backuppub→--grpc-addr; needs a schema-bootstrap decisionrestoremigrationreplicas=0Two corrections to a common mental model:
--measure-data-path/--stream-data-path(banyand/measure/wqueue.go) where it buffers parts before syncing them out. It lacks the storage engine, so it can hold a log batch in flight but can never be where logs are read back from.lifecycleandbackupnever go through a liaison.pub.NewWithoutMetadata(nil)defaults toROLE_DATA, so they publish straight to their co-located data node — a strictly shorter path than the liaison's.4. Proposed approach
4.1 The seam — one event, two writers
zerologalways encodes an event to JSON internally, andConsoleWriteris itself anio.Writerthat re-formats that JSON. SoMultiLevelWriterhands the sink the fully-encoded line — module, level, timestamp, message, all structured fields — one source, one format, one set of parameters.flowchart LR A["call site<br/>logger.GetLogger(measure).Info()<br/>.Str(group, g).Msg(flushed part)"] --> B["zerolog encodes ONE JSON event"] B --> CW["console writer<br/>stderr · ALWAYS ON"] B --> SW["switchableWriter<br/>atomic.Pointer"] SW --> S2["logSink — ONE ring buffer<br/>alive from Init()"] SW --> S3["io.Discard — after GracefulStop"]The writer contract
Three constraints come from zerolog's own implementation (v1.34.0), not from BanyanDB:
Event.write()callsputEvent(e), returninge.bufto async.Poolpis reused. Buffering it withoutappend([]byte(nil), p...)yields a slice the next log line overwrites.multiLevelWriter.Writemaps_n != len(p)→io.ErrShortWrite(len(p), nil). A dropped line must still report a full write; drops surface via the counter, never the return value.MultiLevelWritertype-switches onzerolog.LevelWriterbefore wrapping inLevelWriterAdapterWriteLevel(l zerolog.Level, p []byte)and zerolog hands over the level as an enum — so--logging-native-levelfiltering is an int compare with no JSON parsing on the hot path, and thelevelentity tag needs no extraction.atomic.Pointerrather than a mutex:WriteLevelruns on every log line from every goroutine, while the pointer is swapped once per process (atGracefulStop). A lock-free load is the right trade at that ratio.4.2 Lifecycle — deferred activation
Same deferral
pendingMeasures/native.InitSchemaalready use for metric schemas — but applied to the consumer, not the buffer.4.3 Write workflow
The consumer is a dedicated goroutine, following
accesslog.startConsumer— not atimestamp.Schedulerjob asFlushMetricsuses. A scheduler job is a periodic callback: it fires on the tick and can do nothing between ticks, so it supports a time trigger and nothing else. The size trigger needs something that observes every push, which only a goroutine selecting on the buffer can do.flowchart LR R["buffer<br/>bounded · drop on full"] --> T{"consumer goroutine<br/>flush trigger"} T -- "interval 5s" --> B["build InternalWriteRequest"] T -- "size ≥ 1024" --> B T -- "CloseNotify" --> B B --> N{"nodeSelector"} N -- "nil (data/standalone)" --> LOC["queue.Local() → own shard"] N -- "set (liaison/lifecycle)" --> LO["Locate()"] LO -- ok --> PUB["pub → TopicStreamWrite → data node"] LO -- "fail" --> DROP["count no_node, drop<br/>(do NOT publish empty nodeID)"]4.4 Schema
Derived field by field from the native-metrics schema in
pkg/meter/native/provider.go. Each row names the proto field, so the diff against the existing implementation is explicit.Running example — this line, emitted on node
data-hot-0:{"level":"warn","module":"MEASURE","group":"sw_metric","time":"2026-09-11T10:23:45.123Z","message":"flush took longer than expected"}Group —
common.v1.Group_monitoring)_monitoring_log)metadata.name_monitoring_monitoring_log"_monitoring_log"catalogCATALOG_MEASURECATALOG_STREAMCatalog_CATALOG_STREAM(= 1)resource_opts.shard_num11(configurable)1resource_opts.segment_interval{UNIT_DAY, 1}{UNIT_DAY, 1}&IntervalRule{Unit: UNIT_DAY, Num: 1}resource_opts.ttl{UNIT_DAY, 1}{UNIT_DAY, 7}(configurable)&IntervalRule{Unit: UNIT_DAY, Num: 7}Resource — metrics use
database.v1.Measure, logs usedatabase.v1.StreamMeasure)Stream)metadata.namelog"total_written"· logs:"log"tag_families[].namedefaultsearchable+data"searchable","data"tag_families[].tags[]{Name:"level", Type:TAG_TYPE_STRING},{Name:"body", Type:TAG_TYPE_DATA_BINARY}fields[](FieldSpec)valueFLOAT/GORILLA/ZSTDStream)entity.tag_names[node_id, module, level][]string{"node_id","module","level"}Write payload
measure.v1.InternalWriteRequest/DataPointValuestream.v1.InternalWriteRequest/ElementValue…element_id<node_id>-<process_epoch>-<seq>"data-hot-0-1757585021-42"…timestamptime.Now().Truncate(time.Second)at flush2026-09-11T10:23:45.123Z…tag_families[0](searchable){Tags: labelValues}["data","data-hot-0","MEASURE","warn","10.1.2.3:17912","10.1.2.3:17913","flush took longer than expected"]…tag_families[1](data)[]byte("{\"level\":\"warn\",\"module\":\"MEASURE\",…}")…fieldsFieldValue_Floatentity_valuesentity.tag_namesorder["data-hot-0","MEASURE","warn"]data.TopicMeasureWritedata.TopicStreamWritedata.TopicStreamWritegrpc_address/http_address— searchable, not in the entity. Not cardinality (both follow fromnode_id) but address churn: a restarted pod keeps itsnode_idand gets a new IP, which would open a new series and fragment that node's history.bodyholds the whole original line — no field lost to schema drift, byte-identical to the console.element_id=<node_id>-<process_epoch>-<seq>.timestamp= the event's time, not flush time, so lines buffered before activation land correctly on the time axis.4.5 Two things that differ from metrics
gauge.Set()doesn't logpub,sub,cluster-node-registry-*all calllogger.GetLogger(...)→ a naive sink feeds itselfRecursion guard, three layers: module denylist for write-path modules (primary) → sink never logs through
pkg/logger, only rate-limited stderr → bounded ring caps amplification (backstop).Invariant across every failure mode: console output is never degraded by the sink. Self-storage is best-effort; the console is not.
4.6 Implementation phases
See this comment — delivery order, and why each phase adds exactly one new failure domain.
5. Parameters and configuration
Derived from the two existing surfaces — prefix from console logging, vocabulary from metrics:
--logging-modesconsoleconsole,native, or bothmodesidea, from--observability-modes([prometheus])--logging-native-levelwarn--logging-level, but as an independent threshold. Metrics have no level concept--logging-native-flush-interval5s--observability-native-flush-interval, same default--logging-native-flush-size1024accesslog.DefaultBatchSize(100), a constant today--logging-native-buffer-size8192validRequestschannel capacity (100 sampled / 1000 not), a constant today--logging-native-group-ttl7d_monitoring_logTTLprovider.gohardcodesResourceOpts.Ttl = {UNIT_DAY, 1}--logging-native-shard-num1_monitoring_logshardsprovider.gohardcodesResourceOpts.ShardNum = 1. Load-bearing for logs, given the single-node funnelDeliberately not copied from metrics:
--observability-listener-addr(logs are push-only — no pull endpoint to scrape) and--observability-metrics-interval(that is the Prometheus collection tick; logs have no collection phase).Unchanged: the four existing
--logging-env / -level / -modules / -levelsflags. They apply upstream of both writers; altering them is an explicit non-goal. Each new flag gets the standardBYDB_*env binding, aslogger.RegisterFlagsalready provides.restore/migrationaccept--logging-modesbut rejectnativeatValidate().6. Reading logs back
No new query surface —
_monitoring_logis an ordinary stream group:7. Failure modes
Each row is one stage of the write path, in path order. Derived by walking the pipeline and asking, at each stage, "what can fail here, and what happens when it does?"
Every stage that can fail gets exactly one row, one behaviour, one counter label. The review test is therefore not "are these cases interesting" but "does every arrow in that diagram have a row" — a stage with no row is a stage whose failure is unhandled.
Serve()starts the consumernot_ready; console still has everythingbuffer_fullCreateGroup/CreateStreamfailsnot_ready); retry on the flush tickOnDelete)publish_error. After N consecutive publish errors, re-run the idempotentInitSchemarather than failing forever_monitoring_log)CreateStreamreturnsAlreadyExists, so the sink proceeds and writes fail on tag mismatch. Countschema_mismatch, log expected-vs-actual once to stderr. Never auto-migrateno_node, drop — don't publish an empty nodeIDpublish_error, drop that batch, continuepublish_error— indistinguishable from a network fault at this layer. Diagnose via the existingbanyandb_system_diskmetricGracefulStopwith a full buffercloser.CloseNotify()makes the consumer goroutine drain once and exit; that drain is time-bounded, and whatever cannot ship is countedshutdown_dropso the loss is not silentpkg/loggerDrops are exported as
banyandb_logging_dropped_total{reason}through the existing metrics factory — loss is visible in the system that is reliable. Labels:not_ready,buffer_full,schema_mismatch,no_node,publish_error,shutdown_drop.8. References
See this comment — existing log implementation, the native-metrics template, per-role wiring, and the liaison's two-tier write path.
Related issues
None found.
Are you willing to submit a pull request to implement this on your own?
Code of Conduct