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
The logging and error formatting system was built up incrementally by AI agents, resulting in duplicated logic, dead code, inconsistent patterns, and unnecessary complexity across environments. The goal is to drastically simplify logging while maintaining the DX of "one right way to do things."
Current State: What Exists
Logger Package (@lowdefy/logger, 4 exports)
node (logger/src/node/createNodeLogger.js): Pino wrapper with 80-line attachLevelMethods that adds color sub-methods (.info.blue()) and UI options ({ spin: true }). Custom error serializer.
dev (logger/src/dev/createDevLogger.js): Thin wrapper around createNodeLogger with sync pino destination defaults.
cli (logger/src/cli/createCliLogger.js): Ora-based pretty printer. Duplicates the same color sub-method and error detection pattern as the node logger.
browser (logger/src/browser/createBrowserLogger.js): Console.error/warn/info/debug with formatting. Has duplicate formatBrowserError function.
Three Layers of "Format an Error for Display"
These three functions evolved separately and overlap in confusing ways:
formatErrorMessage (in @lowdefy/errors/src/formatErrorMessage.js) - Used by .print() methods. Returns [ErrorName] message. Received: {...}. This is the "inner" formatter that adds the [Name] prefix and appends received values.
formatUiMessage (in @lowdefy/logger/src/formatUiMessage.js) - The "outer" formatter used by all loggers. Calls .print() if available (which calls formatErrorMessage), else falls back to [Name] messagewithout received values. This inconsistency means errors without .print() get a different format than errors with it.
formatBrowserError (in @lowdefy/logger/src/browser/createBrowserLogger.js) - Near-exact duplicate of formatUiMessage. Exists only because the browser logger was written separately.
The core problem: Location is baked into error.message at construction time (PluginError constructor), but received is added at display time (by formatErrorMessage). There's no good reason for this split - it's an artifact of how things evolved. This inconsistency ripples through the formatting layers.
Error Classes (@lowdefy/errors)
Well-structured hierarchy, but with dead code and constructor side effects that complicate serialization:
LowdefyError
Constructor side effects: None
Dead code: Client override has .log() method (fire-and-forget in _app.js, duplicates createLogError)
ConfigError
Constructor side effects: None in base. Build subclass does location resolution, suppression checking, and YAML parse error handling in constructor — couples error to build context, makes serialization complex.
Duplicate paths: .log() is used by _app.js ErrorBoundary (React rendering errors). createLogError handles action/operator errors. Two entry points into the same /api/client-error round-trip — unified by handleError in Brief 5.
PluginError
Constructor side effects: Appends location to message (rawMessage + at ${location}.). Stores rawMessage separately to avoid double-format on deserialize.
Dead code: None
ServiceError
Constructor side effects: Enhances message (prepends service name, adds context for ECONNREFUSED etc.). Deserializer sets service separately to avoid double-prefix.
Dead code: None
UserError
Constructor side effects: None
Dead code: None
Each class has its own .serialize() / .deserialize() / .print(). The per-class serializers use a ~err marker (string value = class name, data as siblings: { '~err': 'PluginError', message: '...', ... }). They exist specifically because constructor side effects make generic serialization impossible — but Object.create + assign bypasses constructors entirely, making per-class serializers unnecessary.
Pino Error Serializer
defaultErrSerializer in createNodeLogger.js only picks 7 specific fields:
The ~e error handling is basic — only preserves name, message, and value (toString). Does not preserve enumerable properties or reconstruct Lowdefy error classes. Meanwhile, each error class has its own .serialize() / .deserialize() using a separate ~err marker with a different shape (string value vs object value). Two competing mechanisms:
// Current ~e replacer in helpers (line 31-38) — generic, lossyif(type.isError(newValue)){return{'~e': {name: newValue.name,message: newValue.message,value: newValue.toString()}};}// Current ~e reviver in helpers (line 156-159) — always reconstructs plain Errorif(!type.isUndefined(newValue['~e'])){consterror=newError(newValue['~e'].message);error.name=newValue['~e'].name;returnerror;}// Current ~err in per-class serializers — class-specific, selective fields// { '~err': 'PluginError', message: '...', rawMessage: '...', location: '...' }// Note: ~err value is a string (class name), siblings are data.// Different shape from ~e (where value is an object containing data).
Error Logging Paths (Inconsistent)
Server (prod)
How: logError({ context, error }) in servers/server/lib/server/log/logError.js
Location resolution: Async via resolveErrorConfigLocation() reading keyMap.json/refMap.json from disk each time
Server (dev)
How: logError({ context, error }) in servers/server-dev/lib/server/log/logError.js
Location resolution: Same async resolution
Build
How: context.logger.error(error) directly. Logger monkey-patched in createContext.js.
Location resolution: Already resolved at throw time (ConfigError constructor uses keyMap/refMap synchronously)
Browser (actions/blocks)
How: createLogError(lowdefy) → sends to /api/client-error → server resolves → returns source
Location resolution: Async via API round-trip
Browser (ErrorBoundary)
How: error.log(lowdefyRef.current) in _app.js → duplicate path to /api/client-error
Location resolution: Same, but through error's own .log() method
The build monkey-patches logger.warn and logger.error:
logger.warn wrapper (lines 72-122): Creates ConfigWarning instances from params, handles deduplication by source:line, checks suppression via ~ignoreBuildChecks, throws ConfigError in prod mode if prodError: true.
logger.error wrapper (lines 125-148): Currently does almost nothing - just passes through. Copies color sub-methods.
The warn wrapping is useful functionality but creates confusion between context.logger.warn (special build behavior) and what you'd expect from a plain logger.
Parses pino JSON from child processes, extracts level/color/spin/succeed/source/err, re-logs through CLI logger. Currently duplicates some error display logic (source in blue) that the CLI logger also does.
Problems Summary
Three formatting functions that overlap: formatErrorMessage (adds prefix + received), formatUiMessage (calls .print() or falls back without received), formatBrowserError (duplicate of formatUiMessage). The fallback path in formatUiMessage produces different output than the .print() path - errors without .print() lose the received value.
Location and received added at different stages: PluginError bakes location into error.message at construction. formatErrorMessage adds received at display time via .print(). No good reason for this split.
Two browser error entry points: error.log() (used by _app.js ErrorBoundary for React rendering errors) and createLogError (used by Actions.js for action/operator errors) both do the same /api/client-error round-trip independently. Two paths into the same system, unified by handleError in Brief 5.
Constructor side effects: PluginError and ServiceError format messages at construction time, requiring rawMessage and careful deserialization to avoid double-formatting. Build ConfigError does location resolution, suppression, and YAML handling in its constructor — couples errors to build context. This is why per-class serializers exist and why env-specific subclasses were created.
Two competing error serializers: ~e in helpers (generic, lossy) and per-class ~err (class-specific, selective). Different markers, different shapes, different purposes. Should be one mechanism.
80-line attachLevelMethods wrapper: Adds color sub-methods and error formatting to pino. The CLI logger then re-implements the same pattern.
Two browser error logging paths: createLogError and error.log() both send to /api/client-error.
Logger/print distinction in CLI: createCliLogger delegates to createPrint (separate Ora wrapper). Unnecessary indirection.
Build logger monkey-patching: context.logger.warn is secretly special — does suppression, location resolution, dedup behind a plain-looking logger call. Easy to confuse with plain logging.
Inconsistent logError signatures: Server uses logError({ context, error }), browser uses logError(error) with bound context.
logError conflated with logger: The current logError does location resolution, structured data collection, Sentry capture, AND logging — but it's called from context.logger.error. Error handling is a separate concern from logging and shouldn't live inside a logger method.
Target Architecture
Core Principle
Each layer does ONE thing. There is ONE right way to do things, and that way is easy.
Error classes → dumb data carriers. Constructors format message (keeps stack
trace consistent). Store raw parts as properties (_message).
no .print(), no formatErrorMessage, no env subclasses
handlers resolve location, not constructors
errorToDisplayString → ONE function: [Name] + error.message + received
replaces .print(), formatErrorMessage, formatUiMessage,
formatBrowserError — all four collapse into this
Serializer → enhanced ~e in @lowdefy/helpers serializer
preserves all props, reconstructs correct class
direct imports of Lowdefy error classes (no config)
transport uses explicit field picking (not serializer)
Server loggers → plain pino, generic error serializer
UI hints (color, spin, succeed) as JSON properties
context.logger → 4 sync methods (error, warn, info, debug)
just a logger — pino underneath, zero monkey-patching
handleError → separate function, NOT on the logger
resolves location, adds structured data, Sentry
then logs via pino. Used in catch blocks.
handleWarning → build-only: context.handleWarning(params)
suppression, location resolution, dedup
then logs via pino. Explicit, not hidden in logger.
CLI logger → reads pino JSON (via line handler) or direct calls
uses errorToDisplayString for errors
Ora for interactive, console for CI
Browser logger → plain console wrapper (error/warn/info/debug)
exists for general non-error logging only
handleError → lowdefy.handleError (browser)
(browser) dedup, server round-trip, errorToDisplayString
one path for all browser error handling
Error Classes: Dumb Data Carriers
Errors carry data, handlers resolve. This is the industry standard pattern (Go's errors.Wrap, Rust's anyhow, Sentry's source mapping). Errors store configKey and structured properties. Location resolution happens in handleError (errors) and handleWarning (warnings) — not in constructors.
No env-specific subclasses. The /client and /server entry points are removed — they just re-export base classes. The /build entry point stays for build utilities (ConfigWarning, ConfigMessage, resolveConfigLocation) but build/ConfigError.js is deleted — build code uses the base ConfigError directly. All location resolution moves to handlers.
PluginError and ServiceError constructors continue to format error.message at construction time. This keeps stack traces consistent — the first line of the stack always matches error.message. The _message property stores the raw unformatted input for serialization.
The real wins are removing .print(), removing constructor side effects (location resolution, suppression), removing env-specific subclasses, and consolidating to errorToDisplayString. The getter approach was considered but rejected because it causes stack trace / message mismatches and makes error.message mutable (a subtle source of bugs).
// BEFORE: constructor formats message, .print() adds prefix + receivedclassPluginErrorextendsError{constructor({ error, location, ... }){constrawMessage=error?.message;constmessage=location ? `${rawMessage} at ${location}.` : rawMessage;super(message);// location baked into .message ← this is finethis.rawMessage=rawMessage;// stored separately for serialization}print(){returnformatErrorMessage(this);// adds [Name] prefix + received ← DELETE THIS}}// AFTER: constructor still formats, but no .print(), no formatErrorMessageclassPluginErrorextendsError{constructor({ message, error, pluginType, pluginName, location, received, configKey }){constrawMessage=message??error?.message??'Unknown error';constformatted=location ? `${rawMessage} at ${location}.` : rawMessage;super(formatted);// stack trace matches error.message ✓this._message=rawMessage;// raw input for serializationthis.name='PluginError';this.pluginType=pluginType;this.pluginName=pluginName;this.location=location;this.received=received!==undefined ? received : error?.received;this.configKey=configKey??error?.configKey??null;}// No .print() method — errorToDisplayString replaces it}
Same pattern for ServiceError:
classServiceErrorextendsError{constructor({ message, error, service, code, statusCode, configKey }){constrawMessage=message??error?.message??'Service error';letformatted=ServiceError.enhanceMessage(rawMessage,code??error?.code,statusCode??error?.statusCode);if(service)formatted=`${service}: ${formatted}`;super(formatted);// stack trace matches error.message ✓this._message=rawMessage;// raw input for serializationthis.name='ServiceError';this.service=service;this.code=code??error?.code;this.statusCode=statusCode??error?.statusCode;this.configKey=configKey??null;}staticenhanceMessage(message,code,statusCode){if(code==='ECONNREFUSED')return`Connection refused. The service may be down. ${message}`;if(code==='ENOTFOUND')return`DNS lookup failed. ${message}`;if(code==='ETIMEDOUT')return`Connection timed out. ${message}`;if(statusCode>=500)return`Server returned error ${statusCode}. ${message}`;returnmessage;}// No .print() method — errorToDisplayString replaces it}
Benefits:
Stack trace consistency: error.stack first line always matches error.message.
Immutable message: error.message doesn't change after construction. Safe for dedup keys, comparison, logging.
_message is a small price: One extra property on errors that format at construction (PluginError, ServiceError). Other error classes (ConfigError, LowdefyError, UserError) don't need it.
No .print() method on any error class. errorToDisplayString replaces it.
No formatErrorMessage function. Deleted entirely.
No constructor side effects: ConfigError is { message, configKey }. No context parameter, no location resolution, no suppression logic in constructors. Errors are simple to create, serialize, and test.
No env-specific subclasses: One ConfigError, one LowdefyError. The /client and /server entry points become simple re-exports (or are removed). Build utilities stay in /build but don't subclass errors.
Serialization works: extractErrorProps captures both message (formatted) and _message (raw). Deserialization via Object.create + assign sets message as a plain property — no constructor side effects because the constructor is bypassed.
errorToDisplayString — The ONE Formatting Function
Replaces .print(), formatErrorMessage, formatUiMessage, and formatBrowserError. All four collapse into this single function:
This works on ANY error — Lowdefy errors, plain JS errors, error-like objects from deserialized pino JSON. The [Name] prefix and received value are always added consistently. No special cases, no .print() check.
Why received stays in errorToDisplayString (not in error.message): Received values can be large and noisy. error.message is used for dedup keys, API responses, comparison strings — it should be the human-readable description. Received is debug information for display only.
One Generic Error Serializer
Enhance the @lowdefy/helpers serializer's existing ~e handling to preserve all enumerable properties and reconstruct the correct Lowdefy error class. The ~e marker stays (consistent with ~d, ~r, ~k, ~l single-char convention). Per-class .serialize() / .deserialize() / deserializeError() are removed — ~e is the only error serialization mechanism.
// Shared extraction logic (used by both helpers serializer and pino serializer)functionextractErrorProps(err){if(!err)returnerr;// message, name, stack, cause are non-enumerable on Error instances —// Object.keys alone won't capture them. Must access explicitly.constprops={message: err.message,name: err.name,stack: err.stack};if(err.cause!==undefined)props.cause=err.cause;for(constkeyofObject.keys(err)){props[key]=err[key];}returnprops;}// Helpers serializer: ~e replacer (enhanced — preserves all props)if(type.isError(newValue)){return{'~e': extractErrorProps(newValue)};}// Helpers serializer: ~e reviver (enhanced — reconstructs correct class)if(!type.isUndefined(newValue['~e'])){constdata=newValue['~e'];constErrorClass=lowdefyErrorTypes[data.name]||Error;consterror=Object.create(ErrorClass.prototype);for(const[key,value]ofObject.entries(data)){error[key]=value;}returnerror;}// Pino error serializer: same extraction, no ~e wrapperconstpinoErrSerializer=extractErrorProps;
The extractErrorProps function is shared between both consumers. The helpers serializer wraps with ~e, pino uses the flat object directly. One extraction function, one way to convert errors to serializable objects.
The serializer imports Lowdefy error classes directly — no registration, no config passing, no error type map at call sites:
// In @lowdefy/helpers/src/serializer.jsimport{ConfigError,LowdefyError,PluginError,ServiceError,UserError}from'@lowdefy/errors';constlowdefyErrorTypes={ ConfigError, LowdefyError, PluginError, ServiceError, UserError };
These are all already exported from the main @lowdefy/errors entry point. This is the same approach as the framework-specific ~r, ~k, ~l markers — the serializer already knows about Lowdefy-specific types. Direct imports mean: no global mutable state, no temporal coupling, hard errors if something breaks, testable without mocking registration. Every serializer.copy() call automatically gets correct error class reconstruction with zero call-site changes.
Transport (browser → server) is a separate concern. The browser handleError does NOT use the serializer for transport — it picks specific fields explicitly to keep payloads small (excludes received, which can be huge):
The /api/client-error endpoint simplifies. It no longer calls deserializeError(data) to reconstruct a class instance. It receives a flat object with the properties it needs — configKey for location resolution, name/message for logging. It's effectively a specialized handleError for client-reported errors:
// Server /api/client-error endpointasyncfunctionlogClientError(req,res){const{ name, message, configKey, source, ... }=req.body;// Resolve location from configKey (same as server handleError)consterrorData={ configKey };const{ keyMap, refMap }=awaitloadMaps(buildDir);resolveErrorLocation(errorData,{ keyMap, refMap });// Log as structured datapinoLogger.error({errorName: name, configKey,source: errorData.source},message);// Return source for browser console displayres.json({source: errorData.source});}
(The errorToDisplayString function is defined above in the Error Classes section.)
Server Logger: Plain Pino
createNodeLogger returns a plain pino logger. No attachLevelMethods. No color sub-methods. Keeps pino's default messageKey (msg). All log calls use the standard two-argument form logger.level(mergeObj, messageString) — explicit, no magic. UI hints are standard pino merge objects:
createDevLogger sets defaults (sync dest, no pid/hostname) and returns a plain pino logger.
context.logger — Just a Logger
The contract:context.logger is an object with 4 sync methods: error, warn, info, debug. It's just a logger — pino underneath. Zero monkey-patching. No async, no location resolution, no Sentry, no special warn behavior.
// Build contextcontext.logger=pinoLogger;// plain pino — no wrappers// Server contextcontext.logger=pinoLogger;// plain pino// Browsercontext.logger=createBrowserLogger();// console.error/warn/info/debug
Separate from the logger, just like handleError. Creates a ConfigWarning from params, then handles suppression, prodError escalation (collects the ConfigWarning directly — it extends ConfigError), location resolution, dedup, and logging. Callers are explicit about what they're doing — no hidden behavior behind logger.warn().
functioncreateHandleWarning({ pinoLogger, context }){returnfunctionhandleWarning(params){constwarning=newConfigWarning(params);// Check suppression (~ignoreBuildChecks)if(shouldSuppress({configKey: warning.configKey,keyMap: context.keyMap,checkSlug: warning.checkSlug})){return;}// Escalate in prod if marked prodError — ConfigWarning extends ConfigError, so collect directlyif(params.prodError&&context.stage==='prod'){collectExceptions(context,warning);return;}// Resolve location (configKey → source via keyMap/refMap)constlocation=resolveConfigLocation({configKey: warning.configKey, context });constsource=location?.source??null;// Dedup by source:lineconstdedupKey=source??warning.message;if(context.seenSourceLines.has(dedupKey))return;context.seenSourceLines.add(dedupKey);// Two-argument pino call: merge object + message string.// Source display (blue) is handled by the CLI, not here — build just writes JSON.pinoLogger.warn({err: warning, source },warning.message);};}
handleError(error) is an async function that does the full error pipeline: location resolution, per-request structured data, Sentry capture, then logging. It is NOT a logger method. It lives outside the logger, in the error handling layer.
Lives on context (server/build) or lowdefy (browser). Created during context setup, accessed at catch sites via context.handleError. No extra parameter passing — context is already threaded everywhere.
Logger methods should be sync. handleError is async (reads keyMap/refMap, sends to Sentry, browser sends to server).
Logger just logs. handleError does resolution, structured data collection, Sentry capture — then logs.
Server functions should throw errors, not log them. Only catch points call context.handleError.
Server handleError — Per-Request, Async
Created per-request, closes over request data. Reads keyMap/refMap async (current approach, no event loop blocking). Only called from catch points (apiWrapper, runRoutine), so await is fine.
functioncreateHandleError({ pinoLogger, buildDir, sentry, req, user }){returnasyncfunctionhandleError(error){// handleError must never throw — it's the last line of defense.try{const{ keyMap, refMap }=awaitloadMaps(buildDir);resolveErrorLocation(error,{ keyMap, refMap });conststructuredData={err: error,errorName: error.name,isServiceError: error.isServiceError,source: error.source,configKey: error.configKey,
...(user&&{user: {id: user.id,roles: user.roles,sub: user.sub,session_id: user.session_id}}),
...(req&&{url: req.url,method: req.method,headers: pickRequestHeaders(req)}),};pinoLogger.error(structuredData,errorToDisplayString(error));sentry?.captureException(error);}catch{// Resolution or Sentry failed — still log the original error via pinotry{pinoLogger.error({err: error},error.message);}catch{console.error(error);}}};}
The async file reads can still use lazy caching (read once, cache for subsequent errors) — but since it's async, there's no event loop blocking concern. The original async approach was fine all along; the problem was putting it inside context.logger.error which shouldn't be async.
Build handleError — Sync
Build already has keyMap/refMap in memory. handleError in the build is sync. Used by logCollectedErrors to iterate context.errors and log each one before throwing. Suppression is NOT handled here — it happens at collection time in collectExceptions/tryBuildStep, so suppressed errors never enter context.errors.
resolveErrorLocation handles all three resolution paths from the current ConfigMessage.js:
configKey → keyMap → refMap (standard path)
operatorLocation → refMap (operator errors)
filePath + lineNumber → raw path join (needs directories.config)
functioncreateBuildHandleError({ pinoLogger, keyMap, refMap, directories }){returnfunctionhandleError(error){// handleError must never throw.try{resolveErrorLocation(error,{ keyMap, refMap,configDirectory: directories.config});pinoLogger.error({err: error},errorToDisplayString(error));}catch{try{pinoLogger.error({err: error},error.message);}catch{console.error(error);}}};}
Browser handleError — Async, Fire-and-Forget
Sends error to /api/client-error for server-side location resolution and terminal logging. The response (with resolved source) updates the console output. Callers can fire-and-forget or await depending on context.
functioncreateBrowserHandleError(lowdefy){consthandledErrors=newSet();returnasyncfunctionhandleError(error){consterrorKey=`${error.message}:${error.configKey||''}`;if(handledErrors.has(errorKey))return;handledErrors.add(errorKey);try{// Explicit field picking — keeps payload small (excludes received, etc.)const{ name, message, stack, configKey, source, pluginType, pluginName, location }=error;constresponse=awaitfetch(`${lowdefy?.basePath??''}/api/client-error`,{method: 'POST',headers: {'Content-Type': 'application/json'},body: JSON.stringify({ name, message, stack, configKey, source, pluginType, pluginName, location }),});if(response.ok){const{source: resolvedSource}=awaitresponse.json();if(resolvedSource)error.source=resolvedSource;}}catch{// Server logging failed — continue with local console}if(error.source){console.info(error.source);}console.error(errorToDisplayString(error));};}
Where handleError and handleWarning Live
Function
Environment
Accessed via
Signature
Where called
handleError
Server
context.handleError
await context.handleError(error)
apiWrapper, runRoutine, serverSidePropsWrapper
handleError
Build
context.handleError
context.handleError(error) (sync)
logCollectedErrors, buildRefs catch
handleError
Browser
lowdefy.handleError
await lowdefy.handleError(error)
createHandleError, _app.js ErrorBoundary
errorHandler
CLI
errorHandler (imported)
await errorHandler({ context, error })
runCommand catch
handleWarning
Build only
context.handleWarning
context.handleWarning(params) (sync)
Build validation call sites
UserError bypasses handleError. In Actions.js, UserError is logged to the browser console only (console.error(errorToDisplayString(error))), never sent to the server terminal. This is intentional — UserErrors are expected user-facing issues (validation failures, intentional throws), not system errors. The instanceof UserError check in Actions.js stays; it just uses errorToDisplayString instead of error.print().
CLI Logger
One createCliLogger function. No separate createPrint. No color sub-methods.
Input handling logic:
Error/error-like (has .message)?
Log error.source in blue (if present), at the same log level
Log errorToDisplayString(error) in red (error level) or yellow (warn level)
String? → log the string
Object with msg? → log msg (with color/spin/succeed from object)
Else → JSON.stringify(obj, null, 2)
Two modes:
Interactive (default): Ora spinner with timestamps and ANSI colors
Basic (CI): Plain console.log/error/warn
functioncreateCliLogger({ logLevel }={}){constisCI=process.env.CI==='true'||process.env.CI==='1';// Display layer (Ora or console)// ... spinner setup for interactive mode ...functionlog(level,input){// Error-like: source in blue, then formatted messageif(input&&typeofinput!=='string'&&input.message!==undefined){if(input.source){display(level,input.source,{color: 'blue'});}display(level,errorToDisplayString(input));return;}// Stringif(typeofinput==='string'){display(level,input);return;}// Object with msg (from line handler / pino JSON)if(input?.msg){if(input.source){display(level,input.source,{color: 'blue'});}consttext=input.msg;display(level,text,{color: input.color,spin: input.spin,succeed: input.succeed});return;}// Fallback: stringifydisplay(level,JSON.stringify(input,null,2));}return{error: (input)=>log('error',input),warn: (input)=>log('warn',input),info: (input)=>log('info',input),debug: (input)=>log('debug',input),};}
CLI Line Handler: Pure Protocol Translation
The line handler parses pino JSON and calls the CLI logger. It deserializes errors. No display logic.
import{ConfigError,LowdefyError,PluginError,ServiceError,UserError}from'@lowdefy/errors';constlowdefyErrorTypes={ ConfigError, LowdefyError, PluginError, ServiceError, UserError };functionreconstructError(flatObj){constErrorClass=lowdefyErrorTypes[flatObj.name]||Error;consterror=Object.create(ErrorClass.prototype);for(const[key,value]ofObject.entries(flatObj)){error[key]=value;}returnerror;}functioncreateStdOutLineHandler({ context }){constlogger=context.logger;returnfunctionstdOutLineHandler(line){letparsed;try{parsed=JSON.parse(line);}catch{logger.info(line);// Not JSON, log as stringreturn;}constlevel=pinoLevelToName[parsed.level]??'info';// Reconstruct error from flat pino JSON (not ~e wrapped)if(parsed.err){logger[level](reconstructError(parsed.err));return;}// Pass structured message to logger (pino default messageKey is 'msg')logger[level]({msg: parsed.msg,source: parsed.source,color: parsed.color,spin: parsed.spin,succeed: parsed.succeed,});};}
Clean split: line handler does protocol translation (JSON → objects/errors), logger does display.
Browser Logger
Plain console wrapper. No error formatting — that's handleError's job.
The browser logger exists for general non-error logging (context.logger.info('loading page'), context.logger.debug(...)). All error handling goes through lowdefy.handleError — the logger's .error() and .warn() are pass-throughs for the rare case someone logs a plain string at those levels.
Browser Error Handling: One Path
lowdefy.handleError is the single mechanism for all browser error handling. Remove error.log() and error.resolve() from client error classes. The _app.js ErrorBoundary and action error handler both use lowdefy.handleError.
One path. No .log() methods on error classes. No duplicate round-trips. See handleError section above for implementation.
Sentry: Wants the raw Error instance with stack trace. Called in handleError after location resolution.
Log drains: Consume pino JSON output. The pino error serializer determines what they see.
These are handled by handleError, not by the logger or error classes. The pino serializer (using extractErrorProps) ensures log drains get all error properties. Sentry gets the error instance before serialization.
This doesn't need to change architecturally — handleError is the right place for Sentry capture. The simplification is that extractErrorProps is shared with the pino serializer, so JSON log output and Sentry capture get the same complete error data.
Work Units (Briefs)
Brief 1: Remove .print() and formatErrorMessage, Add errorToDisplayString, Clean Up Constructors
Remove .print() method from ALL error classes (LowdefyError, ConfigError, PluginError, ServiceError, UserError).
Delete formatErrorMessage.js entirely.
Add errorToDisplayString function to @lowdefy/errors (exported from main entry). This is the ONE function that formats any error for display: [Name] error.message + received. Replaces .print() at all call sites.
Delete formatUiMessage.js from @lowdefy/logger.
Remove formatBrowserError from browser logger.
Update all imports across all loggers to use errorToDisplayString from @lowdefy/errors.
PluginError: Keep constructor formatting (location baked into message). Rename rawMessage → _message for consistency. Keep structured parts as properties (location, received, pluginType, etc.).
ServiceError: Keep constructor formatting (service prefix, ECONNREFUSED etc. baked into message). Add _message property for the raw input. Keep structured parts as properties (service, code, statusCode).
ConfigError, LowdefyError, UserError: No constructor changes needed (no formatting side effects). Just remove .print().
Delete build/ConfigError.js — the build subclass with location resolution, suppression, and YAML handling in the constructor. Location resolution moves to handleError and handleWarning (Brief 5). Suppression moves to both handleError (for errors) and handleWarning (for warnings). prodError escalation moves to handleWarning. YAML parse error message formatting stays at the throw site (just string formatting, not a constructor concern). The build/index.js re-exports the base ConfigError.
Simplify build/ConfigWarning.js — make ConfigWarning extend ConfigError. Remove location resolution, suppression, and prodError escalation from constructor. ConfigWarning carries { name, message, configKey, checkSlug }. All behavior moves to handleWarning (Brief 5). Extending ConfigError means it works with errorToDisplayString, extractErrorProps, and the ~e serializer. prodError escalation just collects the ConfigWarning directly (it IS a ConfigError).
Update all tests.
Why: Errors are dumb data carriers — handlers resolve. Four formatting functions (formatErrorMessage, .print(), formatUiMessage, formatBrowserError) collapse into one errorToDisplayString. Unblocks Brief 2 (generic serializer). Consistent with how server and browser already work: errors carry configKey, handlers resolve location.
All callers of error.print() across the codebase (replace with errorToDisplayString(error))
Test files for above
Serialization note:extractErrorProps captures both message (formatted, as passed to super()) and _message (raw input). Deserialization via Object.create + assign sets message as a plain string property on the instance, which shadows Error.prototype's message. No constructor is called, so no double-formatting.
Checklist:
Create errorToDisplayString.js — formats [Name] message + received for any error
Export errorToDisplayString from @lowdefy/errors main entry
Remove .print() from LowdefyError
Remove .print() from ConfigError
Remove .print() from PluginError
Remove .print() from ServiceError
Remove .print() from UserError
Delete formatErrorMessage.js
Delete formatUiMessage.js from logger
Remove formatBrowserError from browser logger
PluginError: rename rawMessage → _message
ServiceError: add _message property
Delete build/ConfigError.js, update build/index.js to re-export base ConfigError
Simplify build/ConfigWarning.js: extend ConfigError, remove location resolution/suppression/prodError from constructor
Replace all error.print() call sites with errorToDisplayString(error)
Replace all formatUiMessage imports with errorToDisplayString
Add extractErrorProps function to helpers (shared by serializer ~e replacer and pino serializer).
Enhance ~e replacer to use extractErrorProps (preserves all enumerable properties, not just name/message/value).
Enhance ~e reviver to reconstruct correct Lowdefy error class via Object.create(ErrorClass.prototype) + property assignment. The serializer imports Lowdefy error classes directly — no registration, no config passing, no error type map at call sites.
Export extractErrorProps from helpers for pino serializer use.
Remove .serialize() and .deserialize() from all error classes.
Remove deserializeError.js from errors package.
Update /api/client-error endpoint: no longer calls deserializeError(data). Receives flat object, uses configKey for location resolution, logs properties directly. Effectively becomes a specialized handleError for client-reported errors.
Update all tests.
Why: One serializer, one way to serialize errors. The ~e marker is the single mechanism — consistent with ~d, ~r, ~k, ~l. Removes ~150 lines of per-class serialization code. Every serializer.copy() call automatically gets correct class reconstruction with zero call-site changes.
Files:
packages/utils/helpers/src/serializer.js (enhance ~e replacer/reviver, add extractErrorProps, add direct imports of Lowdefy error classes)
packages/api/src/routes/log/logClientError.js (simplify: flat object, no class reconstruction)
All callers of error.serialize(), error.deserialize(), and deserializeError() across the codebase
Test files for above
Note on transport: The browser → server transport path does NOT use the serializer. Browser handleError picks specific fields explicitly (excludes received etc.) and POSTs a flat object. The server endpoint works with that flat object directly. See target architecture "One Generic Error Serializer" section.
Consequence to evaluate: The helpers serializer.copy() is used throughout the codebase for deep cloning. Errors appearing in cloned data will now be fully preserved (correct class, all props) instead of becoming generic Error instances. This is generally better but needs testing.
Checklist:
Add extractErrorProps function to helpers (captures all enumerable props + message, name, stack, cause)
Export extractErrorProps from @lowdefy/helpers
Enhance ~e replacer to use extractErrorProps
Enhance ~e reviver: Object.create(ErrorClass.prototype) + assign, with lowdefyErrorTypes map (direct imports including UserError)
Remove .serialize() from ConfigError, LowdefyError, PluginError, ServiceError
Remove .deserialize() from ConfigError, LowdefyError, PluginError, ServiceError
Delete deserializeError.js
Remove deserializeError re-export from server/index.js
Simplify logClientError.js: flat object handling, no class reconstruction
Update all callers of error.serialize(), error.deserialize(), deserializeError()
Delete client/ConfigError.js and client/LowdefyError.js — empty subclasses after removing .resolve() and .log().
Delete client/index.js — no subclasses remain, callers import from @lowdefy/errors directly.
Delete server/index.js — after removing deserializeError (Brief 2), nothing remains. Callers import from @lowdefy/errors directly.
Update all import from '@lowdefy/errors/client' and import from '@lowdefy/errors/server' to import from '@lowdefy/errors'.
Why: No env-specific subclasses needed. Errors are dumb data carriers — handlers resolve. The only entry point with real content is /build (for build utilities like ConfigWarning, ConfigMessage, resolveConfigLocation).
Files:
packages/utils/errors/package.json (remove ./client and ./server from exports)
All callers of @lowdefy/errors/client and @lowdefy/errors/server (update imports)
Test files for above
Checklist:
Remove ./client and ./server from package.json exports
Delete client/ConfigError.js
Delete client/LowdefyError.js
Delete client/index.js
Delete server/index.js
Update all import from '@lowdefy/errors/client' → import from '@lowdefy/errors'
Update all import from '@lowdefy/errors/server' → import from '@lowdefy/errors'
Update all affected tests
Run pnpm --filter=@lowdefy/errors test
Brief 4: Simplify Node Logger — Plain Pino
Scope:packages/utils/logger/src/node/
What:
Remove attachLevelMethods function entirely (80 lines). No color sub-methods.
Remove buildMergeObj function.
Replace defaultErrSerializer with extractErrorProps from helpers.
Keep pino's default messageKey (msg). All log calls use the standard two-argument form logger.level(mergeObj, messageString) — explicit, no magic.
createNodeLogger returns a plain pino logger. Callers use standard pino API: logger.info({ color: 'blue' }, 'text').
createDevLogger stays as a thin wrapper setting defaults (sync dest, no pid/hostname).
Update all callers using color sub-methods to use pino merge objects (e.g. context.logger.info.blue(text) → context.logger.info({ color: 'blue' }, text)).
Why: The node logger's job is to produce structured JSON. All the display logic (color sub-methods, error formatting, source logging) belongs in the CLI logger, not in pino.
Create context.handleError — a separate async function for the full error pipeline. NOT a logger method. Lives on context (server/build) or lowdefy (browser). Created during context setup — no extra parameter passing since context is already threaded everywhere:
Build: context.handleError = createBuildHandleError({ pinoLogger, keyMap, refMap, directories }) — per-build, sync. Handles all three location resolution paths (configKey, operatorLocation, filePath+lineNumber). Does NOT handle suppression — that stays at collection time in collectExceptions/tryBuildStep.
Create context.handleWarning — build-only. Creates ConfigWarning (extends ConfigError) from params, then handles suppression, prodError escalation (collects the ConfigWarning directly), location resolution, dedup, and logging. Created per-build, lives on context. See handleWarning pseudocode in target architecture.
Create resolveErrorLocation — unified function that handles all three resolution paths from current ConfigMessage.js: configKey → keyMap → refMap, operatorLocation → refMap, filePath + lineNumber → raw path join. Signature: resolveErrorLocation(errorOrData, { keyMap, refMap, configDirectory }). Mutates first arg, setting .source.
Unify browser error path: Update _app.js ErrorBoundary in both server and server-dev to use lowdefy.handleError instead of error.log(). Wiring note:lowdefyRef.current doesn't have basePath at ErrorBoundary level (it's set later by initLowdefyContext from router.basePath). This is fine — the current error.log() already falls back to '', giving a relative /api/client-error URL. createBrowserHandleError uses lowdefy?.basePath ?? '' to match. The handleError function is created early and attached to lowdefyRef.current. Rename createLogError.js → createHandleError.js (creates lowdefy.handleError).
Update all catch points to await context.handleError(error): apiWrapper, runRoutine, serverSidePropsWrapper, logCollectedErrors, buildRefs catch, browser lowdefy.handleError, _app.js ErrorBoundary. Note: serverSidePropsWrapper currently calls logError fire-and-forget — change to await. This is safe because getServerSideProps runs before any response is sent; the catch block fully executes before the throw propagates to Next.js.
Update all context.logger.warn({ configKey, ... }) calls in build to context.handleWarning({ ... }). Plain string warnings stay on context.logger.warn.
Server async file reads stay async — no sync readFileSync, no event loop blocking. The async approach was fine all along; the problem was putting it inside a logger method.
Why: Clean separation of concerns. Logger is sync, just logs. Error handling is async, does resolution + structured data + Sentry + logging. Warning handling is sync, does suppression + resolution + dedup + logging. Both are explicit — not hidden behind logger methods. Zero function signature changes — context is already passed everywhere. One browser error logging path — no duplicate round-trips to /api/client-error.
Files:
packages/build/src/createContext.js (wire up context.handleError and context.handleWarning)
packages/build/src/utils/logCollectedErrors.js
packages/servers/server/lib/server/log/logError.js (rename to handleError.js)
packages/servers/server-dev/lib/server/log/logError.js (rename to handleError.js)
packages/servers/server-dev/pages/api/page/[pageId].js (calls logError in loop)
packages/api/src/routes/endpoints/runRoutine.js
packages/client/src/createLogError.js (rename to createHandleError.js)
packages/servers/server/pages/_app.js
packages/servers/server-dev/pages/_app.js
packages/cli/src/utils/errorHandler.js (no rename — this is a top-level catch-all, not the same handleError pattern. Just update it to use errorToDisplayString and the new logger API.)
packages/cli/src/utils/runCommand.js
All other callers of logError() across the codebase
All callers of context.logger.warn({ configKey, ... }) in build (change to context.handleWarning(...))
packages/utils/errors/src/build/resolveConfigLocation.js (used by handleWarning for location resolution)
packages/utils/errors/src/build/ConfigMessage.js (used by handleWarning for suppression checking)
Note on resolveErrorLocation: This function absorbs the three resolution strategies from the current ConfigMessage.js methods (resolveOperatorLocation, resolveRawLocation, and the configKey path). Unified signature: resolveErrorLocation(errorOrData, { keyMap, refMap, configDirectory }). It mutates the first argument, setting .source. The configDirectory parameter is only needed by the build (for raw filePath + lineNumber resolution); server omits it. The function reads configKey, operatorLocation, filePath, and lineNumber from the error/data object to determine which resolution path to use (same cascade as current ConfigMessage.js).
Note on ConfigError suppression: Suppression stays at collection time. Currently tryBuildStep/collectExceptions check error.suppressed — after the refactor, ConfigError no longer sets .suppressed. Instead, collectExceptions/tryBuildStep call shouldSuppress() directly and skip suppressed errors before adding them to context.errors. This keeps error counts correct — logCollectedErrors throws with context.errors.length, so suppressed errors must never be collected. handleWarning also calls shouldSuppress for warnings. handleError does NOT check suppression — it just logs whatever it receives.
Note:loadMaps in the server pseudocode is extracted from the existing logError implementations. Not new logic — just pulled out of the current server logError.js functions.
Migration notes:
logError({ context, error }) → await context.handleError(error) at the few catch points.
if (error.suppressed) return; in tryBuildStep/collectExceptions → replace with if (shouldSuppress({ configKey: error.configKey, keyMap: context.keyMap, checkSlug: error.checkSlug })) return; (suppression stays at collection time, but uses shouldSuppress() instead of the .suppressed property).
Checklist:
Create createBuildHandleError function (sync, resolution + logging, safety wrapper)
Create createHandleWarning function (suppression, prodError escalation, location resolution, dedup, logging)
Create resolveErrorLocation function (unified 3-path resolution)
Wire context.handleError and context.handleWarning in createContext.js
Brief 6: Simplify context.logger — Plain Pino, Zero Monkey-Patching
Scope:packages/build/src/
What:
context.logger becomes a plain pino logger. 4 sync methods. Remove ALL wrappers from createContext.js — both the _lowdefyWrapped error wrapper and the warn wrapper.
No monkey-patching. context.logger.warn is just pino's .warn().
Build warning handling (suppression, location resolution, dedup) is now in context.handleWarning (Brief 5).
Why:context.logger should just be a logger — zero surprises. Error handling moves to context.handleError, warning handling moves to context.handleWarning. Both are explicit, not hidden behind logger methods. Callers that pass { configKey, checkSlug, prodError } use context.handleWarning — they know they're doing something special. Callers that just log a string use context.logger.warn.
Files:
packages/build/src/createContext.js (remove all logger wrappers)
Checklist:
Remove _lowdefyWrapped warn wrapper from createContext.js
Remove _lowdefyWrapped error wrapper from createContext.js
Remove _lowdefyContext reference on logger
Remove seenSourceLines from context (now in handleWarning)
Verify context.logger is just the plain pino logger passed in
Update tests for createContext.js
Run pnpm --filter=@lowdefy/build test
Brief 7: Simplify CLI Logger
Scope:packages/utils/logger/src/cli/
What:
Merge createPrint into createCliLogger. Remove createPrint.js as separate module. Keep Ora for interactive, console for CI.
Remove color sub-methods. The API is: logger.error(input), logger.info(input).
Input handling:
Error/error-like (has .message): log source in blue if present, then errorToDisplayString(error) in default color for level (red for error, yellow for warn). For LowdefyError and non-Lowdefy errors (i.e. not ConfigError, PluginError, ServiceError, UserError), also log error.stack — these represent internal bugs or unexpected failures where the stack trace is critical for debugging. Lowdefy error types use source/location instead of stack traces to point to root cause.
String: log string.
Object with msg: log msg, apply color/spin/succeed from object properties.
Else: JSON.stringify(input, null, 2).
Keep memoisation in createCliLogger (only one Ora spinner should be active per process). The memoisation moves from createPrint into createCliLogger itself.
Export from @lowdefy/logger/cli.
Why: One module, clear input handling logic, no indirection.
Files:
packages/utils/logger/src/cli/createCliLogger.js
packages/utils/logger/src/cli/createPrint.js (merge into createCliLogger or delete)
No display logic (no source handling, no color application — logger does all of that).
Error reconstruction from pino JSON: pino's err serializer writes flat objects via extractErrorProps (not ~e wrapped). The line handler reconstructs these using the same lowdefyErrorTypes map the helpers serializer uses (direct import). Object.create(ErrorClass.prototype) + property assignment — same pattern as the ~e reviver, just without the ~e wrapper.
Logging & Error Formatting Refactor Plan
Problem Statement
The logging and error formatting system was built up incrementally by AI agents, resulting in duplicated logic, dead code, inconsistent patterns, and unnecessary complexity across environments. The goal is to drastically simplify logging while maintaining the DX of "one right way to do things."
Current State: What Exists
Logger Package (
@lowdefy/logger, 4 exports)node(logger/src/node/createNodeLogger.js): Pino wrapper with 80-lineattachLevelMethodsthat adds color sub-methods (.info.blue()) and UI options ({ spin: true }). Custom error serializer.dev(logger/src/dev/createDevLogger.js): Thin wrapper aroundcreateNodeLoggerwith sync pino destination defaults.cli(logger/src/cli/createCliLogger.js): Ora-based pretty printer. Duplicates the same color sub-method and error detection pattern as the node logger.browser(logger/src/browser/createBrowserLogger.js): Console.error/warn/info/debug with formatting. Has duplicateformatBrowserErrorfunction.Three Layers of "Format an Error for Display"
These three functions evolved separately and overlap in confusing ways:
formatErrorMessage(in@lowdefy/errors/src/formatErrorMessage.js) - Used by.print()methods. Returns[ErrorName] message. Received: {...}. This is the "inner" formatter that adds the[Name]prefix and appends received values.formatUiMessage(in@lowdefy/logger/src/formatUiMessage.js) - The "outer" formatter used by all loggers. Calls.print()if available (which callsformatErrorMessage), else falls back to[Name] messagewithout received values. This inconsistency means errors without.print()get a different format than errors with it.formatBrowserError(in@lowdefy/logger/src/browser/createBrowserLogger.js) - Near-exact duplicate offormatUiMessage. Exists only because the browser logger was written separately.The core problem: Location is baked into
error.messageat construction time (PluginError constructor), but received is added at display time (byformatErrorMessage). There's no good reason for this split - it's an artifact of how things evolved. This inconsistency ripples through the formatting layers.Error Classes (
@lowdefy/errors)Well-structured hierarchy, but with dead code and constructor side effects that complicate serialization:
LowdefyError.log()method (fire-and-forget in_app.js, duplicatescreateLogError)ConfigError.log()is used by_app.jsErrorBoundary (React rendering errors).createLogErrorhandles action/operator errors. Two entry points into the same/api/client-errorround-trip — unified byhandleErrorin Brief 5.PluginErrorrawMessage+at ${location}.). StoresrawMessageseparately to avoid double-format on deserialize.ServiceErrorserviceseparately to avoid double-prefix.UserErrorEach class has its own
.serialize()/.deserialize()/.print(). The per-class serializers use a~errmarker (string value = class name, data as siblings:{ '~err': 'PluginError', message: '...', ... }). They exist specifically because constructor side effects make generic serialization impossible — butObject.create + assignbypasses constructors entirely, making per-class serializers unnecessary.Pino Error Serializer
defaultErrSerializerincreateNodeLogger.jsonly picks 7 specific fields:This is too selective. Other error properties (pluginType, pluginName, location, received, service, code, statusCode) are lost in JSON log output.
Helpers Serializer (
@lowdefy/helpers/src/serializer.js)The
~eerror handling is basic — only preservesname,message, andvalue(toString). Does not preserve enumerable properties or reconstruct Lowdefy error classes. Meanwhile, each error class has its own.serialize()/.deserialize()using a separate~errmarker with a different shape (string value vs object value). Two competing mechanisms:Error Logging Paths (Inconsistent)
Server (prod)
logError({ context, error })inservers/server/lib/server/log/logError.jsresolveErrorConfigLocation()reading keyMap.json/refMap.json from disk each timeServer (dev)
logError({ context, error })inservers/server-dev/lib/server/log/logError.jsBuild
context.logger.error(error)directly. Logger monkey-patched increateContext.js.Browser (actions/blocks)
createLogError(lowdefy)→ sends to/api/client-error→ server resolves → returns sourceBrowser (ErrorBoundary)
error.log(lowdefyRef.current)in_app.js→ duplicate path to/api/client-error.log()methodCLI
createStdOutLineHandlerparses pino JSON from subprocess → CLI logger displaysBuild Context Logger Wrapping (
packages/build/src/createContext.js)The build monkey-patches
logger.warnandlogger.error:logger.warnwrapper (lines 72-122): Creates ConfigWarning instances from params, handles deduplication bysource:line, checks suppression via~ignoreBuildChecks, throws ConfigError in prod mode ifprodError: true.logger.errorwrapper (lines 125-148): Currently does almost nothing - just passes through. Copies color sub-methods.The warn wrapping is useful functionality but creates confusion between
context.logger.warn(special build behavior) and what you'd expect from a plain logger.CLI Subprocess Bridge (
createStdOutLineHandler.js)Parses pino JSON from child processes, extracts level/color/spin/succeed/source/err, re-logs through CLI logger. Currently duplicates some error display logic (source in blue) that the CLI logger also does.
Problems Summary
formatErrorMessage(adds prefix + received),formatUiMessage(calls.print()or falls back without received),formatBrowserError(duplicate offormatUiMessage). The fallback path informatUiMessageproduces different output than the.print()path - errors without.print()lose the received value.error.messageat construction.formatErrorMessageadds received at display time via.print(). No good reason for this split.error.log()(used by_app.jsErrorBoundary for React rendering errors) andcreateLogError(used by Actions.js for action/operator errors) both do the same/api/client-errorround-trip independently. Two paths into the same system, unified byhandleErrorin Brief 5.rawMessageand careful deserialization to avoid double-formatting. Build ConfigError does location resolution, suppression, and YAML handling in its constructor — couples errors to build context. This is why per-class serializers exist and why env-specific subclasses were created.defaultErrSerializerloses error properties in JSON log output.~ein helpers (generic, lossy) and per-class~err(class-specific, selective). Different markers, different shapes, different purposes. Should be one mechanism.attachLevelMethodswrapper: Adds color sub-methods and error formatting to pino. The CLI logger then re-implements the same pattern.createLogErroranderror.log()both send to/api/client-error.createCliLoggerdelegates tocreatePrint(separate Ora wrapper). Unnecessary indirection.context.logger.warnis secretly special — does suppression, location resolution, dedup behind a plain-looking logger call. Easy to confuse with plain logging.logErrorsignatures: Server useslogError({ context, error }), browser useslogError(error)with bound context.logErrorconflated with logger: The currentlogErrordoes location resolution, structured data collection, Sentry capture, AND logging — but it's called fromcontext.logger.error. Error handling is a separate concern from logging and shouldn't live inside a logger method.Target Architecture
Core Principle
Each layer does ONE thing. There is ONE right way to do things, and that way is easy.
Error Classes: Dumb Data Carriers
Errors carry data, handlers resolve. This is the industry standard pattern (Go's
errors.Wrap, Rust'sanyhow, Sentry's source mapping). Errors storeconfigKeyand structured properties. Location resolution happens inhandleError(errors) andhandleWarning(warnings) — not in constructors.No env-specific subclasses. The
/clientand/serverentry points are removed — they just re-export base classes. The/buildentry point stays for build utilities (ConfigWarning,ConfigMessage,resolveConfigLocation) butbuild/ConfigError.jsis deleted — build code uses the baseConfigErrordirectly. All location resolution moves to handlers.PluginError and ServiceError constructors continue to format
error.messageat construction time. This keeps stack traces consistent — the first line of the stack always matcheserror.message. The_messageproperty stores the raw unformatted input for serialization.The real wins are removing
.print(), removing constructor side effects (location resolution, suppression), removing env-specific subclasses, and consolidating toerrorToDisplayString. The getter approach was considered but rejected because it causes stack trace / message mismatches and makeserror.messagemutable (a subtle source of bugs).Same pattern for ServiceError:
Benefits:
error.stackfirst line always matcheserror.message.error.messagedoesn't change after construction. Safe for dedup keys, comparison, logging._messageis a small price: One extra property on errors that format at construction (PluginError, ServiceError). Other error classes (ConfigError, LowdefyError, UserError) don't need it..print()method on any error class.errorToDisplayStringreplaces it.formatErrorMessagefunction. Deleted entirely.{ message, configKey }. Nocontextparameter, no location resolution, no suppression logic in constructors. Errors are simple to create, serialize, and test./clientand/serverentry points become simple re-exports (or are removed). Build utilities stay in/buildbut don't subclass errors.extractErrorPropscaptures bothmessage(formatted) and_message(raw). Deserialization viaObject.create + assignsetsmessageas a plain property — no constructor side effects because the constructor is bypassed.errorToDisplayString— The ONE Formatting FunctionReplaces
.print(),formatErrorMessage,formatUiMessage, andformatBrowserError. All four collapse into this single function:This works on ANY error — Lowdefy errors, plain JS errors, error-like objects from deserialized pino JSON. The
[Name]prefix and received value are always added consistently. No special cases, no.print()check.Why received stays in
errorToDisplayString(not inerror.message): Received values can be large and noisy.error.messageis used for dedup keys, API responses, comparison strings — it should be the human-readable description. Received is debug information for display only.One Generic Error Serializer
Enhance the
@lowdefy/helpersserializer's existing~ehandling to preserve all enumerable properties and reconstruct the correct Lowdefy error class. The~emarker stays (consistent with~d,~r,~k,~lsingle-char convention). Per-class.serialize()/.deserialize()/deserializeError()are removed —~eis the only error serialization mechanism.The
extractErrorPropsfunction is shared between both consumers. The helpers serializer wraps with~e, pino uses the flat object directly. One extraction function, one way to convert errors to serializable objects.The serializer imports Lowdefy error classes directly — no registration, no config passing, no error type map at call sites:
These are all already exported from the main
@lowdefy/errorsentry point. This is the same approach as the framework-specific~r,~k,~lmarkers — the serializer already knows about Lowdefy-specific types. Direct imports mean: no global mutable state, no temporal coupling, hard errors if something breaks, testable without mocking registration. Everyserializer.copy()call automatically gets correct error class reconstruction with zero call-site changes.Transport (browser → server) is a separate concern. The browser
handleErrordoes NOT use the serializer for transport — it picks specific fields explicitly to keep payloads small (excludesreceived, which can be huge):The
/api/client-errorendpoint simplifies. It no longer callsdeserializeError(data)to reconstruct a class instance. It receives a flat object with the properties it needs —configKeyfor location resolution,name/messagefor logging. It's effectively a specializedhandleErrorfor client-reported errors:(The
errorToDisplayStringfunction is defined above in the Error Classes section.)Server Logger: Plain Pino
createNodeLoggerreturns a plain pino logger. NoattachLevelMethods. No color sub-methods. Keeps pino's defaultmessageKey(msg). All log calls use the standard two-argument formlogger.level(mergeObj, messageString)— explicit, no magic. UI hints are standard pino merge objects:createDevLoggersets defaults (sync dest, no pid/hostname) and returns a plain pino logger.context.logger— Just a LoggerThe contract:
context.loggeris an object with 4 sync methods:error,warn,info,debug. It's just a logger — pino underneath. Zero monkey-patching. No async, no location resolution, no Sentry, no special warn behavior.context.handleWarning— Build-Only Warning HandlerSeparate from the logger, just like
handleError. Creates a ConfigWarning from params, then handles suppression, prodError escalation (collects the ConfigWarning directly — it extends ConfigError), location resolution, dedup, and logging. Callers are explicit about what they're doing — no hidden behavior behindlogger.warn().Usage is explicit:
handleError— Separate from the LoggerhandleError(error)is an async function that does the full error pipeline: location resolution, per-request structured data, Sentry capture, then logging. It is NOT a logger method. It lives outside the logger, in the error handling layer.Lives on
context(server/build) orlowdefy(browser). Created during context setup, accessed at catch sites viacontext.handleError. No extra parameter passing —contextis already threaded everywhere.Why separate from logger:
handleErroris async (reads keyMap/refMap, sends to Sentry, browser sends to server).handleErrordoes resolution, structured data collection, Sentry capture — then logs.context.handleError.Server
handleError— Per-Request, AsyncCreated per-request, closes over request data. Reads keyMap/refMap async (current approach, no event loop blocking). Only called from catch points (
apiWrapper,runRoutine), soawaitis fine.The async file reads can still use lazy caching (read once, cache for subsequent errors) — but since it's async, there's no event loop blocking concern. The original async approach was fine all along; the problem was putting it inside
context.logger.errorwhich shouldn't be async.Build
handleError— SyncBuild already has keyMap/refMap in memory.
handleErrorin the build is sync. Used bylogCollectedErrorsto iteratecontext.errorsand log each one before throwing. Suppression is NOT handled here — it happens at collection time incollectExceptions/tryBuildStep, so suppressed errors never entercontext.errors.resolveErrorLocationhandles all three resolution paths from the currentConfigMessage.js:configKey→ keyMap → refMap (standard path)operatorLocation→ refMap (operator errors)filePath+lineNumber→ raw path join (needsdirectories.config)Browser
handleError— Async, Fire-and-ForgetSends error to
/api/client-errorfor server-side location resolution and terminal logging. The response (with resolved source) updates the console output. Callers can fire-and-forget or await depending on context.Where
handleErrorandhandleWarningLivehandleErrorcontext.handleErrorawait context.handleError(error)apiWrapper,runRoutine,serverSidePropsWrapperhandleErrorcontext.handleErrorcontext.handleError(error)(sync)logCollectedErrors,buildRefscatchhandleErrorlowdefy.handleErrorawait lowdefy.handleError(error)createHandleError,_app.jsErrorBoundaryerrorHandlererrorHandler(imported)await errorHandler({ context, error })runCommandcatchhandleWarningcontext.handleWarningcontext.handleWarning(params)(sync)UserError bypasses
handleError. InActions.js,UserErroris logged to the browser console only (console.error(errorToDisplayString(error))), never sent to the server terminal. This is intentional — UserErrors are expected user-facing issues (validation failures, intentional throws), not system errors. Theinstanceof UserErrorcheck inActions.jsstays; it just useserrorToDisplayStringinstead oferror.print().CLI Logger
One
createCliLoggerfunction. No separatecreatePrint. No color sub-methods.Input handling logic:
.message)?error.sourcein blue (if present), at the same log levelerrorToDisplayString(error)in red (error level) or yellow (warn level)msg? → logmsg(with color/spin/succeed from object)JSON.stringify(obj, null, 2)Two modes:
CLI Line Handler: Pure Protocol Translation
The line handler parses pino JSON and calls the CLI logger. It deserializes errors. No display logic.
Clean split: line handler does protocol translation (JSON → objects/errors), logger does display.
Browser Logger
Plain console wrapper. No error formatting — that's
handleError's job.The browser logger exists for general non-error logging (
context.logger.info('loading page'),context.logger.debug(...)). All error handling goes throughlowdefy.handleError— the logger's.error()and.warn()are pass-throughs for the rare case someone logs a plain string at those levels.Browser Error Handling: One Path
lowdefy.handleErroris the single mechanism for all browser error handling. Removeerror.log()anderror.resolve()from client error classes. The_app.jsErrorBoundary and action error handler both uselowdefy.handleError.One path. No
.log()methods on error classes. No duplicate round-trips. SeehandleErrorsection above for implementation.Unresolved: Sentry / Log Drains
External services (Sentry, log drains) consume errors differently:
handleErrorafter location resolution.These are handled by
handleError, not by the logger or error classes. The pino serializer (usingextractErrorProps) ensures log drains get all error properties. Sentry gets the error instance before serialization.This doesn't need to change architecturally —
handleErroris the right place for Sentry capture. The simplification is thatextractErrorPropsis shared with the pino serializer, so JSON log output and Sentry capture get the same complete error data.Work Units (Briefs)
Brief 1: Remove
.print()andformatErrorMessage, AdderrorToDisplayString, Clean Up ConstructorsScope:
packages/utils/errors/src/,packages/utils/logger/What:
.print()method from ALL error classes (LowdefyError, ConfigError, PluginError, ServiceError, UserError).formatErrorMessage.jsentirely.errorToDisplayStringfunction to@lowdefy/errors(exported from main entry). This is the ONE function that formats any error for display:[Name] error.message+ received. Replaces.print()at all call sites.formatUiMessage.jsfrom@lowdefy/logger.formatBrowserErrorfrom browser logger.errorToDisplayStringfrom@lowdefy/errors.rawMessage→_messagefor consistency. Keep structured parts as properties (location,received,pluginType, etc.)._messageproperty for the raw input. Keep structured parts as properties (service,code,statusCode)..print().build/ConfigError.js— the build subclass with location resolution, suppression, and YAML handling in the constructor. Location resolution moves tohandleErrorandhandleWarning(Brief 5). Suppression moves to bothhandleError(for errors) andhandleWarning(for warnings). prodError escalation moves tohandleWarning. YAML parse error message formatting stays at the throw site (just string formatting, not a constructor concern). Thebuild/index.jsre-exports the baseConfigError.build/ConfigWarning.js— make ConfigWarning extend ConfigError. Remove location resolution, suppression, and prodError escalation from constructor. ConfigWarning carries{ name, message, configKey, checkSlug }. All behavior moves tohandleWarning(Brief 5). Extending ConfigError means it works witherrorToDisplayString,extractErrorProps, and the~eserializer. prodError escalation just collects the ConfigWarning directly (it IS a ConfigError).Why: Errors are dumb data carriers — handlers resolve. Four formatting functions (
formatErrorMessage,.print(),formatUiMessage,formatBrowserError) collapse into oneerrorToDisplayString. Unblocks Brief 2 (generic serializer). Consistent with how server and browser already work: errors carryconfigKey, handlers resolve location.Files:
packages/utils/errors/src/errorToDisplayString.js(new)packages/utils/errors/src/index.jspackages/utils/errors/src/PluginError.jspackages/utils/errors/src/ServiceError.jspackages/utils/errors/src/LowdefyError.jspackages/utils/errors/src/ConfigError.jspackages/utils/errors/src/UserError.jspackages/utils/errors/src/build/ConfigError.js(delete —build/index.jsre-exports base ConfigError)packages/utils/errors/src/build/ConfigWarning.js(simplify: remove.print(), remove location resolution, remove suppression from constructor)packages/utils/errors/src/formatErrorMessage.js(delete)packages/utils/logger/src/formatUiMessage.js(delete)packages/utils/logger/src/browser/createBrowserLogger.jspackages/utils/logger/src/cli/createCliLogger.jspackages/utils/logger/src/node/createNodeLogger.jserror.print()across the codebase (replace witherrorToDisplayString(error))Serialization note:
extractErrorPropscaptures bothmessage(formatted, as passed tosuper()) and_message(raw input). Deserialization viaObject.create + assignsetsmessageas a plain string property on the instance, which shadows Error.prototype's message. No constructor is called, so no double-formatting.Checklist:
errorToDisplayString.js— formats[Name] message+ received for any errorerrorToDisplayStringfrom@lowdefy/errorsmain entry.print()from LowdefyError.print()from ConfigError.print()from PluginError.print()from ServiceError.print()from UserErrorformatErrorMessage.jsformatUiMessage.jsfrom loggerformatBrowserErrorfrom browser loggerrawMessage→_message_messagepropertybuild/ConfigError.js, updatebuild/index.jsto re-export base ConfigErrorbuild/ConfigWarning.js: extend ConfigError, remove location resolution/suppression/prodError from constructorerror.print()call sites witherrorToDisplayString(error)formatUiMessageimports witherrorToDisplayStringpnpm --filter=@lowdefy/errors testpnpm --filter=@lowdefy/logger testBrief 2: Enhance Helpers Serializer
~e, Remove Per-Class SerializersScope:
packages/utils/helpers/src/serializer.js,packages/utils/errors/src/*.jsWhat:
extractErrorPropsfunction to helpers (shared by serializer~ereplacer and pino serializer).~ereplacer to useextractErrorProps(preserves all enumerable properties, not just name/message/value).~ereviver to reconstruct correct Lowdefy error class viaObject.create(ErrorClass.prototype)+ property assignment. The serializer imports Lowdefy error classes directly — no registration, no config passing, no error type map at call sites.extractErrorPropsfrom helpers for pino serializer use..serialize()and.deserialize()from all error classes.deserializeError.jsfrom errors package./api/client-errorendpoint: no longer callsdeserializeError(data). Receives flat object, usesconfigKeyfor location resolution, logs properties directly. Effectively becomes a specializedhandleErrorfor client-reported errors.Why: One serializer, one way to serialize errors. The
~emarker is the single mechanism — consistent with~d,~r,~k,~l. Removes ~150 lines of per-class serialization code. Everyserializer.copy()call automatically gets correct class reconstruction with zero call-site changes.Files:
packages/utils/helpers/src/serializer.js(enhance~ereplacer/reviver, addextractErrorProps, add direct imports of Lowdefy error classes)packages/utils/errors/src/ConfigError.js(remove.serialize(),.deserialize())packages/utils/errors/src/LowdefyError.js(remove.serialize(),.deserialize())packages/utils/errors/src/PluginError.js(remove.serialize(),.deserialize())packages/utils/errors/src/ServiceError.js(remove.serialize(),.deserialize())packages/utils/errors/src/deserializeError.js(delete)packages/utils/errors/src/server/index.js(removedeserializeErrorre-export)packages/api/src/routes/log/logClientError.js(simplify: flat object, no class reconstruction)error.serialize(),error.deserialize(), anddeserializeError()across the codebaseNote on transport: The browser → server transport path does NOT use the serializer. Browser
handleErrorpicks specific fields explicitly (excludesreceivedetc.) and POSTs a flat object. The server endpoint works with that flat object directly. See target architecture "One Generic Error Serializer" section.Consequence to evaluate: The helpers
serializer.copy()is used throughout the codebase for deep cloning. Errors appearing in cloned data will now be fully preserved (correct class, all props) instead of becoming genericErrorinstances. This is generally better but needs testing.Checklist:
extractErrorPropsfunction to helpers (captures all enumerable props +message,name,stack,cause)extractErrorPropsfrom@lowdefy/helpers~ereplacer to useextractErrorProps~ereviver:Object.create(ErrorClass.prototype)+ assign, withlowdefyErrorTypesmap (direct imports including UserError).serialize()from ConfigError, LowdefyError, PluginError, ServiceError.deserialize()from ConfigError, LowdefyError, PluginError, ServiceErrordeserializeError.jsdeserializeErrorre-export fromserver/index.jslogClientError.js: flat object handling, no class reconstructionerror.serialize(),error.deserialize(),deserializeError()pnpm --filter=@lowdefy/helpers testpnpm --filter=@lowdefy/errors testBrief 3: Remove Env-Specific Entry Points
Scope:
packages/utils/errors/src/client/,packages/utils/errors/src/server/What:
client/ConfigError.jsandclient/LowdefyError.js— empty subclasses after removing.resolve()and.log().client/index.js— no subclasses remain, callers import from@lowdefy/errorsdirectly.server/index.js— after removingdeserializeError(Brief 2), nothing remains. Callers import from@lowdefy/errorsdirectly.import from '@lowdefy/errors/client'andimport from '@lowdefy/errors/server'toimport from '@lowdefy/errors'.Why: No env-specific subclasses needed. Errors are dumb data carriers — handlers resolve. The only entry point with real content is
/build(for build utilities likeConfigWarning,ConfigMessage,resolveConfigLocation).Files:
packages/utils/errors/package.json(remove./clientand./serverfromexports)packages/utils/errors/src/client/ConfigError.js(delete)packages/utils/errors/src/client/LowdefyError.js(delete)packages/utils/errors/src/client/index.js(delete)packages/utils/errors/src/server/index.js(delete)@lowdefy/errors/clientand@lowdefy/errors/server(update imports)Checklist:
./clientand./serverfrompackage.jsonexportsclient/ConfigError.jsclient/LowdefyError.jsclient/index.jsserver/index.jsimport from '@lowdefy/errors/client'→import from '@lowdefy/errors'import from '@lowdefy/errors/server'→import from '@lowdefy/errors'pnpm --filter=@lowdefy/errors testBrief 4: Simplify Node Logger — Plain Pino
Scope:
packages/utils/logger/src/node/What:
attachLevelMethodsfunction entirely (80 lines). No color sub-methods.buildMergeObjfunction.defaultErrSerializerwithextractErrorPropsfrom helpers.messageKey(msg). All log calls use the standard two-argument formlogger.level(mergeObj, messageString)— explicit, no magic.createNodeLoggerreturns a plain pino logger. Callers use standard pino API:logger.info({ color: 'blue' }, 'text').createDevLoggerstays as a thin wrapper setting defaults (sync dest, no pid/hostname).context.logger.info.blue(text)→context.logger.info({ color: 'blue' }, text)).Why: The node logger's job is to produce structured JSON. All the display logic (color sub-methods, error formatting, source logging) belongs in the CLI logger, not in pino.
Files:
packages/utils/logger/src/node/createNodeLogger.jspackages/utils/logger/src/dev/createDevLogger.jspackages/servers/server-dev/pages/api/request/[pageId]/[requestId].js(usescontext.logger.info.gray())Checklist:
attachLevelMethodsfunction (80 lines)buildMergeObjfunctiondefaultErrSerializerwithextractErrorPropsfrom helperscreateNodeLoggerreturns plain pino loggercreateDevLoggerstill works as thin wrapper[pageId]/[requestId].js:context.logger.info.gray()→context.logger.info({ color: 'gray' }, ...)pnpm --filter=@lowdefy/logger testBrief 5: Create
handleErrorandhandleWarning, Unify Browser Error Path, Update Call SitesScope:
packages/build/src/,packages/servers/*/,packages/api/,packages/client/What:
context.handleError— a separate async function for the full error pipeline. NOT a logger method. Lives oncontext(server/build) orlowdefy(browser). Created during context setup — no extra parameter passing sincecontextis already threaded everywhere:context.handleError = createHandleError({ pinoLogger, buildDir, sentry, req, user })— per-request, async.context.handleError = createBuildHandleError({ pinoLogger, keyMap, refMap, directories })— per-build, sync. Handles all three location resolution paths (configKey, operatorLocation, filePath+lineNumber). Does NOT handle suppression — that stays at collection time incollectExceptions/tryBuildStep.lowdefy.handleError = createBrowserHandleError(lowdefy)— per-app, async.context.handleWarning— build-only. Creates ConfigWarning (extends ConfigError) from params, then handles suppression, prodError escalation (collects the ConfigWarning directly), location resolution, dedup, and logging. Created per-build, lives oncontext. SeehandleWarningpseudocode in target architecture.context.handleWarning = createHandleWarning({ pinoLogger, context })— per-build, sync.resolveErrorLocation— unified function that handles all three resolution paths from currentConfigMessage.js: configKey → keyMap → refMap, operatorLocation → refMap, filePath + lineNumber → raw path join. Signature:resolveErrorLocation(errorOrData, { keyMap, refMap, configDirectory }). Mutates first arg, setting.source._app.jsErrorBoundary in both server and server-dev to uselowdefy.handleErrorinstead oferror.log(). Wiring note:lowdefyRef.currentdoesn't havebasePathat ErrorBoundary level (it's set later byinitLowdefyContextfromrouter.basePath). This is fine — the currenterror.log()already falls back to'', giving a relative/api/client-errorURL.createBrowserHandleErroruseslowdefy?.basePath ?? ''to match. ThehandleErrorfunction is created early and attached tolowdefyRef.current. RenamecreateLogError.js→createHandleError.js(createslowdefy.handleError).await context.handleError(error):apiWrapper,runRoutine,serverSidePropsWrapper,logCollectedErrors,buildRefscatch, browserlowdefy.handleError,_app.jsErrorBoundary. Note:serverSidePropsWrappercurrently callslogErrorfire-and-forget — change toawait. This is safe becausegetServerSidePropsruns before any response is sent; the catch block fully executes before the throw propagates to Next.js.context.logger.warn({ configKey, ... })calls in build tocontext.handleWarning({ ... }). Plain string warnings stay oncontext.logger.warn.readFileSync, no event loop blocking. The async approach was fine all along; the problem was putting it inside a logger method.Why: Clean separation of concerns. Logger is sync, just logs. Error handling is async, does resolution + structured data + Sentry + logging. Warning handling is sync, does suppression + resolution + dedup + logging. Both are explicit — not hidden behind logger methods. Zero function signature changes —
contextis already passed everywhere. One browser error logging path — no duplicate round-trips to/api/client-error.Files:
packages/build/src/createContext.js(wire upcontext.handleErrorandcontext.handleWarning)packages/build/src/utils/logCollectedErrors.jspackages/servers/server/lib/server/log/logError.js(rename tohandleError.js)packages/servers/server-dev/lib/server/log/logError.js(rename tohandleError.js)packages/servers/server/lib/server/apiWrapper.jspackages/servers/server-dev/lib/server/apiWrapper.jspackages/servers/server/lib/server/serverSidePropsWrapper.js(callslogError)packages/servers/server-dev/pages/api/page/[pageId].js(callslogErrorin loop)packages/api/src/routes/endpoints/runRoutine.jspackages/client/src/createLogError.js(rename tocreateHandleError.js)packages/servers/server/pages/_app.jspackages/servers/server-dev/pages/_app.jspackages/cli/src/utils/errorHandler.js(no rename — this is a top-level catch-all, not the samehandleErrorpattern. Just update it to useerrorToDisplayStringand the new logger API.)packages/cli/src/utils/runCommand.jslogError()across the codebasecontext.logger.warn({ configKey, ... })in build (change tocontext.handleWarning(...))packages/utils/errors/src/build/resolveConfigLocation.js(used byhandleWarningfor location resolution)packages/utils/errors/src/build/ConfigMessage.js(used byhandleWarningfor suppression checking)Note on
resolveErrorLocation: This function absorbs the three resolution strategies from the currentConfigMessage.jsmethods (resolveOperatorLocation,resolveRawLocation, and the configKey path). Unified signature:resolveErrorLocation(errorOrData, { keyMap, refMap, configDirectory }). It mutates the first argument, setting.source. TheconfigDirectoryparameter is only needed by the build (for rawfilePath+lineNumberresolution); server omits it. The function readsconfigKey,operatorLocation,filePath, andlineNumberfrom the error/data object to determine which resolution path to use (same cascade as currentConfigMessage.js).Note on ConfigError suppression: Suppression stays at collection time. Currently
tryBuildStep/collectExceptionscheckerror.suppressed— after the refactor, ConfigError no longer sets.suppressed. Instead,collectExceptions/tryBuildStepcallshouldSuppress()directly and skip suppressed errors before adding them tocontext.errors. This keeps error counts correct —logCollectedErrorsthrows withcontext.errors.length, so suppressed errors must never be collected.handleWarningalso callsshouldSuppressfor warnings.handleErrordoes NOT check suppression — it just logs whatever it receives.Note:
loadMapsin the server pseudocode is extracted from the existinglogErrorimplementations. Not new logic — just pulled out of the current serverlogError.jsfunctions.Migration notes:
logError({ context, error })→await context.handleError(error)at the few catch points.context.logger.warn({ message, configKey, ... })→context.handleWarning({ message, configKey, ... })in build validation code.if (error.suppressed) return;intryBuildStep/collectExceptions→ replace withif (shouldSuppress({ configKey: error.configKey, keyMap: context.keyMap, checkSlug: error.checkSlug })) return;(suppression stays at collection time, but usesshouldSuppress()instead of the.suppressedproperty).Checklist:
createBuildHandleErrorfunction (sync, resolution + logging, safety wrapper)createHandleWarningfunction (suppression, prodError escalation, location resolution, dedup, logging)resolveErrorLocationfunction (unified 3-path resolution)context.handleErrorandcontext.handleWarningincreateContext.jscreateLogError.js→createHandleError.js(browser)createBrowserHandleErrorwithlowdefy?.basePath ?? ''fallback_app.jsErrorBoundary in server:error.log()→lowdefy.handleError(error)_app.jsErrorBoundary in server-dev: samelogError.js→handleError.jslogError.js→handleError.jsapiWrapper.js(server):logError→context.handleErrorapiWrapper.js(server-dev): sameserverSidePropsWrapper.js: fire-and-forget →await context.handleError(error)[pageId].js(server-dev):logError→context.handleErrorrunRoutine.js:logError→context.handleErrorlogCollectedErrors.js: iteratecontext.errors, callcontext.handleErrorfor eacherrorHandler.js(CLI): useerrorToDisplayStringand new logger APIerror.suppressedchecks intryBuildStep/collectExceptionswithshouldSuppress()callscontext.logger.warn({ configKey, ... })calls →context.handleWarning({ ... })pnpm --filter=@lowdefy/build testpnpm --filter=@lowdefy/api testBrief 6: Simplify
context.logger— Plain Pino, Zero Monkey-PatchingScope:
packages/build/src/What:
context.loggerbecomes a plain pino logger. 4 sync methods. Remove ALL wrappers fromcreateContext.js— both the_lowdefyWrappederror wrapper and the warn wrapper.context.logger.warnis just pino's.warn().context.handleWarning(Brief 5).Why:
context.loggershould just be a logger — zero surprises. Error handling moves tocontext.handleError, warning handling moves tocontext.handleWarning. Both are explicit, not hidden behind logger methods. Callers that pass{ configKey, checkSlug, prodError }usecontext.handleWarning— they know they're doing something special. Callers that just log a string usecontext.logger.warn.Files:
packages/build/src/createContext.js(remove all logger wrappers)Checklist:
_lowdefyWrappedwarn wrapper fromcreateContext.js_lowdefyWrappederror wrapper fromcreateContext.js_lowdefyContextreference on loggerseenSourceLinesfrom context (now inhandleWarning)context.loggeris just the plain pino logger passed increateContext.jspnpm --filter=@lowdefy/build testBrief 7: Simplify CLI Logger
Scope:
packages/utils/logger/src/cli/What:
createPrintintocreateCliLogger. RemovecreatePrint.jsas separate module. Keep Ora for interactive, console for CI.logger.error(input),logger.info(input)..message): log source in blue if present, thenerrorToDisplayString(error)in default color for level (red for error, yellow for warn). For LowdefyError and non-Lowdefy errors (i.e. not ConfigError, PluginError, ServiceError, UserError), also logerror.stack— these represent internal bugs or unexpected failures where the stack trace is critical for debugging. Lowdefy error types usesource/location instead of stack traces to point to root cause.msg: logmsg, applycolor/spin/succeedfrom object properties.JSON.stringify(input, null, 2).createCliLogger(only one Ora spinner should be active per process). The memoisation moves fromcreatePrintintocreateCliLoggeritself.@lowdefy/logger/cli.Why: One module, clear input handling logic, no indirection.
Files:
packages/utils/logger/src/cli/createCliLogger.jspackages/utils/logger/src/cli/createPrint.js(merge into createCliLogger or delete)packages/utils/logger/src/cli/index.jsChecklist:
createPrintintocreateCliLoggercreatePrint.js(or keep as internal)error.sourcein blue, thenerrorToDisplayString(error)error.stackfor LowdefyError and non-Lowdefy errorsmsg,color,spin,succeed@lowdefy/logger/cliexportspnpm --filter=@lowdefy/logger testBrief 8: Simplify CLI Line Handler
Scope:
packages/utils/logger/src/cli/createStdOutLineHandler.jsWhat:
parsed.err: reconstruct error from flat pino JSON object (see below), calllogger[level](error).logger[level]({ msg: parsed.msg, source, color, spin, succeed }). Plain message forwarding — no error reconstruction needed.errserializer writes flat objects viaextractErrorProps(not~ewrapped). The line handler reconstructs these using the samelowdefyErrorTypesmap the helpers serializer uses (direct import).Object.create(ErrorClass.prototype)+ property assignment — same pattern as the~ereviver, just without the~ewrapper.Files:
packages/utils/logger/src/cli/createStdOutLineHandler.jsChecklist:
level,msg,err,source,color,spin,succeedparsed.err: reconstruct error viaObject.create + assignwithlowdefyErrorTypesmap, calllogger[level](error){ msg, source, color, spin, succeed }tologger[level]{ context }signature (extractcontext.loggerinternally)pnpm --filter=@lowdefy/logger testBrief 9: Simplify Browser Logger
Scope:
packages/utils/logger/src/browser/What:
formatBrowserErrorfunction.handleError's job now.console.error/warn/info/debug.lowdefy.handleError.Files:
packages/utils/logger/src/browser/createBrowserLogger.jsChecklist:
formatBrowserErrorfunctionpnpm --filter=@lowdefy/logger test