Debugging and error handling
This guide covers troubleshooting LogTape issues, debugging configuration problems, understanding LogTape's internal error handling mechanisms, and finding where in your code a log record was made.
Understanding LogTape's error handling
LogTape is designed to be resilient and non-intrusive. When errors occur in the logging system itself, LogTape handles them gracefully to prevent disrupting your application.
Meta logger
LogTape uses a special internal logger called the meta logger to report its own operational issues. The meta logger has the category ["logtape", "meta"] and handles:
- Sink errors and exceptions
- Configuration issues
- Internal LogTape errors
import { , } from "@logtape/logtape";
await ({
: {
: (),
},
: [
// Configure the meta logger to see LogTape's internal messages
{ : ["logtape", "meta"], : ["console"], : "warning" },
{ : ["app"], : ["console"], : "info" }
]
});TIP
It's recommended to configure the meta logger with a separate sink so you can easily notice if logging itself fails or is misconfigured.
Sink error handling
When a sink throws an exception, LogTape:
- Suppresses the exception to prevent application crashes
- Logs the error to the meta logger
- Continues processing other sinks
- Prevents infinite recursion by bypassing the failing sink for meta logs
import {
,
,
type LogRecord,
type ,
} from "@logtape/logtape";
// Example: A sink that sometimes fails
const : = (: LogRecord) => {
if (.() < 0.1) { // 10% failure rate
throw new ("Sink temporarily unavailable");
}
.("Reliable log:", .);
};
await ({
: {
: ,
: () // Meta logger will report sink failures here
},
: [
{ : ["logtape", "meta"], : ["meta"], : "error" },
{ : ["app"], : ["unreliable"], : "info" }
]
});Configuration errors
LogTape validates your configuration and throws specific errors when it detects problems. Understanding these error types and their common causes will help you quickly diagnose and fix configuration issues.
ConfigError
LogTape throws ConfigError for configuration-related issues. This is a specific error type that indicates problems with your LogTape configuration rather than application logic errors. Common scenarios include attempting to reconfigure LogTape without the reset() flag, duplicate logger configurations, or mismatched async/sync configurations.
import { , , } from "@logtape/logtape";
try {
await ({
: { : () },
: [
{ : ["app"], : ["console"], : "info" }
]
});
// This will throw ConfigError: Already configured
await ({
: { : () },
: [
{ : ["app"], : ["console"], : "debug" }
]
});
} catch () {
if ( instanceof ) {
.("Configuration error:", .);
// Handle configuration error appropriately
}
}Common configuration errors
Duplicate configuration
This error occurs when you try to configure multiple loggers for the same category. LogTape requires each category to have a unique configuration to avoid conflicts and ambiguous behavior.
import { , } from "@logtape/logtape";
try {
await ({
: { : () },
: [
{ : ["app"], : ["console"] },
{ : ["app"], : ["console"] } // Duplicate!
]
});
} catch () {
.();
// "Duplicate logger configuration for category: [\"app\"]"
}Missing reset() flag
LogTape prevents accidental reconfiguration by default. If you need to change the configuration after it's already been set up, you must explicitly use the reset: true flag. This safety mechanism helps prevent configuration conflicts in complex applications where multiple parts might try to configure LogTape.
import { , } from "@logtape/logtape";
// First configuration
await ({
: { : () },
: [{ : ["app"], : ["console"] }]
});
try {
// This fails without reset: true
await ({
: { : () },
: [{ : ["app"], : ["console"] }]
});
} catch () {
.();
// "Already configured; if you want to reset, turn on the reset flag."
}Here's correct way to reconfigure LogTape with the reset() flag:
await ({
: true, // Add this flag
: { : () },
: [{ : ["app"], : ["console"] }]
});Async/sync configuration mismatch
This error occurs when you try to use configureSync() while there are still active async disposables (like async sinks) from a previous configuration. LogTape cannot mix synchronous and asynchronous configurations because they have different disposal mechanisms. You must properly dispose of async resources before switching to a sync configuration, or use configure() instead.
import {
,
,
,
,
} from "@logtape/logtape";
// Configure with async sink
await ({
: {
: (async () => {
await ("/logs", { : "POST", : .() });
})
},
: [{ : ["app"], : ["async"] }]
});
try {
// This fails because async disposables are still active
({
: { : () },
: [{ : ["app"], : ["console"] }]
});
} catch () {
.();
// "Previously configured async disposables are still active..."
}Inspecting the effective configuration
This API is available since LogTape 2.4.0.
When a logger's records do not show up where you expect, getConfig() tells you what you configured, but not how a particular logger's level, its ancestors' sinks, and their filters combine. inspectLogger() explains that for one logger in the current execution context:
import { , , } from "@logtape/logtape";
await ({
: { : () },
: [
{ : ["my-app"], : "info", : ["console"] },
{ : ["my-app", "db"], : "debug" },
],
});
const = (["my-app", "db"], { : "debug" });
for (const of .) {
.(., ., ., .);
}
// console [ "my-app" ] disabled [
// { category: [ "my-app", "db" ], lowestLevel: "debug" },
// { category: [ "my-app" ], lowestLevel: "info" }
// ]The "debug" record is accepted by ["my-app", "db"], but the console sink belongs to ["my-app"], whose lowestLevel is "info". Configuring the child with parentSinks: "forward" would let the record through; see Forwarding sinks regardless of ancestor levels.
The report contains:
source- Whether the report reflects a scoped configuration set by
withConfig()orwithConfigSync()("scoped"), the process-global configuration ("global"), or neither ("unconfigured"). categoryPrefixandeffectiveCategory- The category prefix set by
withCategoryPrefix(), and the category records are actually dispatched under. loggers- Each category from the root to the effective category, with whether it is configured and its own
lowestLevel,parentSinks, sink identifiers, and filter identifiers. sinkPaths- Every way a record can reach a sink, in the order the sinks are called. A sink that would receive a record more than once appears more than once. Each path names the category that has the sink, the
lowestLevelgates on the way along with the categories that supply them, and a status. filters- The filters that apply, and the category that supplies them.
inheritanceBoundary- The nearest category configured with
parentSinks: "override", beyond which no sinks are inherited, or the root. status- Whether records can reach any sink.
Each status is one of:
"enabled"- Records are delivered without consulting any custom filter.
"conditional"- Records are delivered only if custom filters accept them. Since the outcome of a custom filter is only known when a record is logged, the report cannot tell more than that.
"disabled"- Records are never delivered, because a
lowestLevelgate or a level filter rejects them.
Pass the level option to evaluate statuses for records of that level. Without it, a status tells whether records of some level can be delivered, and each path's lowestLevel tells which levels.
inspectLogger() has no side effects: it does not log anything, invoke sinks or filters, evaluate lazy properties, or create loggers. Sinks are opaque to it, so a sink that filters records by itself, such as one made by withFilter() or fingersCrossed(), may still drop records that are reported as delivered.
TIP
The report makes category mistakes visible. inspectLogger("my-app:http") shows a single category segment "my-app:http" whose only ancestor is the root, not a child of ["my-app"]; use ["my-app", "http"] instead.
Showing where log records come from
This API is available since LogTape 2.4.0.
Browser consoles show a link to the place that called console.log(), which is always inside LogTape's console sink rather than the code that logged the message. To find the logging call itself during development, you can let LogTape capture the source location of each logging call and show it in the formatted output.
Capturing and showing are configured separately, so that, for example, a file sink can keep the locations without adding them to every console message. Turn on capturing with the ~LoggerConfig.captureSourceLocation option of a logger configuration, and showing with the sourceLocation option of a formatter:
import {
,
,
,
} from "@logtape/logtape";
await ({
: {
: ({
: ({ : true }),
}),
},
: [
{
: "my-app",
: "debug",
: ["console"],
: true,
},
],
});The console then shows the location after the category, e.g.:
12:34:56.789 INF my-app (http://localhost:5173/src/main.ts:42:7) Hello, world!The text formatters take the same option, ~TextFormatterOptions.sourceLocation, and accept a function that renders the location, e.g., to show only the file name:
import { } from "@logtape/logtape";
const = ({
: ({ , }) =>
`${.(.("/") + 1)}:${}`,
});
// 2023-11-14 22:13:20.000 +00:00 [INF] my-app (main.ts:42): Hello, world!Captured locations are in the ~LogRecord.sourceLocation field of log records, as SourceLocation objects with file, line, and column fields, so that custom sinks and formatters can use them as well. The other built-in formatters, such as the JSON Lines and logfmt formatters, and the formatters of other packages, such as @logtape/pretty, do not output them.
WARNING
Capturing a source location builds a stack trace for every logging call whose level is not filtered out by the logger's lowestLevel, which costs several microseconds per call, or tens of microseconds on Deno. Use it for development, and leave it off in production. While no configuration turns it on, it costs next to nothing.
Which loggers capture source locations
The ~LoggerConfig.captureSourceLocation option is inherited by child categories: a logger without the option uses the setting of its nearest ancestor that has it, and the root logger's default is false. So you can turn it on for a whole application and off for some noisy part of it:
await ({
: { : () },
: [
{ : "my-app", : ["console"], : true },
{ : ["my-app", "hot-loop"], : false },
],
});The inheritance does not depend on the parentSinks option. Within a withConfig() or withConfigSync() callback, the scoped configuration's loggers decide instead, just as they decide the sinks. Under withCategoryPrefix(), the setting of the prefixed category applies. The meta logger never captures source locations.
Which location is reported
The location is where your code calls a logging method such as info() or error(), in every form of the call:
- For a logger made by
with()orgetChild(), it is where the logging method of that logger is called, not where the logger was made. - For tagged templates, callbacks, and lazy or asynchronous property callbacks, it is where the logging method is called. The location is captured before any callback runs and before the record reaches any sink, so buffering sinks such as
fingersCrossed()keep it as well. - For
warn()orerror()with anError, it is where the logging method is called, not where the error was thrown. emit()does not capture a location, but keeps thesourceLocationfield of the record you pass to it.- If you wrap LogTape's logging methods in functions of your own, it is the call inside your wrapper. If you pass a logging method as a callback, e.g., to
Promise.prototype.then(), it is where that callback is called, which can be inside the runtime.
Limitations
The location is read from the runtime's stack trace, so it is not always available or exact:
- It points to the code that actually runs. LogTape does not resolve source maps, so bundled or minified code may report positions in the generated code, unless the runtime applies source maps to stack traces itself, as Deno and Bun do for TypeScript, and Node.js does with
--enable-source-maps. - Runtimes format stack traces differently. This feature has been tested on Node.js, Deno, Bun, Chromium, and Firefox; it has not been tested on Safari or other runtimes. The column may point to a different part of the call depending on the runtime.
- JavaScriptCore, the engine of Bun and Safari, omits the frame of a function that ends with a call in tail position, e.g., an arrow function like
() => logger.info("…")orreturn logger.info("…"). For such calls, the location of the caller is reported, or no location at all. - If the stack trace cannot be read unambiguously, for example, because
Error.stackTraceLimitis too small,Error.prepareStackTracefails or returns an unusual format, or the code was created byeval(), the location is left out rather than guessed. The logging call still succeeds. A customError.prepareStackTracethat adds or removes frames while keeping the usual format can make the location inaccurate. - The clickable link in the browser console still points to LogTape's console sink. The location only appears in the message text.