Concepts
Use these concepts to choose stable scopes, decide what belongs in properties versus metadata, and understand what Leylines stores before you query it.
Leylines models local runtime activity as scoped, structured log entries in a durable store.
Store
A store is a local SQLite-backed timeline. It owns persistence, retention, redaction, query behavior, collapsed values, and tail subscriptions.
Users should interact with stores through the Node API, Vite plugin, or CLI. The database schema and file layout are internal details.
import { openScopedLogs } from 'leylines'
const logs = openScopedLogs({
path: '.leylines/logs.sqlite',
})
logs.close()
When path is omitted, Leylines uses .leylines/logs.sqlite under the current
working directory and keeps that default folder out of Git through the local
exclude file when possible.
Entry
An entry is immutable after it is written. It contains:
id: stable entry identifier.sequence: store-local monotonic order.timestamp: ISO occurrence time.level:debug,info,warn, orerror.scope: dotted domain scope.message: short event text.metadata: runtime context.properties: structured, queryable event data.error: optional normalized error details.
Prefer short messages and put correlation values in properties.
{
"level": "info",
"scope": "checkout.cart",
"message": "item added",
"metadata": { "runtime": "node" },
"properties": { "cartId": "cart-1", "sku": "sku-123" }
}
Scope
Scopes identify the product concept or workflow that emitted an entry. They are query boundaries, not display tags.
Good scopes:
checkout.cart
auth.session
worker.queue
browser.router
posthog
Use child loggers when a workflow naturally nests:
const logger = logs.logger('checkout')
const cartLogger = logger.child({ scope: 'cart' })
cartLogger.info('opened')
The child entry scope is checkout.cart.
Use scopePrefix when a workflow owns nested scopes:
const page = logs.query({ scopePrefix: 'checkout' })
This matches both checkout and dotted children such as checkout.cart.
Metadata Versus Properties
Use metadata for runtime context such as browser URL, Vite mode, process id,
or user agent. Use properties for values you expect to filter, correlate, or
pivot on during investigation.
Examples of properties:
request.idcartIdprojectIdoperationattempt
logger.info('request finished', {
metadata: {
runtime: 'node',
pid: process.pid,
},
properties: {
request: { id: 'req-123' },
operation: 'checkout.submit',
},
})
Query filters only target properties, so a value such as request.id belongs
there when an investigation will pivot on it:
ley --property request.id=req-123 --json
Debug Entries
debug entries are hidden from default queries and CLI output. Request them
explicitly with includeDebug, levels: ['debug'], or --include-debug.
This keeps routine investigation output compact while preserving debug detail for focused queries.
logger.debug('payment gateway response', {
properties: { request: { id: 'req-123' } },
})
const entries = logs.query({
scopePrefix: 'checkout',
includeDebug: true,
}).entries
Redaction
Redaction runs before persistence. Leylines redacts common secret-looking property names and values by default, and callers may add project-specific rules.
Redaction preserves diagnostic shape. An entry can still show that a token-like field existed without storing its raw value.
const logs = openScopedLogs({
redaction: {
rules: [{ name: /apiToken/i }],
},
})
const logger = logs.logger('checkout')
logger.info('provider configured', {
properties: {
provider: 'stripe',
apiToken: 'sk_test_secret',
},
})
const entry = logs.query({ scopePrefix: 'checkout' }).entries[0]
console.log(entry.properties.apiToken) // [REDACTED]
Retention
Retention is applied when a store opens, when it closes, and periodically during writes. Use it to keep local stores bounded by entry count, age, or both without paying retention cost on every entry.
openScopedLogs({
retention: {
maxEntries: 10_000,
maxAgeMs: 7 * 24 * 60 * 60 * 1000,
},
})
When both limits are configured, entries must survive both checks: old entries
are removed by age, and only the newest maxEntries entries remain.
Retention deletes rows and lets SQLite reuse freed pages. Leylines also
checkpoint-truncates the SQLite WAL file when a store opens and closes, but it
does not run VACUUM automatically.
Collapsed Values
Large JSON values are collapsed out of default entries and can be expanded by id later. This keeps timelines compact while retaining full diagnostic payloads when needed.
Use logs.expand(id) or ley expand <id>.
const page = logs.query({ scopePrefix: 'worker' })
const entry = page.entries[0]
// Query results keep the entry id and collapsed path separate.
const payload = logs.expand(`${entry.id}:properties.payload`)
console.log(payload?.value)
From the CLI, pass the entry id plus the collapsed path:
ley expand '<entry-id>:properties.payload' --json