26 KiB
logger
Professional structured logger. Zero external dependencies. Decoupled from
i18n. Dynamic transports with per-sink filtering. Async-aware dispatch.
Failure routing with deniedFor. Built-in adapters for Sentry, Datadog,
Logtail (Better Stack), Grafana Loki and OpenTelemetry.
Features
- 6 severity levels aligned with pino / Log4j / OpenTelemetry / Sentry:
TRACE,DEBUG,INFO,WARN,ERROR,FATAL. - Structured entries — category, context, tags, traceId, error, durationMs, source (auto-captured from stack in DEV).
- Lazy messages — pass a thunk, evaluated only if the entry passes the global level filter.
- globalContext merged into every entry (appVersion, env, sessionId, ...).
- Web Vitals integration —
registerWebVitals()turns LCP/INP/CLS/FCP/TTFB into structured log entries; every transport automatically becomes a RUM sink. child(ctx)— logger with extended context; shares history and transports.time(label)/timeEnd(label)— duration measurement withtimertag.- Dynamic transports —
addTransport(),subscribe(),removeAllTransports(). - Per-transport filters —
level(threshold cutoff) andfilter(arbitrary predicate) at the transport level. Threshold matches the convention used across pino/winston/Python/Java/Go/syslog/OTel. - Hybrid dispatch — sync loop, transports may return
Promise<void>for async work. Caller never awaits. - Failure routing — when a transport throws (sync) or rejects (async), a
synthetic ERROR entry is dispatched to the remaining transports with
deniedFor: [failedName]. Guarantees no cascade loop. - Built-in adapters —
sentryTransport,datadogTransport,logtailTransport,lokiTransport,otelTransport. All accept the SDK as an injected parameter sologgerstays dep-free.
Architecture
logger/
├── index.ts Barrel exports
├── types.ts LogLevel, LogEntry, LogInput, Transport, EngineLogger, ...
├── engine-logger.ts Factory createEngineLogger()
├── transports.ts consoleTransport, httpTransport, callbackTransport
├── source.ts captureSource(), extractError()
├── consts.ts ENGINE_NAME (`engine_logger`), LEVEL_LABELS, LEVEL_CONSOLE_METHOD,
│ ALL_LEVELS
├── vitals.ts registerWebVitals(logger, webVitalsSDK, options?)
├── adapters/
│ ├── sentry.ts sentryTransport(sentrySDK, options)
│ ├── datadog.ts datadogTransport(ddLogger, options)
│ ├── logtail.ts logtailTransport(logtailInstance, options)
│ ├── loki.ts lokiTransport({ url, labels, ... })
│ └── otel.ts otelTransport(otelLogger, options)
└── test/
└── logger.test.ts Unit tests (vitest, node env)
Alias
Configured in svelte.config.js:
alias: { $logger: 'src/arts/logger' }
Imports:
import { createEngineLogger, LogLevel, consoleTransport } from '$logger';
import { sentryTransport } from '$logger/adapters/sentry';
import { datadogTransport } from '$logger/adapters/datadog';
import { logtailTransport } from '$logger/adapters/logtail';
import { lokiTransport } from '$logger/adapters/loki';
import { otelTransport } from '$logger/adapters/otel';
import { registerWebVitals } from '$logger/vitals';
Quick start
import { createEngineLogger, LogLevel, consoleTransport } from '$logger';
const logger = createEngineLogger({
level: LogLevel.DEBUG,
globalContext: { appVersion: '1.2.3', env: 'prod' },
transports: [consoleTransport()]
});
logger.info('auth', 'Login succeeded', {
context: { userId: 42 },
tags: ['auth', 'security'],
traceId: 'req-abc-123'
});
try { ... } catch (err) {
logger.error('db', 'Query failed', { error: err, traceId: 'req-abc-123' });
}
// Duration measurement
logger.time('fetch-user');
await fetchUser();
logger.timeEnd('fetch-user', 'perf'); // emits INFO with durationMs and tag 'timer'
// Lazy message — only evaluated if the entry passes the level filter
logger.debug('sql', () => `Query: ${expensiveFormat(query)}`);
Log levels
enum LogLevel {
TRACE = 0,
DEBUG = 1,
INFO = 2,
WARN = 3,
ERROR = 4,
FATAL = 5,
NONE = 999 // disables all logs
}
| Level | Intended use |
|---|---|
| TRACE | Very verbose tracing (spans, frame-by-frame) |
| DEBUG | Development diagnostics |
| INFO | Normal flow events worth noting |
| WARN | Unexpected but non-interrupting situations |
| ERROR | Recoverable errors that need attention |
| FATAL | Unrecoverable failures (route to immediate paging) |
Level → string lookups (for custom transports)
logger/consts exports two readonly tables that adapter authors can reuse
instead of re-rolling another switch (level):
import { LEVEL_LABELS, LEVEL_CONSOLE_METHOD, LogLevel } from '$logger';
LEVEL_LABELS[LogLevel.WARN]; // 'warn'
LEVEL_CONSOLE_METHOD[LogLevel.FATAL]; // 'error' (FATAL collapses to console.error)
LEVEL_LABELS— canonical string per level (trace | debug | info | warn | error | fatal). Used byvitalsfor category routing and by the bundled Loki adapter.LEVEL_CONSOLE_METHOD—consolemethod name per level (debug | info | warn | error). TRACE collapses todebug, FATAL toerrorbecause the console exposes neither natively.
Both omit LogLevel.NONE (which is a routing decision, not an emitted level).
API
createEngineLogger(options)
interface LoggerOptions {
level?: LogLevel; // @default LogLevel.WARN
maxLogs?: number; // @default 1000
transports?: Transport[]; // @default [consoleTransport()]
globalContext?: Record<string, unknown>;
captureSource?: boolean; // @default true in DEV, false in PROD
}
Level methods
logger.trace(category, message, input?)
logger.debug(category, message, input?)
logger.info (category, message, input?)
logger.warn (category, message, input?)
logger.error(category, message, input?)
logger.fatal(category, message, input?)
Where:
type LogMessage = string | (() => string); // lazy thunks supported
interface LogInput {
context?: Record<string, unknown>; // merged with globalContext
error?: unknown; // extracted to LogError { name, message, stack, cause }
tags?: string[]; // finer-grained filtering than category
traceId?: string; // correlate across async flows
durationMs?: number; // auto-set by timeEnd()
source?: LogSource; // manual override of auto-captured file:line
}
History
logger.getLogs(filters?) // defensive copy, filtered by level/fromLevel/category/tag/traceId/since
logger.clear()
logger.serialize() // JSON string (circular-safe)
Runtime controls
logger.setLevel(LogLevel.WARN)
logger.setMaxLogs(500)
logger.setGlobalContext({ appVersion: '1.3.0' })
Lifecycle
logger.flush(); // flush every buffered transport synchronously
logger.dispose(); // detach beforeunload listener, flush, drop transports, clear history
dispose() is idempotent. Children created via child() do not own the
lifecycle — calling dispose() on a child is a no-op (DEV warning).
Dynamic transports
const remove = logger.addTransport(transport); // returns a detach fn
const unsub = logger.subscribe((entry) => ...); // sugar over addTransport
logger.removeAllTransports();
logger.transports(); // read-only snapshot
Child loggers
const reqLog = logger.child({ requestId: 'req-1', userId: 42 });
reqLog.info('auth', 'authenticated'); // context merges parent + child
Children share history, transports and level with the parent. They only
extend globalContext.
Timers
logger.time('fetch');
await doWork();
logger.timeEnd('fetch', 'perf', 'operation done'); // emits INFO with durationMs
Framework Logger Contract
El contrato minimo para cualquier modulo vive en $libs/logger:
import type { Logger, LogInput, LogMessage } from '$libs/logger';
type LogFn = (category: string, message: LogMessage, input?: LogInput) => void;
export interface Logger {
trace: LogFn;
debug: LogFn;
info: LogFn;
warn: LogFn;
error: LogFn;
fatal: LogFn;
}
Los artefactos no deben declarar mini-loggers locales ni aliases reducidos por
modulo. Si un modulo necesita loggear libremente, recibe
logger?: Logger y llama al nivel que corresponda.
EngineLogger vive en arts/logger y extiende ese contrato con capacidades de
runtime: transports, history, children, timers, flush y dispose. El resto del
framework no necesita conocer esas capacidades para emitir logs.
Framework Diagnostics
Diagnostics es una capa opcional encima de Logger, pensada para eventos
internos repetibles del framework: listener que lanza, reconnect agotado,
validacion fallida, cache invalidada, etc.
import { createCatalogDiagnostics, LogLevel, type Diagnostics } from '$libs/logger';
import type { Logger } from '$libs/logger';
type ConnEvent =
| { artifact: 'connection'; type: 'reconnect_exhausted'; meta: { attempts: number } }
| { artifact: 'connection'; type: 'transport_error'; meta: { error: unknown } };
function createConnDiagnostics(logger?: Logger): Diagnostics<ConnEvent> {
return createCatalogDiagnostics({
logger,
defaultCategory: 'connection',
catalog: {
reconnect_exhausted: {
level: LogLevel.WARN,
message: 'reconnect attempts exhausted'
},
transport_error: {
level: LogLevel.ERROR,
message: 'transport error'
}
}
});
}
La idea no es reemplazar logger.info(...) ni obligar a declarar cada log como
evento. La regla es:
- Usa
logger.info(...),logger.warn(...), etc. para logs libres de negocio o de modulo. - Usa
Diagnostics.emit(...)para eventos internos catalogados que queremos mantener homogeneos, con categoria/mensaje/nivel centralizados. - El diagnostic conserva
diagnostics.logger, asi que sigue siendo un logger normal por debajo.
Los diagnostics aceptan un threshold opcional al estilo del logger; si el descriptor del catalog cae por debajo, se descarta antes de llegar al logger:
const diagnostics = createCatalogDiagnostics({
logger,
catalog,
level: LogLevel.WARN,
events: {
transport_error: { level: LogLevel.ERROR },
reconnect_exhausted: false
}
});
El EngineLogger sigue siendo quien decide finalmente si la entrada se emite,
se guarda, pasa filtros de transporte o se manda a Sentry/Datadog/Loki/etc.
Diagnostics solo traduce un evento interno a una llamada normal del logger.
Transport interface
interface Transport {
write(entry: LogEntry): void | Promise<void>;
writeBatch?(entries: LogEntry[]): void | Promise<void>;
/** Threshold-style per-transport filter. Entries below `level` are skipped. */
level?: LogLevel;
/** Predicate applied after the threshold check. */
filter?(entry: LogEntry): boolean;
/** Buffer size; entries are queued and flushed on full / interval / page-unload. @default 0 */
buffer?: number;
/** Periodic flush interval (ms) when `buffer > 0`. @default 0 */
flushIntervalMs?: number;
/** Identifier shown in introspection and in `deniedFor` on failure entries. */
name?: string;
}
levelandfilterare applied after the logger's global level filter, for fine-grained per-sink routing.nameappears in introspection and indeniedForon failure entries.- Sync throws and promise rejections both route to the failure handler.
Failure routing (deniedFor)
If transport.write() throws or rejects:
- A synthetic
ERRORentry is created with:category: 'engine_logger'message: 'Transport "X" failed: ...'error: structuredLogErrorfrom the thrown valuetags: ['engine_logger', 'transport-failure']deniedFor: ['X']
- The failure entry is dispatched to every other transport, skipping the failed one by reference.
- If a second transport throws while handling the failure entry, the secondary failure is swallowed (console-only) — this guard guarantees no cascade loop.
This is why you can attach Sentry (or any other transport) safely: if Sentry is down, the failure gets logged to console/Datadog/Loki/etc., but never back to Sentry.
Throttling failure entries (failureThrottleMs)
Without throttling, a single down transport can multiply your log volume:
when Sentry is unavailable, every log.info(...) produces a failure entry
that flows to every other sink. With 1000 logs that is 1000 extra Datadog
entries.
Set a per-transport throttle to collapse the cascade into one entry per window:
logger.addTransport(sentryTransport(Sentry, {
name: 'sentry',
failureThrottleMs: 60_000 // one failure entry per minute, max
}));
Behavior:
- First failure — dispatch normally; open a
failureThrottleMswindow. - Failures inside the window — suppressed (counter incremented, no
dispatch). The actual
transport.write()keeps being invoked on subsequent application logs so a recovery is detected immediately. - First failure after the window closes — dispatch with
(+N suppressed in last Xms)appended to the message and{ suppressedCount: N, throttleMs: X }merged into the entry context, so downstream sinks can chart the suppression rate.
failureThrottleMs: 0 (default) preserves the original "every failure
dispatches" behavior. Each transport throttles independently. Children
created via child() share the throttle state with the root.
Built-in transports
consoleTransport(options?)
interface ConsoleTransportOptions {
timestamp?: boolean; // @default true
prefix?: boolean; // @default true
source?: boolean; // @default true — shows "(file.ts:42)"
level?: LogLevel; // threshold cutoff
filter?: (entry: LogEntry) => boolean; // e.g. drop noisy vitals
}
Browser/Node console with the appropriate method per level (TRACE → debug, FATAL → error).
httpTransport({ url, headers?, level?, filter? })
Fire-and-forget POST to an arbitrary endpoint. Defaults to LogLevel.ERROR
to stay quiet by default. No retry built in; batching uses writeBatch when
buffer > 0.
callbackTransport(fn, options?)
Factory that builds a Transport out of a callback. Structurally equivalent
to logger.subscribe(fn) but lets you pre-register it in
LoggerOptions.transports and configure level, filter, name.
Adapters
All adapters follow the same pattern: receive the third-party SDK as an
injected parameter. logger itself never imports @sentry/*, @datadog/*,
etc. — callers install those packages and pass the already-initialized SDK.
This keeps logger zero-dep and makes tests trivial (mock the SDK with a plain
object).
Sentry — sentryTransport(SentrySDK, options?)
import * as Sentry from '@sentry/browser';
import { sentryTransport } from '$logger/adapters/sentry';
Sentry.init({ dsn: '<your-dsn>', environment: 'prod' });
logger.addTransport(sentryTransport(Sentry, {
eventLevel: LogLevel.ERROR, // @default ERROR — entries at or above become Sentry events
breadcrumbLevel: LogLevel.INFO // @default INFO — entries in [breadcrumb, event) become breadcrumbs
}));
ERROR+→captureException(entry.error)if present, otherwisecaptureMessage(entry.message, severity).[breadcrumbLevel, eventLevel)→addBreadcrumb(...)attached to the next captured event.< breadcrumbLevel→ ignored.- Every event passes through
Sentry.withScope()to attach tags (category,traceId,tag:x), contexts (log.context,log.source,log.timing) without polluting the global scope.
Datadog — datadogTransport(ddLogger, options?)
import { datadogLogs } from '@datadog/browser-logs';
import { datadogTransport } from '$logger/adapters/datadog';
datadogLogs.init({
clientToken: 'pubXXXXXXXXXX',
site: 'datadoghq.eu',
service: 'my-app',
env: 'prod',
version: '1.0.0'
});
logger.addTransport(datadogTransport(datadogLogs.logger, { level: LogLevel.WARN }));
Level mapping: TRACE/DEBUG → debug, INFO → info, WARN → warn,
ERROR/FATAL → error. Original level preserved in messageContext.logLevel.
Uses reserved-safe names (logTags, sourceLocation, durationMs) to avoid
Datadog's reserved attributes (source, duration, tags).
Logtail (Better Stack) — logtailTransport(logtailInstance, options?)
import { Logtail } from '@logtail/browser';
import { logtailTransport } from '$logger/adapters/logtail';
const lt = new Logtail('<source-token>');
logger.addTransport(logtailTransport(lt, { level: LogLevel.INFO }));
Grafana Loki — lokiTransport(options)
No SDK required — pure HTTP POST to the Loki push endpoint.
import { lokiTransport } from '$logger/adapters/loki';
logger.addTransport(lokiTransport({
url: 'https://logs-prod-006.grafana.net/loki/api/v1/push',
labels: { app: 'my-app', env: 'prod' }, // low-cardinality only!
basicAuthUser: '123456',
basicAuthPassword: 'eyJhbG...',
tenantId: 'my-tenant' // X-Scope-OrgID header
}));
The level goes into the stream labels (low cardinality, safe). Everything
else (context, tags, traceId, error) is JSON-encoded in the log line so you
can query with LogQL:
{app="my-app"} | json | traceId="req-1".
OpenTelemetry — otelTransport(otelLogger, options?)
import { logs } from '@opentelemetry/api-logs';
import { LoggerProvider } from '@opentelemetry/sdk-logs';
import { otelTransport } from '$logger/adapters/otel';
const provider = new LoggerProvider({ /* ... */ });
logs.setGlobalLoggerProvider(provider);
const otelLogger = logs.getLogger('my-app');
logger.addTransport(otelTransport(otelLogger));
Maps LogLevel to OTel severityNumber: TRACE=1, DEBUG=5, INFO=9, WARN=13,
ERROR=17, FATAL=21 (base of each 4-number band per the OTel spec).
Structured context, tags, source and error flatten into OTel attributes
using dotted keys (log.context.userId, code.filepath, exception.type,
etc.).
Web Vitals (RUM)
logger integrates with Google's web-vitals
package so every Core Web Vital becomes a structured LogEntry. This turns
the logger into a Real User Monitoring bus: the same pipeline that ships
errors to Sentry and logs to Loki also ships LCP/INP/CLS/FCP/TTFB samples,
tagged with the route, browser, session, etc. Hot zones and regressions
become queryable per category/tag like any other log.
Setup
import * as webVitals from 'web-vitals';
import { registerWebVitals } from '$logger/vitals';
registerWebVitals(logger, webVitals, {
// Promote 'poor' to WARN so dashboards light it up.
levels: { poor: LogLevel.WARN }
});
By default each metric emits a single entry with:
category: 'vitals'durationMs: metric.valuetags: ['vitals', <metric>, <rating>]— e.g.['vitals', 'lcp', 'poor']context: { metric, value, rating, delta, metricId, navigationType, route }levelbased on the rating:good → DEBUG,needs-improvement → INFO,poor → WARN
Options
interface VitalsOptions {
category?: string; // @default 'vitals'
levels?: Partial<Record<WebVitalRating, LogLevel>>;
metrics?: ('LCP' | 'INP' | 'CLS' | 'FCP' | 'TTFB')[];
tags?: string[]; // extra tags added to every entry
reportAllChanges?: boolean; // @default false (only final values)
includeRoute?: boolean; // @default true — adds location.pathname
}
Silencing the console
Web Vitals fire on every page load, so you usually don't want them in the
browser console. Filter them at the consoleTransport level; other
transports still receive them unchanged:
const logger = createEngineLogger({
transports: [
consoleTransport({
filter: (e) => !e.tags?.includes('vitals')
}),
// Loki/Sentry/Datadog still get the vitals entries.
sentryTransport(Sentry),
lokiTransport({ url: '...' })
]
});
Hot-zone analysis patterns
Because vitals entries carry context.route and context.rating, you can
query directly in your observability backend:
- LogQL (Loki):
{app="my-app",level="warn"} | json | metric="LCP" | rating="poor"→ worst-LCP pages - Sentry discover: group events by
tag.category = vitalsandtag.tag:lcp, plot p75 per release - Datadog logs: facet on
metricandroute, dashboard p95/p99 per route
Vitals + Sentry: two delivery paths
Vitals can reach Sentry through two independent pipelines, each feeding a different Sentry product. Knowing which is which avoids noise and gives you the right dashboard for the question you're asking.
Path A — through the logger (what registerWebVitals does)
Vitals flow as regular LogEntry objects through every transport, including
sentryTransport. They end up as Sentry Issues (if promoted to ERROR)
or breadcrumbs (at INFO/WARN), alongside your normal logs.
| Rating | Default level | Reaches Sentry as … |
|---|---|---|
good |
DEBUG | Dropped (below breadcrumbLevel: INFO) |
needs-improvement |
INFO | Breadcrumb (attached to the next captured event, not a new Issue) |
poor |
WARN | Breadcrumb (still below eventLevel: ERROR) |
With the defaults, vitals rarely appear as Sentry Issues — this is by design. In production you do not want every page load producing an Issue.
Best production use of Path A: lower breadcrumbLevel so vitals become
rich context on the next captured error, without flooding the Issues feed:
logger.addTransport(sentryTransport(Sentry, {
eventLevel: LogLevel.ERROR,
breadcrumbLevel: LogLevel.TRACE // every sample becomes a breadcrumb
}));
Validation trick for dev pages — temporarily promote every sample to ERROR so they show up as Issues and you can confirm the pipeline:
registerWebVitals(logger, webVitals, {
levels: {
good: LogLevel.ERROR,
'needs-improvement': LogLevel.ERROR,
poor: LogLevel.ERROR
}
});
Path B — through Sentry's native Browser Tracing (recommended for production)
Sentry's own browserTracingIntegration auto-captures Web Vitals and emits
them as Performance transactions. These land in a dedicated product and
do not pollute the Issues feed.
Where to find them in the Sentry dashboard:
- Insights → Browser → Web Vitals — aggregate p75/p95 per route, browser, device, release
- Performance → Transactions — per-pageload detail with full vitals, resource timing and long tasks
Setup (this is a one-time thing at app init, nothing to do in logger):
import * as Sentry from '@sentry/browser';
Sentry.init({
dsn: '<your-dsn>',
integrations: [Sentry.browserTracingIntegration()],
tracesSampleRate: 0.1 // 10% of page loads send a transaction
});
Vitals now flow automatically — no call to registerWebVitals needed for
this path.
When to use which
| Use case | Path |
|---|---|
| Aggregate dashboards (p75 LCP by route, regressions by release) | B (Sentry Performance) |
| Correlate vitals with a specific error (debugging triage) | A at breadcrumbLevel: TRACE (breadcrumbs on error events) |
Alerts on poor ratings (poor LCP → PagerDuty) |
A with poor: LogLevel.ERROR + Sentry Alert rule on the tag |
| Ship vitals to non-Sentry backends (Loki, Datadog, OTel) | A — the logger fans out to every transport |
| Validate the pipeline during development | A with all ratings at ERROR (noisy but visible in Issues) |
The two paths are complementary and can run in parallel. In production the common setup is Path B for analytics + Path A as breadcrumbs — you get the aggregate view under Performance and contextual vitals on every error, without a single extra Issue.
Patterns
Sentry + breadcrumbs + metrics
// Events in Sentry (ERROR+) and breadcrumbs (INFO/WARN)
logger.addTransport(sentryTransport(Sentry));
// Counter of errors
logger.subscribe((entry) => {
if (entry.level >= LogLevel.ERROR) metrics.errorsPerMinute.inc();
});
Scoped child for a request
export async function handle(request: Request) {
const reqLog = logger.child({
requestId: crypto.randomUUID(),
path: new URL(request.url).pathname
});
reqLog.info('http', 'incoming request');
try {
return await handler(request, reqLog);
} catch (err) {
reqLog.error('http', 'handler threw', { error: err });
throw err;
}
}
Feature-flagged transport
if (flags.enableRemoteLogs) {
const detach = logger.addTransport(httpTransport({ url: '...' }));
onFlagDisable(() => detach());
}
Tests
npx vitest run --project server
Covers 171 cases: level filtering, lazy messages, globalContext merging,
error extraction, tags/traceId, time/timeEnd, child semantics, getLogs
filters, dynamic add/remove, per-transport levels/filter, sync and async
failure routing with deniedFor, cascade guard, serialize, plus adapter
unit tests (Sentry, Datadog, Logtail, Loki, OpenTelemetry) using mocked SDKs.