16 · Logging System
This feature is already merged into main (spec
.kiro/specs/logging-system, phase=implemented, all tasks checked off, including isolated-build E2E).
The logging system provides unified structured logging for pi-web’s three component types — agent source, pi extension, and webext — aggregating from subprocess stderr into the main process, pushing in real time to the browser logs panel via the session stream, and supporting on-demand history retrieval.
Architecture Overview
agent source / pi extension (Node subprocess)
└─ createLogger() → nodeSink → stderr (prefixed with LOG_SENTINEL)
│
main process parseLogLine parses
│
PiSession per-session ring buffer (LogRingBuffer)
│
control:logs SSE frame ──► browser
│
LogsStore (merge/dedupe entries from three sources)
│
kernel PiChat renders LogsPanel by panelPosition
(bottom / right / drawer, with webext logs slot alongside)
webext (browser)
└─ createLogger() → browserSink → in-memory ring buffer (2000 entries) → LogsStoreThree core paths:
- Node subprocess —
nodeSinkserializes eachLogEntryto JSON and writes it to stderr prefixed withLOG_SENTINEL(\x02PILOG\x03); the main processparseLogLinerecognizes the prefix, deserializes the entry, and routes it to the correspondingPiSession. - Browser webext —
browserSinkwrites entries into an in-memory ring buffer (BROWSER_LOG_CAPACITY = 2000); subscribers (LogsStore) update their state once notified. - Isomorphic
@blksails/loggerpackage — zero runtime dependencies, no static Node module references, safe to import on both the Node and browser sides.
The @blksails/logger Package
Package name: @blksails/logger
Location: packages/logger/
Core API
import { createLogger, configureLogger, initConfigFromEnv } from "@blksails/logger";
// Create a logger (Node subprocess side)
const logger = createLogger({ namespace: "agent:hello", level: "debug" });
logger.info("started", { version: "1.0" });
logger.debug("tool called", { toolName: "search" });
// Derive a child logger (namespace becomes "agent:hello:tool")
const toolLogger = logger.child("tool");
toolLogger.warn("rate limit approaching");
// Initialize config from environment variables at Node service startup (one-time)
initConfigFromEnv();Type Definitions
| Type | Description |
|---|---|
LogLevel | "debug" | "info" | "warn" | "error" |
LogEntry | { id?, level, ns, msg, data?, ts } |
Logger | { debug, info, warn, error, child } |
LoggerRuntimeConfig | { enabled, level, namespaces? } |
Sink | (entry: LogEntry) => void |
Three-Level Gating (inside createLogger)
createLogger applies the following to each logging call in order:
- enabled gate — globally discards when
LoggerRuntimeConfig.enabledisfalse - level gate — takes the stricter of the per-logger static level and the runtime global level
- namespace gate — discards when the namespace is explicitly disabled
Gating takes effect immediately — no need to rebuild the logger instance: configureLogger(partial) mutates the module-level singleton, and the next call automatically reads the new config.
Node Sink: stderr sentinel
// packages/logger/src/node-sink.ts
export const LOG_SENTINEL = "\x02PILOG\x03 ";
// Per-line format: LOG_SENTINEL + JSON.stringify(entry) + "\n"The main process parses with parseLogLine (packages/protocol/src/logging/log-entry.ts): it uses LOG_SENTINEL as the recognition marker, and stderr output that does not match (such as native Node diagnostics) is wrapped as a raw log entry, without interfering with RPC protocol message routing.
Browser Sink: in-memory ring buffer
// packages/logger/src/browser-sink.ts
export const BROWSER_LOG_CAPACITY = 2000; // max entries in the ring bufferWhen over capacity, the oldest entry is evicted; subscribers register a callback via subscribeBrowserLogs(cb), which returns an unsubscribe function.
File Output (P1)
Enabled via env variables or configureFileOutput():
PI_WEB_LOG_FILE=/var/log/pi-web/app.log
PI_WEB_LOG_FILE_MAXSIZE=10 # MB, default 10
PI_WEB_LOG_FILE_MAXFILES=5 # number of rotated backups, default 5Rotation strategy: app.log → app.log.1 → app.log.2 … → app.log.N, with backups exceeding maxFiles deleted automatically. On the Node side, fs access is injected through globalThis.__PI_WEB_FS__ (preset by the server-side runner bootstrap); in the browser environment this seam does not exist, so the file sink becomes a no-op, preserving isomorphic safety.
Environment Variable Reference
| Variable | Default | Description |
|---|---|---|
PI_WEB_LOG_ENABLED | true | Set to false to disable logging globally |
PI_WEB_LOG_LEVEL | debug | Global minimum level: debug / info / warn / error |
PI_WEB_LOG_NAMESPACES | —— | Comma-separated; enables the specified namespaces, e.g. agent:hello,ext:probe |
PI_WEB_LOG_FILE | —— | Absolute path of the log file (setting it enables file output) |
PI_WEB_LOG_FILE_MAXSIZE | 10 | Max MB per file |
PI_WEB_LOG_FILE_MAXFILES | 5 | Number of rotated backups to keep |
Using It in an Agent Source
The runner injects a namespace-bound Logger into the agent source via AgentContext.logger:
// examples/logging-demo-agent/index.ts (excerpt)
import type { AgentContext, AgentDefinition } from "@blksails/pi-web-agent-kit";
import { defineAgent } from "@blksails/pi-web-agent-kit";
export default function (ctx: AgentContext): AgentDefinition {
const logger = ctx.logger; // injected by the runner; namespace taken from the agent source directory name
if (logger !== undefined) {
logger.debug("factory invoked", { cwd: ctx.cwd });
logger.info("started", { env: Object.keys(ctx.env).length });
logger.warn("this is a sample warn");
logger.error("this is a sample error (not a real error)");
const childLogger = logger.child("tool"); // namespace: <agent>:tool
childLogger.info("child logger created with namespace :tool");
}
return defineAgent({ systemPrompt: "..." });
}A pi extension can reference this package directly, without depending on the pi SDK:
// .pi/extensions/my-ext.ts
import { createLogger } from "@blksails/logger";
const log = createLogger({ namespace: "ext:my-ext" });
log.info("extension loaded");Server Side: Authoritative Gating
The design calls this “server-side authoritative gating” (design.md / task 4.4): changing enabled/level/namespaces in Settings affects not only the browser — Node subprocess logs are also filtered again by the server before they “enter the ring buffer / produce a frame,” ensuring that the Node logs of agents and extensions are likewise controlled.
runner bootstrap — initConfigFromEnv() reads PI_WEB_LOG_* env (packages/server/src/runner/runner.ts)
│
PiSession.handleStderr — loads the logging config via loggingConfigProvider at session start (ConfigCodec.load("logging"))
│ buffers chunks before the config is ready, replays them once ready
▼
PiSession.processStderrChunk — filters entry by entry per the gates, ingests into LogRingBuffer, then merges into a control:logs frame to broadcast- runner bootstrap — at runner startup,
initConfigFromEnv()is called to initialize the Node-side logger config fromPI_WEB_LOG_*env (packages/server/src/runner/runner.ts:199). - PiSession gating —
handleStderrloads the logging config via the injectedloggingConfigProviderwhen the session activates; before the config is ready it buffers the stderr chunks, then replays and filters them once ready. - Per-entry filtering + ingest —
processStderrChunkappliesgate.enabled/isLevelEnabled/isNamespaceEnabled(from@blksails/logger) to eachLogEntryin turn; those that pass are assigned an id byLogRingBuffer.ingestinto the per-session ring buffer, then merged into acontrol:logsframe to broadcast (packages/server/src/session/pi-session.ts). - SSE backfill — when a browser subscription is established,
PiSessionfirst backfills the existing ring buffer entries as a singlecontrol:logsframe (to avoid races on early logs), then pushes subsequent new entries in real time.
REST endpoint (history retrieval): GET /api/sessions/[sessionId]/logs?level=info&limit=200&since=<ts> (the internal handler routes /sessions/:id/logs, see packages/server/src/http/routes/query-routes.ts, returning { entries }).
Browser Side: LoggingConfigLoader
LoggingConfigLoader (components/logging-config-loader.tsx) fetches the logging config from the config API when the client mounts, calls configureLogger() to sync the browser-side gating, renders nothing (returns null), and handles failures silently. In this branch it is mounted inside components/chat-app.tsx (the PiChat shell), sharing the lifecycle of the session UI.
// Mount once in the app shell (e.g. chat-app.tsx)
import { LoggingConfigLoader } from "@/components/logging-config-loader";
export default function ChatShell({ children }) {
return (
<>
<LoggingConfigLoader />
{children}
</>
);
}Config source endpoint: GET /api/config/logging, returning { values: { enabled, level, namespaces } }.
The Logs Panel (LogsPanel)
The panel is rendered directly by the kernel PiChat (packages/ui/src/chat/pi-chat.tsx), not as a standalone slot. PiChat decides whether and where to mount the panel based on three props — showLogs / logsPanelVisible (corresponding to outputs.panelVisible) / logsPanelPosition (corresponding to outputs.panelPosition); each of the three positions renders a container marked with data-pi-logs-region:
panelPosition | Render location | Behavior |
|---|---|---|
bottom (default) | Below the input dock (pi-chat.tsx:960) | A horizontal panel stacked in the same column as the session usage bar |
right | A standalone block inside the right-hand aside (pi-chat.tsx:1112) | Coexists with panelRight / artifact in the same aside |
drawer | A fixed bottom overlay (pi-chat.tsx:974) | Toggled by the “Logs” button with data-pi-logs-drawer-toggle; a fixed drawer at max-h-[40vh] |
Alongside the kernel LogsPanel at each position there coexists a webext logs slot (ExtSlotRegion slot="logs", pi-chat.tsx:966 / 989 / 1117): an extension’s contributions to the logs slot render after the kernel panel with append semantics — the two coexist rather than replace each other. See slots.logs of the webext in examples/* for an example (wired in task 8.3).
Panel features (the filtering logic lives in LogsStore, packages/react/src/logging/logs-store.ts; the panel merely consumes its result):
- Filter by level (dropdown selecting
debug / info / warn / error, with minimum-level semantics) - Filter by namespace (colon-segmented prefix match, automatically including child namespaces, e.g.
agent:hellomatchesagent:hello:toolbut notagentx:other) - Text search (case-sensitive substring match against
msg, i.e.e.msg.includes(filterText)) - Automatic history retrieval (mounting the panel triggers
fetchHistory, hitting the REST endpoint above)
Smart-follow (smart-follow + jump-to-unread)
Auto-scroll is implemented by LogsPanel itself (packages/ui/src/logs/logs-panel.tsx); its handleScroll determines bottom-pinning via scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD (logs-panel.tsx:178) — independent of the use-auto-scroll.ts hook used generally by the conversation area, which the panel does not reuse:
- Pinned follow — when at the bottom, a new entry arriving sets
ul.scrollTop = ul.scrollHeightto keep following, andunreadCountis reset to zero (logs-panel.tsx:157). - Pause on scroll-up — scrolling up away from the bottom pauses the follow; during the pause new entries accumulate as the unread count by positive increments, while entry reductions caused by filtering (negative increments) are not counted (
logs-panel.tsx:164). - Jump-to-unread button — when paused and
unreadCount > 0, adata-pi-logs-jump-latestbutton floats at the bottom right of the panel, with the text↓ N new logs; clicking it returns to the bottom, resumes the follow, and resets unread to zero (logs-panel.tsx:190 / 305).
Narrow-Column Adaptive Wrapping
LogRow uses an adaptive row layout (logs-panel.tsx:81): in a wide container the four columns — time / level / namespace / message — lay out on a single row; in a narrow container (such as the right-side column), the message column triggers flex-wrap via min-w 12rem to wrap onto a full-width row and break by word (break-words), avoiding fixed columns squeezing the message into a character-by-character vertical layout.
Settings UI Config Domain
The logging system registers a logging config domain on the Settings page (packages/protocol/src/config/domains/logging.ts, with schema loggingConfigSchema), split into three groups:
| Group ID | Fields |
|---|---|
general | enabled (enable logging, the master switch), level (global level, default info) |
components | namespaces (per-namespace toggles, custom widget logNamespaceToggles) |
output | outputs (nested object: console console, file file path/rotation, panelVisible panel visibility, panelPosition panel position), panelDefaultLevel (panel default level) |
Note: the config domain’s
leveldefault isinfo(see the schema), whereas the Node-side library’sinitConfigFromEnvdefaults internally todebugwhenPI_WEB_LOG_LEVELis not read — these are default values at different layers.
Quick Verification Steps
For hands-on practice see
examples/logging-demo-agent(with its own README): it converges the three paths above — the agent-injectedctx.logger, the pi extension’s directcreateLogger, and the webext browser log bus — into a single logs panel, the fastest entry point for comparing logs from the three sources. The steps below operate on this example.
-
Start the dev server:
pnpm dev -
Open pi-web in the browser, select
logging-demo-agent(located atexamples/logging-demo-agent/), and start a session. -
Once the session is established, the logs panel should immediately show the startup logs emitted by the demo agent during the factory phase: four main-namespace entries (
debug / info / warn / error) plus oneinfoentry from the child namespace<agent>:tool. -
Verify env gating:
PI_WEB_LOG_LEVEL=warn pnpm devThe
debugandinfoentries should not appear in the panel. -
Verify file output:
PI_WEB_LOG_FILE=/tmp/pi-web.log pnpm dev tail -f /tmp/pi-web.log # you should see JSONL-formatted log lines (one JSON.stringify(entry) per line, with no sentinel prefix)
If the panel stays empty, troubleshoot in the following order:
- Confirm the panel is not hidden or moved:
outputs.panelVisibleistruein Settings (otherwise it does not render even withshowLogs), and whenoutputs.panelPositionisdrawerthe panel is collapsed by default — click the “Logs” button (data-pi-logs-drawer-toggle) to expand it. - Confirm logging is not turned off via env or Settings:
PI_WEB_LOG_ENABLEDis notfalse, andPI_WEB_LOG_LEVELis not higher than the lowest level the demo agent emits (the demo emitsdebug, so setting it towarngates the twodebug/infoentries). - The server-side gating is independent of the browser (see “Server Side: Authoritative Gating” above): raising the level via env filters out the lower-level entries before they “enter the ring buffer,” so the panel never receives them.
- If there is still no output, see 18 · Troubleshooting FAQ.
Protocol: SSE Log Control Frame
Logs are pushed over the existing SSE control-frame channel. The top-level SSE frame is discriminated by kind; logs travel on a kind: "control" frame, with the inner payload.control being "logs" (plural), distinguished from other control events (extension-ui / queue / stats / error) via the same discriminatedUnion("control", …) (packages/protocol/src/transport/sse-frame.ts). A single frame can carry multiple entries:
// SSE frame example (the product of makeControlFrame({ control: "logs", entries: [...] }))
data: {"kind":"control","protocolVersion":"0.1.0","payload":{"control":"logs","entries":[{"id":"seq-42","level":"info","ns":"agent:hello","msg":"started","ts":1719000000000}]}}parseLogLine in packages/protocol/src/logging/log-entry.ts is responsible for the sentinel recognition of subprocess stderr lines and LogEntrySchema (zod) validation; on validation failure it returns null, which the main process silently ignores.
Related Chapters
- 03 · System Architecture — the three-stage subprocess / main-process / browser structure
- 05 · Configuration —
PI_WEB_*env variables and the Settings UI framework - 07 · Agent Development —
AgentContext.loggerinjection - 09 · Extensions and Skills — referencing the logging library directly in a pi extension
- 17 · Development and Testing — unit tests and isolated-build E2E
- 18 · Troubleshooting FAQ — common log-related issues