# 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 with `timer` tag. - **Dynamic transports** — `addTransport()`, `subscribe()`, `removeAllTransports()`. - **Per-transport filters** — `level` (threshold cutoff) and `filter` (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` 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 so `logger` stays 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`: ```js alias: { $logger: 'src/arts/logger' } ``` Imports: ```ts 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 ```ts 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 ```ts 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)`: ```ts 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 by `vitals` for category routing and by the bundled Loki adapter. - `LEVEL_CONSOLE_METHOD` — `console` method name per level (`debug | info | warn | error`). TRACE collapses to `debug`, FATAL to `error` because the console exposes neither natively. Both omit `LogLevel.NONE` (which is a routing decision, not an emitted level). --- ## API ### `createEngineLogger(options)` ```ts interface LoggerOptions { level?: LogLevel; // @default LogLevel.WARN maxLogs?: number; // @default 1000 transports?: Transport[]; // @default [consoleTransport()] globalContext?: Record; captureSource?: boolean; // @default true in DEV, false in PROD } ``` ### Level methods ```ts 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: ```ts type LogMessage = string | (() => string); // lazy thunks supported interface LogInput { context?: Record; // 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 ```ts logger.getLogs(filters?) // defensive copy, filtered by level/fromLevel/category/tag/traceId/since logger.clear() logger.serialize() // JSON string (circular-safe) ``` ### Runtime controls ```ts logger.setLevel(LogLevel.WARN) logger.setMaxLogs(500) logger.setGlobalContext({ appVersion: '1.3.0' }) ``` ### Lifecycle ```ts 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 ```ts 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 ```ts 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 ```ts 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`: ```ts 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. ```ts 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 { 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: ```ts 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 ```ts interface Transport { write(entry: LogEntry): void | Promise; writeBatch?(entries: LogEntry[]): void | Promise; /** 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; } ``` - `level` and `filter` are applied **after** the logger's global level filter, for fine-grained per-sink routing. - `name` appears in introspection and in `deniedFor` on failure entries. - Sync throws and promise rejections both route to the failure handler. --- ## Failure routing (`deniedFor`) If `transport.write()` throws or rejects: 1. A synthetic `ERROR` entry is created with: - `category: 'engine_logger'` - `message: 'Transport "X" failed: ...'` - `error`: structured `LogError` from the thrown value - `tags: ['engine_logger', 'transport-failure']` - `deniedFor: ['X']` 2. The failure entry is dispatched to every **other** transport, skipping the failed one by reference. 3. 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: ```ts logger.addTransport(sentryTransport(Sentry, { name: 'sentry', failureThrottleMs: 60_000 // one failure entry per minute, max })); ``` Behavior: - **First failure** — dispatch normally; open a `failureThrottleMs` window. - **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?)` ```ts 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?)` ```ts import * as Sentry from '@sentry/browser'; import { sentryTransport } from '$logger/adapters/sentry'; Sentry.init({ 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, otherwise `captureMessage(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?)` ```ts 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?)` ```ts import { Logtail } from '@logtail/browser'; import { logtailTransport } from '$logger/adapters/logtail'; const lt = new Logtail(''); logger.addTransport(logtailTransport(lt, { level: LogLevel.INFO })); ``` ### Grafana Loki — `lokiTransport(options)` No SDK required — pure HTTP POST to the Loki push endpoint. ```ts 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?)` ```ts 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`](https://www.npmjs.com/package/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 ```ts 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.value` - `tags: ['vitals', , ]` — e.g. `['vitals', 'lcp', 'poor']` - `context: { metric, value, rating, delta, metricId, navigationType, route }` - `level` based on the rating: `good → DEBUG`, `needs-improvement → INFO`, `poor → WARN` ### Options ```ts interface VitalsOptions { category?: string; // @default 'vitals' levels?: Partial>; 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: ```ts 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 = vitals` and `tag.tag:lcp`, plot p75 per release - **Datadog logs:** facet on `metric` and `route`, 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: ```ts 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: ```ts 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`): ```ts import * as Sentry from '@sentry/browser'; Sentry.init({ 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 ```ts // 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 ```ts 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 ```ts if (flags.enableRemoteLogs) { const detach = logger.addTransport(httpTransport({ url: '...' })); onFlagDisable(() => detach()); } ``` --- ## Tests ```bash 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.