|
|
6 months ago | |
|---|---|---|
| .. | ||
| adapters | 6 months ago | |
| test | 6 months ago | |
| README.md | 6 months ago | |
| consts.ts | 6 months ago | |
| engine-logger.ts | 6 months ago | |
| index.ts | 6 months ago | |
| source.ts | 6 months ago | |
| transports.ts | 6 months ago | |
| types.ts | 6 months ago | |
README.md
logr
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, ...).
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 —
minLevelandfilterat the transport level. - 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 so logr stays dep-free.
Architecture
logr/
├── 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 runtime identifier (`engine_logger`)
├── 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/
└── logr.test.ts Unit tests (vitest, node env)
Alias
Configured in svelte.config.js:
alias: { $logr: 'src/arts/logr' }
Imports:
import { createEngineLogger, LogLevel, consoleTransport } from '$logr';
import { sentryTransport } from '$logr/adapters/sentry';
import { datadogTransport } from '$logr/adapters/datadog';
import { logtailTransport } from '$logr/adapters/logtail';
import { lokiTransport } from '$logr/adapters/loki';
import { otelTransport } from '$logr/adapters/otel';
Quick start
import { createEngineLogger, LogLevel, consoleTransport } from '$logr';
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) |
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/minLevel/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' })
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
Transport interface
interface Transport {
write(entry: LogEntry): void | Promise<void>;
minLevel?: LogLevel;
filter?(entry: LogEntry): boolean;
name?: string;
}
minLevelandfilterare 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.
Built-in transports
consoleTransport(options?)
interface ConsoleTransportOptions {
timestamp?: boolean; // @default true
prefix?: boolean; // @default true
source?: boolean; // @default true — shows "(file.ts:42)"
minLevel?: LogLevel;
}
Browser/Node console with the appropriate method per level (TRACE → debug, FATAL → error).
httpTransport({ url, headers?, minLevel? })
Fire-and-forget POST to an arbitrary endpoint. No retry/batching built in — use it for prototypes or wrap it for production.
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 minLevel, filter, name.
Adapters
All adapters follow the same pattern: receive the third-party SDK as an
injected parameter. logr itself never imports @sentry/*, @datadog/*,
etc. — callers install those packages and pass the already-initialized SDK.
This keeps logr 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 '$logr/adapters/sentry';
Sentry.init({ dsn: '<your-dsn>', environment: 'prod' });
logger.addTransport(sentryTransport(Sentry, {
minLevel: LogLevel.ERROR, // @default ERROR
breadcrumbLevel: LogLevel.INFO // @default INFO
}));
ERROR+→captureException(entry.error)if present, otherwisecaptureMessage(entry.message, severity).[breadcrumbLevel, minLevel)→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 '$logr/adapters/datadog';
datadogLogs.init({
clientToken: 'pubXXXXXXXXXX',
site: 'datadoghq.eu',
service: 'my-app',
env: 'prod',
version: '1.0.0'
});
logger.addTransport(datadogTransport(datadogLogs.logger, { minLevel: 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 '$logr/adapters/logtail';
const lt = new Logtail('<source-token>');
logger.addTransport(logtailTransport(lt, { minLevel: LogLevel.INFO }));
Grafana Loki — lokiTransport(options)
No SDK required — pure HTTP POST to the Loki push endpoint.
import { lokiTransport } from '$logr/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 '$logr/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.).
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 minLevel/filter, sync and async
failure routing with deniedFor, cascade guard, serialize, plus adapter
unit tests (Sentry, Datadog, Logtail, Loki, OpenTelemetry) using mocked SDKs.