@log-kit/core
v0.1.2
Published
Isomorphic, zero-dependency log engine providing structured types, i18n formatting, deduplication, and client sanitization.
Maintainers
Readme
@log-kit/core
Isomorphic, zero-dependency logging primitives, structured types, and a lightweight logger engine.
Designed for high-throughput Node.js, browser, edge runtime, and serverless environments.
@log-kit/core provides strongly-typed logging contracts with native i18n support, runtime
deduplication, and audience-based filtering, with zero runtime dependencies.
Features
- Isomorphic & zero-dependency: runs identically in Node.js, browsers, Bun, Deno, and edge environments.
- Strongly typed contracts: structured interfaces (
Log,LogClient,SerializedError) and string-union constants (LogType,LogErrorType,LogAudience). - Rust
serdeparity: constants and properties uselowerCamelCase, matchingserde(rename_all = "camelCase")mapping. - Context-aware
Logger: child scopes, level filtering, time-window deduplication, and pluggable transports. - Environment-aware console output:
minLevelalone decides whether a log is emitted; the default transport separately uses a resolvedisDevflag to decide how much detail to print for logs that pass, keeping raw payloads out of production consoles by default without silently overriding your level configuration. Bundler-specific dev flags (e.g. Vite/SvelteKit'simport.meta.env.DEV) can be passed in vianew Logger({ isDev }). - Built-in utilities: structured error serialization with sensitive-key redaction (including Axios-style
.responseerrors), deduplication, audience sanitization/filtering, multi-log aggregation, and type guards — every utility is a standalone function, independently importable from@log-kit/core/utils.
Installation
npm install @log-kit/core
# or
pnpm add @log-kit/core
# or
yarn add @log-kit/core
# or
bun add @log-kit/coreQuick start
1. Logger
import { Logger, LogType, LogErrorType } from "@log-kit/core";
const logger = new Logger({
namespace: "api-gateway",
minLevel: LogType.debug,
dedupeWindowMs: 1000 // suppress an identical log re-emitted within 1s
});
logger.info({
code: "USER_AUTH_SUCCESS",
message: "User usr_1024 logged in from 192.168.1.1"
});
// Scoped child logger — namespaces are joined with ":"
const authLogger = logger.child({ group: "oauth-flow" });
authLogger.warn({
code: "ERR_RATE_LIMIT",
message: "IP exceeded maximum auth attempts",
errorType: LogErrorType.rateLimit,
status: 429
});Or use the createLogger factory if you'd rather not use new:
import { createLogger } from "@log-kit/core";
const logger = createLogger({ namespace: "api-gateway" });Each level method (trace, debug, info, log, warn, error, fatal) returns the
normalized Log object it emitted, or null when the entry was filtered out by minLevel
or suppressed as a duplicate.
Note: templating/interpolation (
{key}-style placeholders) is intentionally out of scope for this package — build the finalmessagestring yourself, or reach for a dedicated templating package.
2. Custom transports
By default logs are written to the console. minLevel is the only thing that decides whether a
log is emitted — the default console transport never re-filters by severity. It does use
isDev for one thing: outside production, it hides log.details (arbitrary, unredacted,
caller-supplied context) from the console, since — unlike log.error, which passes through
serializeError's redaction — nothing in this package sanitizes details. log.error is
always shown for error/fatal logs, since that's the point of logging an error. Provide
transports to ship logs anywhere else (a file, an HTTP sink, an observability platform) —
transport errors are caught internally so one broken transport can't take down the others or the
calling app. An empty transports: [] is treated the same as omitting it (falls back to the
console transport) — pass a no-op transport ([() => {}]) if you want to silence output entirely.
import { Logger, type LogTransportHandler } from "@log-kit/core";
const shipToDatadog: LogTransportHandler = (log) => {
fetch("https://http-intake.logs.datadoghq.com/v1/input", {
method: "POST",
body: JSON.stringify(log)
});
};
const logger = new Logger({ transports: [shipToDatadog] });isDev (whether log.details gets shown) defaults to this package's own isDev() utility,
which only checks process.env.NODE_ENV. That's the standard convention in Node and in
bundlers that replace it (webpack, esbuild, Next.js), but Vite and SvelteKit expose their dev
flag as import.meta.env.DEV instead — isDev()'s process check runs before any
bundler-side text replacement could apply, so it can't see that flag on its own. Pass it in
explicitly:
import { Logger, isDev } from "@log-kit/core";
// In a Vite/SvelteKit app:
const logger = new Logger({ isDev: import.meta.env.DEV });
// Or, to keep a graceful fallback for environments without `import.meta.env`
// (e.g. this same code also running under plain Node/Jest):
const logger = new Logger({ isDev: isDev(import.meta.env.DEV) });isDev is resolved once per Logger instance at construction time (not re-checked on every
log), and child() inherits the parent's resolved value unless a child explicitly overrides it.
For transports that need one-time setup (opening a file handle, building an HTTP client bound to
a resolved endpoint, ...) or want to read this Logger's resolved config (its resolved
isDev, minLevel, etc. — not just the raw options you passed in), use createTransports
instead of building the handler ahead of time. It's a factory, called once at construction and
appended to transports, kept as a separate option specifically so there's no runtime
type-detection between "is this a plain handler or a factory" — a transports entry is always
a plain (log) => void, and createTransports entries are always factories:
import { Logger, type ResolvedLoggerConfig, type LogTransportHandler } from "@log-kit/core";
function createFileTransport(config: ResolvedLoggerConfig): LogTransportHandler {
const stream = openLogFile(config.namespace ?? "app"); // one-time setup
return (log) => stream.write(JSON.stringify(log) + "\n");
}
const logger = new Logger({
createTransports: (config) => [createFileTransport(config)]
});3. Sanitizing logs for public APIs (toClientLog)
Strip internal stack traces, system namespaces, and non-client-visible fields before returning log payloads to frontend clients:
import { toClientLog, type Log } from "@log-kit/core";
const internalLog: Log = {
code: "ERR_DB_TIMEOUT",
message: "Database query timed out for query ID q_88",
namespaces: "internal-db-cluster",
stack: "Error: Query failed at Pool.query...",
details: { internalHost: "10.0.0.5" }
};
const publicLog = toClientLog(internalLog);
// { code: "ERR_DB_TIMEOUT", message: "..." }4. Serializing caught errors (serializeError)
Handles plain Errors, primitive throws, and structured HTTP-client errors (e.g. Axios) that
carry a .response. Sensitive-looking keys (password, token, authorization, cookie, ...)
are redacted wherever this function touches external data — the error's own properties,
response.headers, and response.data — as a best-effort safety net against accidentally
logging credentials. Redaction recurses into nested objects/arrays (default 3 levels deep, with
circular-reference protection), so a nested response.data.user.password is caught too, not
just a top-level field:
import { serializeError } from "@log-kit/core";
try {
await axios.get("/users/1");
} catch (err) {
const serialized = serializeError(err);
// { name: "AxiosError", message: "...", code: "ERR_BAD_REQUEST",
// response: { status: 404, statusText: "Not Found",
// data: { user: { password: "[REDACTED]" } },
// headers: { authorization: "[REDACTED]", "content-type": "application/json" } } }
}5. Redacting sensitive keys directly (redactObject / redactValue)
The same redaction serializeError uses internally is available standalone, for scrubbing any
plain object (e.g. request headers) or arbitrary value (object, array, or primitive) before you
hand it to a transport or a details payload:
import { redactObject, redactValue } from "@log-kit/core";
redactObject({ userId: "u1", password: "hunter2" });
// { userId: "u1", password: "[REDACTED]" }
redactObject({ user: { profile: { password: "hunter2" } } });
// { user: { profile: { password: "[REDACTED]" } } } — recurses by default
// redactValue also handles arrays and primitives, for when you don't know the shape ahead of time
redactValue([{ token: "a" }, { token: "b" }]);
// [{ token: "[REDACTED]" }, { token: "[REDACTED]" }]6. Building Log objects without a logger instance (createLog)
The same normalization Logger uses internally — building a complete Log from a level and
payload, serializing payload.error — is exported standalone so you can compose it into your
own pipeline:
import { createLog, LogType } from "@log-kit/core";
try {
await db.query(sql);
} catch (error) {
const log = createLog(LogType.error, { code: "ERR_DB_QUERY", error });
// log.error -> SerializedError, log.stack derived automatically
}7. Aggregating multiple logs (toLog)
Collapse a batch of related logs (e.g. several field-validation failures) into a single, localizable, displayable log:
import { toLog } from "@log-kit/core";
const summary = toLog([
{ code: "ERR_EMAIL_INVALID", type: "warn", errorType: "validation", message: "Invalid email" },
{ code: "ERR_AGE_INVALID", type: "error", errorType: "validation", message: "Invalid age" }
]);
// summary.type === "error", summary.code === "error.validation.error"summary.audience is only set when you pass config.audiences (to the first requested
audience); otherwise it's left undefined, same as any other log with no explicit audience —
it does not silently default to LogAudience.admin.
8. Audience-based filtering (filterByAudience)
import { filterByAudience, LogAudience } from "@log-kit/core";
const clientVisibleLogs = filterByAudience(allLogs, LogAudience.client);9. Checking for errors in a batch (hasError)
import { hasError, LogType } from "@log-kit/core";
if (hasError(aggregatedLogs)) {
// at least one log is `error` or `fatal` (the ERROR_TYPES default)
}
hasError(aggregatedLogs, [LogType.warn, LogType.error, LogType.fatal]); // custom thresholdArchitecture
No Log class
Log is a plain data shape (see Type contracts), not a class. A class
would only add value here if Log needed private state, inheritance, or behavior tightly
bound to a single instance — it doesn't. Logs are plain objects created, passed around,
serialized, and compared by value across process/network boundaries (they need to survive a
JSON.stringify round-trip), which is exactly what plain objects are for. createLog() is the
functional equivalent of a constructor: it takes a level and a payload and returns a fully
normalized Log, with no hidden state and no prototype.
Logger is a class, and that's the right call here
Unlike Log, the logger engine genuinely owns per-instance state: its resolved config, its
transports, and a one-slot dedupe cache that has to persist across calls. That's real
encapsulated, mutable state tied to one instance's lifetime — exactly what a class is for, and
child() needs prototype-free, straightforward instantiation (new Logger({...})) to produce
scoped instances cleanly. createLogger() is provided alongside it for callers who'd rather
not use new.
Every method on Logger is deliberately thin — dispatch() reads like a short pipeline of
calls into ./utils (meetsMinLevel, createLog, isDuplicate, mergeNamespace, isDev)
rather than reimplementing that logic inline. The class owns state and orchestration; the
actual logic lives in standalone, independently testable functions that other packages can
also import without touching Logger at all.
Utilities reference
All utilities live under src/utils/ (one file per concern) and are re-exported from both
@log-kit/core and @log-kit/core/utils.
| Function | Description |
| --- | --- |
| createLog(type, payload) | Builds a normalized Log, serializing payload.error when present. |
| serializeError(error) | Converts an unknown thrown value into a SerializedError, including Axios-style .response errors. Never throws. |
| isDuplicate(logA, logB, options?) | Structural equivalence check between two logs. |
| toClientLog(log) | Strips server-only fields for public API responses. |
| filterByAudience(logs, targetAudience) | Filters logs by visibility scope. |
| toLog(logs, config?) | Reduces multiple logs into one representative log. |
| pickLogType(types) | Picks the most severe LogType from a list. |
| pickErrorType(errorTypes) | Picks the most specific LogErrorType from a list. |
| summarizeMessages(messages, type?) | Joins multiple messages into one summary string. |
| hasError(logs, types?) | Checks whether any log's type is in types (defaults to ERROR_TYPES: fatal, error). |
| meetsMinLevel(type, minLevel?) | Checks whether a severity level meets a minimum threshold. |
| mergeNamespace(parent?, child?) | Joins a parent/child namespace pair the same way Logger.child does. |
| isDev(fallback?) | Detects whether the current environment is non-production, via process.env.NODE_ENV (works in Node and in any bundler that statically replaces it — webpack, esbuild, Vite, Next.js, ...). |
| redactObject(obj, depth?) | Recursively redacts sensitive-looking keys (password, token, ...) on a plain object, up to depth levels (default 3), with circular-reference protection. |
| redactValue(value, depth?) | Same as redactObject, but for a value of unknown shape — object, array, or primitive. |
| isSensitiveKey(key) | Checks whether a key name looks like it holds a secret. |
| isLogType, isLogErrorType, isLogAudience | Runtime type guards for the string-union constants. |
Type contracts
All interfaces and type aliases live in src/types/types.d.ts.
export interface Log extends LogClient, LogErrorPayload, LogContext {
details?: Record<string, unknown>;
}
export interface LogClient extends LogLocalization {
type?: LogType;
status?: number;
logs?: Log[];
}
export interface SerializedError {
name: string;
message: string;
stack?: string;
code?: string | number;
response?: SerializedErrorResponse;
raw?: unknown;
[key: string]: unknown;
}
export interface LoggerOptions {
namespace?: string;
group?: string;
defaultAudience?: LogAudience;
minLevel?: LogType;
dedupeWindowMs?: number;
transports?: LogTransportHandler[];
isDev?: boolean;
createTransports?: (config: ResolvedLoggerConfig) => LogTransportHandler[];
}
export interface ResolvedLoggerConfig {
namespace?: string;
group?: string;
defaultAudience: LogAudience;
minLevel: LogType;
dedupeWindowMs: number;
isDev: boolean;
}LogType, LogErrorType, and LogAudience are as const string-union objects (not TypeScript
enums), so they compile away to plain string literals with no runtime enum object overhead
beyond the small lookup table itself, and compare correctly across module/bundle boundaries.
