Add vitals, entry.id, per-level transport routing and buffering

logr:
- entry.id auto-generated (UUID v4 / hex fallback) — survives backend dedup, batched-retry, cross-transport correlation.
- Transport.levels replaces minLevel — per-level { enabled, filter? } record. More flexible: enable/disable individual levels, attach per-level filters. Helper levelsAtLeast(level) for the common min-level case; allLevels() for the permissive case.
- Transport buffering: buffer, flushIntervalMs, writeBatch on the Transport interface. Logger tracks per-transport queues, flushes on size/time/flush()/detach/beforeunload. httpTransport and lokiTransport implement writeBatch for true batched HTTP delivery.
- logger.flush() to drain all buffered transports manually.
- Web Vitals integration (src/arts/logr/vitals.ts): registerWebVitals(logger, webVitalsSDK, options?) turns LCP/INP/CLS/FCP/TTFB samples into structured log entries tagged with route/rating. Zero-deps in logr — SDK is injected.
- ConsoleTransportOptions now exposes filter so vitals can be silenced on console while still flowing to Sentry/Datadog/Loki.
- Runtime name constant: `engine_logger` (via src/arts/logr/consts.ts) replaces hardcoded 'logr' string in failure reports, console prefix and synthetic entry tags.

lang:
- README expanded with a dedicated section on ts() and the LangString type: plain strings, LangRecord, LangRef; the `message: LangString` prop pattern for generic components; when to use the isLang* guards.

Tests: 185 passing (added vitals + buffer + entry.id coverage).
Docs: logr README now documents vitals (hot-zone patterns for LogQL / Sentry Discover / Datadog), buffering, the levels config, and the helpers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
master
dev 6 months ago
parent a6077dd357
commit 4c074e196e

9
package-lock.json generated

@ -8,7 +8,8 @@
"name": "active",
"version": "0.0.1",
"dependencies": {
"@sentry/browser": "^10.50.0"
"@sentry/browser": "^10.50.0",
"web-vitals": "^5.2.0"
},
"devDependencies": {
"@eslint/compat": "^2.0.4",
@ -3398,6 +3399,12 @@
"vitest": "^4.0.0"
}
},
"node_modules/web-vitals": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.2.0.tgz",
"integrity": "sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==",
"license": "Apache-2.0"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

@ -39,6 +39,7 @@
"vitest-browser-svelte": "^2.1.0"
},
"dependencies": {
"@sentry/browser": "^10.50.0"
"@sentry/browser": "^10.50.0",
"web-vitals": "^5.2.0"
}
}

@ -163,6 +163,67 @@ lang.ts({ es: 'Hola', en: 'Hello' }, 'en'); // 'Hello'
lang.ts('#?common.ok', 'es'); // 'Aceptar'
```
### `ts()` and the `LangString` type
`t()` resolves translations **by path** from the schema. `ts()` takes a
**`LangString` value directly** — useful when the translation is built inline
or flows through props/context. The value can be any of the three variants:
```ts
type LangString = string | LangRecord | LangRef;
```
This is the idiomatic way to accept "maybe translatable" text through
component boundaries: a prop typed `message: LangString` accepts all three
shapes transparently, and components call `lang.ts(message)` to render.
```ts
// 1) Plain string — passthrough, no lookup
lang.ts('Static text', 'en'); // 'Static text'
// 2) LangRecord — inline per-locale values
lang.ts({ es: 'Guardar', en: 'Save', ar: 'حفظ' }, 'en'); // 'Save'
lang.ts({ es: 'Solo español' }, 'en'); // 'Solo español' (single-locale fallback)
// 3) LangRef — alias to a schema key (optional literal fallback)
lang.ts('#?common.ok', 'es'); // resolves to 'Aceptar'
lang.ts('#?missing.key|Default', 'es'); // fallback literal wins
```
Practical pattern — a generic button component that accepts a translatable label:
```svelte
<!-- MyButton.svelte -->
<script lang="ts">
import type { LangString } from '$lang';
import { getContext } from 'svelte';
import type { ActiveLangInstance } from '$lang';
let { label, onclick }: { label: LangString; onclick: () => void } = $props();
const lang = getContext<ActiveLangInstance>('lang');
</script>
<button {onclick}>{lang.ts(label)}</button>
```
Callers pass whichever form fits:
```svelte
<MyButton label="Plain text" onclick={...} />
<MyButton label={{ es: 'Guardar', en: 'Save' }} onclick={...} />
<MyButton label="#?actions.save" onclick={...} />
```
The three guards help narrow a `LangString` when needed:
```ts
import { isLangRef, isLangRecord, isLangString } from '$lang';
if (isLangRef(value)) { /* value is `#?...` */ }
if (isLangRecord(value)) { /* value is { es?, en?, ar?, ... } */ }
if (isLangString(value)) { /* value is one of the three */ }
```
### Reactive wrapper for Svelte 5
```svelte

@ -16,10 +16,12 @@ Logtail (Better Stack), Grafana Loki and OpenTelemetry.
- **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** — `minLevel` and `filter` at the transport level.
- **Per-transport filters** — `levels` (per-severity enable + filter) and `filter` (global predicate) at 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
@ -41,6 +43,7 @@ logr/
├── transports.ts consoleTransport, httpTransport, callbackTransport
├── source.ts captureSource(), extractError()
├── consts.ts runtime identifier (`engine_logger`)
├── vitals.ts registerWebVitals(logger, webVitalsSDK, options?)
├── adapters/
│ ├── sentry.ts sentryTransport(sentrySDK, options)
│ ├── datadog.ts datadogTransport(ddLogger, options)
@ -68,6 +71,7 @@ import { datadogTransport } from '$logr/adapters/datadog';
import { logtailTransport } from '$logr/adapters/logtail';
import { lokiTransport } from '$logr/adapters/loki';
import { otelTransport } from '$logr/adapters/otel';
import { registerWebVitals } from '$logr/vitals';
```
---
@ -389,6 +393,79 @@ etc.).
---
## Web Vitals (RUM)
`logr` 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 '$logr/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', <metric>, <rating>]` — 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<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:
```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
---
## Patterns
### Sentry + breadcrumbs + metrics

@ -6,12 +6,17 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { LogLevel, createEngineLogger, type LogEntry } from '../index.ts';
import { LogLevel, createEngineLogger, levelsAtLeast, type LogEntry } from '../index.ts';
import { sentryTransport, type SentryLike, type SentryScopeLike } from './sentry.ts';
import { datadogTransport, type DatadogLoggerLike } from './datadog.ts';
import { logtailTransport, type LogtailLike } from './logtail.ts';
import { lokiTransport } from './loki.ts';
import { otelTransport, type OtelLoggerLike, type OtelLogRecord } from './otel.ts';
import {
registerWebVitals,
type WebVitalMetric,
type WebVitalsLike
} from '../vitals.ts';
// ============================================================================
// SENTRY
@ -172,7 +177,7 @@ describe('datadogTransport', () => {
it('maps TRACE/DEBUG to debug, FATAL/ERROR to error', () => {
const { dd, calls } = mockDatadog();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(datadogTransport(dd, { minLevel: LogLevel.TRACE }));
log.addTransport(datadogTransport(dd, { levels: levelsAtLeast(LogLevel.TRACE) }));
log.trace('t', 'a');
log.debug('t', 'b');
@ -187,7 +192,7 @@ describe('datadogTransport', () => {
it('preserves original level in messageContext.logLevel', () => {
const { dd, calls } = mockDatadog();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(datadogTransport(dd, { minLevel: LogLevel.TRACE }));
log.addTransport(datadogTransport(dd, { levels: levelsAtLeast(LogLevel.TRACE) }));
log.fatal('t', 'boom');
const ctx = calls[0].context as Record<string, unknown>;
expect(ctx.logLevel).toBe('FATAL');
@ -196,7 +201,7 @@ describe('datadogTransport', () => {
it('uses reserved-safe names (logTags, sourceLocation, durationMs)', () => {
const { dd, calls } = mockDatadog();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(datadogTransport(dd, { minLevel: LogLevel.TRACE }));
log.addTransport(datadogTransport(dd, { levels: levelsAtLeast(LogLevel.TRACE) }));
log.info('t', 'hi', {
tags: ['x'],
traceId: 'r1',
@ -212,7 +217,7 @@ describe('datadogTransport', () => {
it('passes reconstructed Error to dd.error', () => {
const { dd, calls } = mockDatadog();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(datadogTransport(dd, { minLevel: LogLevel.TRACE }));
log.addTransport(datadogTransport(dd, { levels: levelsAtLeast(LogLevel.TRACE) }));
log.error('t', 'fail', { error: new Error('boom') });
expect(calls[0].error).toBeInstanceOf(Error);
@ -250,7 +255,7 @@ describe('logtailTransport', () => {
it('maps levels correctly', () => {
const { lt, calls } = mockLogtail();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(logtailTransport(lt, { minLevel: LogLevel.TRACE }));
log.addTransport(logtailTransport(lt, { levels: levelsAtLeast(LogLevel.TRACE) }));
log.trace('t', 'a');
log.debug('t', 'b');
@ -265,7 +270,7 @@ describe('logtailTransport', () => {
it('includes logLevel, category and error in context', () => {
const { lt, calls } = mockLogtail();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(logtailTransport(lt, { minLevel: LogLevel.TRACE }));
log.addTransport(logtailTransport(lt, { levels: levelsAtLeast(LogLevel.TRACE) }));
log.error('auth', 'fail', { error: new Error('boom') });
const ctx = calls[0].context as Record<string, unknown>;
@ -299,7 +304,7 @@ describe('lokiTransport', () => {
lokiTransport({
url: 'https://loki.example.com/loki/api/v1/push',
labels: { app: 'test' },
minLevel: LogLevel.TRACE
levels: levelsAtLeast(LogLevel.TRACE)
})
);
@ -327,7 +332,7 @@ describe('lokiTransport', () => {
url: 'https://loki.example.com/push',
basicAuthUser: 'user',
basicAuthPassword: 'pass',
minLevel: LogLevel.TRACE
levels: levelsAtLeast(LogLevel.TRACE)
})
);
@ -346,7 +351,7 @@ describe('lokiTransport', () => {
lokiTransport({
url: 'https://loki.example.com/push',
tenantId: 'tenant-1',
minLevel: LogLevel.TRACE
levels: levelsAtLeast(LogLevel.TRACE)
})
);
@ -363,9 +368,9 @@ describe('lokiTransport', () => {
const seen: LogEntry[] = [];
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport({ name: 'capture', write: (e) => seen.push(e) });
log.addTransport({ name: 'capture', write: (e) => { seen.push(e); } });
log.addTransport(
lokiTransport({ url: 'https://loki.example.com/push', minLevel: LogLevel.TRACE })
lokiTransport({ url: 'https://loki.example.com/push', levels: levelsAtLeast(LogLevel.TRACE) })
);
log.info('t', 'hi');
@ -394,11 +399,140 @@ function mockOtelLogger(): {
return { otelLogger, emitted };
}
// ============================================================================
// WEB VITALS
// ============================================================================
function mockWebVitals(): {
wv: WebVitalsLike;
trigger: (metric: WebVitalMetric) => void;
} {
const callbacks: ((m: WebVitalMetric) => void)[] = [];
const register = (cb: (m: WebVitalMetric) => void) => {
callbacks.push(cb);
};
const wv: WebVitalsLike = {
onLCP: register,
onINP: register,
onCLS: register,
onFCP: register,
onTTFB: register
};
return {
wv,
trigger: (metric) => callbacks.forEach((cb) => cb(metric))
};
}
describe('registerWebVitals', () => {
let originalWindow: unknown;
beforeEach(() => {
// The module guards against SSR via `typeof window === 'undefined'`.
// We stub a minimal window so the registration path runs in node.
originalWindow = (globalThis as { window?: unknown }).window;
(globalThis as { window?: unknown }).window = {};
});
afterEach(() => {
(globalThis as { window?: unknown }).window = originalWindow;
});
it('emits a log entry for every metric dispatched', () => {
const entries: LogEntry[] = [];
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport({ name: 'capture', write: (e) => { entries.push(e); } });
const { wv, trigger } = mockWebVitals();
registerWebVitals(log, wv);
trigger({
name: 'LCP',
value: 1234.5,
rating: 'good',
delta: 1234.5,
id: 'v-1'
});
// Five observers received the same metric → 5 log entries, one per registration.
expect(entries.length).toBeGreaterThan(0);
const entry = entries[0];
expect(entry.category).toBe('vitals');
expect(entry.message).toContain('LCP');
expect(entry.durationMs).toBe(1234.5);
expect(entry.tags).toContain('vitals');
expect(entry.tags).toContain('lcp');
expect(entry.tags).toContain('good');
});
it('maps rating to level: good→DEBUG, needs-improvement→INFO, poor→WARN', () => {
const entries: LogEntry[] = [];
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport({ name: 'capture', write: (e) => { entries.push(e); } });
const { wv, trigger } = mockWebVitals();
registerWebVitals(log, wv, { metrics: ['LCP'] });
trigger({ name: 'LCP', value: 1000, rating: 'good', delta: 0, id: 'a' });
trigger({ name: 'LCP', value: 3000, rating: 'needs-improvement', delta: 0, id: 'b' });
trigger({ name: 'LCP', value: 5000, rating: 'poor', delta: 0, id: 'c' });
expect(entries[0].level).toBe(LogLevel.DEBUG);
expect(entries[1].level).toBe(LogLevel.INFO);
expect(entries[2].level).toBe(LogLevel.WARN);
});
it('honors a custom level mapping', () => {
const entries: LogEntry[] = [];
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport({ name: 'capture', write: (e) => { entries.push(e); } });
const { wv, trigger } = mockWebVitals();
registerWebVitals(log, wv, {
metrics: ['LCP'],
levels: { poor: LogLevel.ERROR }
});
trigger({ name: 'LCP', value: 6000, rating: 'poor', delta: 0, id: 'x' });
expect(entries[0].level).toBe(LogLevel.ERROR);
});
it('only registers enabled metrics', () => {
const callbacks = { lcp: 0, inp: 0, cls: 0, fcp: 0, ttfb: 0 };
const wv: WebVitalsLike = {
onLCP: () => { callbacks.lcp++; },
onINP: () => { callbacks.inp++; },
onCLS: () => { callbacks.cls++; },
onFCP: () => { callbacks.fcp++; },
onTTFB: () => { callbacks.ttfb++; }
};
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
registerWebVitals(log, wv, { metrics: ['LCP', 'INP'] });
expect(callbacks.lcp).toBe(1);
expect(callbacks.inp).toBe(1);
expect(callbacks.cls).toBe(0);
expect(callbacks.fcp).toBe(0);
expect(callbacks.ttfb).toBe(0);
});
it('passes reportAllChanges option through to the observers', () => {
let seenOpts: { reportAllChanges?: boolean } | undefined;
const wv: WebVitalsLike = {
onLCP: (_cb, opts) => { seenOpts = opts; }
};
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
registerWebVitals(log, wv, { metrics: ['LCP'], reportAllChanges: true });
expect(seenOpts).toEqual({ reportAllChanges: true });
});
});
describe('otelTransport', () => {
it('maps levels to OTel severityNumber (1/5/9/13/17/21)', () => {
const { otelLogger, emitted } = mockOtelLogger();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(otelTransport(otelLogger, { minLevel: LogLevel.TRACE }));
log.addTransport(otelTransport(otelLogger, { levels: levelsAtLeast(LogLevel.TRACE) }));
log.trace('t', 'a');
log.debug('t', 'b');
@ -413,7 +547,7 @@ describe('otelTransport', () => {
it('sets severityText to the level name', () => {
const { otelLogger, emitted } = mockOtelLogger();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(otelTransport(otelLogger, { minLevel: LogLevel.TRACE }));
log.addTransport(otelTransport(otelLogger, { levels: levelsAtLeast(LogLevel.TRACE) }));
log.info('t', 'hi');
expect(emitted[0].severityText).toBe('INFO');
});
@ -421,7 +555,7 @@ describe('otelTransport', () => {
it('flattens context as log.context.* attributes', () => {
const { otelLogger, emitted } = mockOtelLogger();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(otelTransport(otelLogger, { minLevel: LogLevel.TRACE }));
log.addTransport(otelTransport(otelLogger, { levels: levelsAtLeast(LogLevel.TRACE) }));
log.info('auth', 'hi', { context: { userId: 42, region: 'eu' } });
const attrs = emitted[0].attributes ?? {};
@ -433,7 +567,7 @@ describe('otelTransport', () => {
it('maps error fields to exception.* attributes', () => {
const { otelLogger, emitted } = mockOtelLogger();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(otelTransport(otelLogger, { minLevel: LogLevel.TRACE }));
log.addTransport(otelTransport(otelLogger, { levels: levelsAtLeast(LogLevel.TRACE) }));
log.error('t', 'fail', { error: new Error('boom') });
const attrs = emitted[0].attributes ?? {};

@ -1,5 +1,6 @@
import type { LogEntry, Transport } from '../types.ts';
import type { LevelsConfig, LogEntry, Transport } from '../types.ts';
import { LogLevel } from '../types.ts';
import { levelsAtLeast } from '../consts.ts';
/**
* Duck-typed subset of the `@datadog/browser-logs` SDK used by the transport.
@ -18,8 +19,8 @@ export interface DatadogLoggerLike {
}
export interface DatadogTransportOptions {
/** @default LogLevel.INFO — INFO/WARN/ERROR/FATAL reach the backend */
minLevel?: LogLevel;
/** @default levelsAtLeast(LogLevel.INFO) — INFO/WARN/ERROR/FATAL */
levels?: LevelsConfig;
/** @default 'datadog' */
name?: string;
filter?: (entry: LogEntry) => boolean;
@ -57,7 +58,7 @@ export function datadogTransport(
): Transport {
return {
name: options.name ?? 'datadog',
minLevel: options.minLevel ?? LogLevel.INFO,
levels: options.levels ?? levelsAtLeast(LogLevel.INFO),
filter: options.filter,
write(entry: LogEntry): void {
// `source`, `duration`, `tags` are Datadog-reserved attributes — we

@ -1,5 +1,6 @@
import type { LogEntry, Transport } from '../types.ts';
import type { LevelsConfig, LogEntry, Transport } from '../types.ts';
import { LogLevel } from '../types.ts';
import { levelsAtLeast } from '../consts.ts';
/**
* Duck-typed subset of the `@logtail/browser` SDK (Better Stack).
@ -17,8 +18,8 @@ export interface LogtailLike {
}
export interface LogtailTransportOptions {
/** @default LogLevel.INFO */
minLevel?: LogLevel;
/** @default levelsAtLeast(LogLevel.INFO) */
levels?: LevelsConfig;
/** @default 'logtail' */
name?: string;
filter?: (entry: LogEntry) => boolean;
@ -42,7 +43,7 @@ export function logtailTransport(
): Transport {
return {
name: options.name ?? 'logtail',
minLevel: options.minLevel ?? LogLevel.INFO,
levels: options.levels ?? levelsAtLeast(LogLevel.INFO),
filter: options.filter,
write(entry: LogEntry): void {
const context: Record<string, unknown> = {

@ -1,5 +1,6 @@
import type { LogEntry, Transport } from '../types.ts';
import type { LevelsConfig, LogEntry, Transport } from '../types.ts';
import { LogLevel } from '../types.ts';
import { levelsAtLeast } from '../consts.ts';
export interface LokiTransportOptions {
/** Absolute URL of the push endpoint. Typically `https://<host>/loki/api/v1/push`. */
@ -17,8 +18,8 @@ export interface LokiTransportOptions {
/** Basic auth user. Equivalent to `Authorization: Basic base64(user:pass)`. */
basicAuthUser?: string;
basicAuthPassword?: string;
/** @default LogLevel.INFO */
minLevel?: LogLevel;
/** @default levelsAtLeast(LogLevel.INFO) */
levels?: LevelsConfig;
/** @default 'loki' */
name?: string;
filter?: (entry: LogEntry) => boolean;
@ -99,7 +100,7 @@ export function lokiTransport(options: LokiTransportOptions): Transport {
return {
name: options.name ?? 'loki',
minLevel: options.minLevel ?? LogLevel.INFO,
levels: options.levels ?? levelsAtLeast(LogLevel.INFO),
filter: options.filter,
async write(entry: LogEntry): Promise<void> {
const streamLabels: Record<string, string> = {

@ -1,5 +1,6 @@
import type { LogEntry, Transport } from '../types.ts';
import type { LevelsConfig, LogEntry, Transport } from '../types.ts';
import { LogLevel } from '../types.ts';
import { levelsAtLeast } from '../consts.ts';
/**
* Duck-typed subset of the OpenTelemetry Logs API. The caller injects an
@ -29,8 +30,8 @@ export interface OtelLogRecord {
}
export interface OtelTransportOptions {
/** @default LogLevel.INFO */
minLevel?: LogLevel;
/** @default levelsAtLeast(LogLevel.INFO) */
levels?: LevelsConfig;
/** @default 'otel' */
name?: string;
filter?: (entry: LogEntry) => boolean;
@ -57,7 +58,7 @@ export function otelTransport(
): Transport {
return {
name: options.name ?? 'otel',
minLevel: options.minLevel ?? LogLevel.INFO,
levels: options.levels ?? levelsAtLeast(LogLevel.INFO),
filter: options.filter,
write(entry: LogEntry): void {
const attributes: Record<string, unknown> = {

@ -1,5 +1,6 @@
import type { LogEntry, Transport } from '../types.ts';
import type { LevelsConfig, LogEntry, Transport } from '../types.ts';
import { LogLevel } from '../types.ts';
import { levelsAtLeast } from '../consts.ts';
/**
* Duck-typed subset of the Sentry SDK used by the transport. The `engine_logger`
@ -30,18 +31,23 @@ export type SentrySeverity = 'debug' | 'info' | 'warning' | 'error' | 'fatal';
export interface SentryTransportOptions {
/**
* Entries at or above `minLevel` become Sentry events (captureException/
* Entries at or above `eventLevel` become Sentry events (captureException/
* captureMessage).
* @default LogLevel.ERROR
*/
minLevel?: LogLevel;
eventLevel?: LogLevel;
/**
* Entries in `[breadcrumbLevel, minLevel)` become breadcrumbs. Breadcrumbs
* are context attached to the next captured event. Pass `LogLevel.NONE` to
* disable them entirely.
* Entries in `[breadcrumbLevel, eventLevel)` become breadcrumbs. Pass
* `LogLevel.NONE` to disable them entirely.
* @default LogLevel.INFO
*/
breadcrumbLevel?: LogLevel;
/**
* Optional per-level acceptance override. When undefined, the transport
* accepts `[breadcrumbLevel, FATAL]` — everything required for its dual
* event/breadcrumb routing. Set this to restrict further.
*/
levels?: LevelsConfig;
/** Transport name shown in introspection and `deniedFor`. @default 'sentry' */
name?: string;
/** Extra predicate. When it returns `false`, the entry is skipped entirely. */
@ -74,19 +80,20 @@ export function sentryTransport(
sentry: SentryLike,
options: SentryTransportOptions = {}
): Transport {
const minLevel = options.minLevel ?? LogLevel.ERROR;
const eventLevel = options.eventLevel ?? LogLevel.ERROR;
const breadcrumbLevel = options.breadcrumbLevel ?? LogLevel.INFO;
const name = options.name ?? 'sentry';
return {
name,
// Intentionally no `transport.minLevel` here — we need to receive lower
// levels too so they can become breadcrumbs.
// Accept everything from breadcrumbLevel upwards; the write() body
// decides whether to emit an event or a breadcrumb.
levels: options.levels ?? levelsAtLeast(breadcrumbLevel),
filter: options.filter,
write(entry: LogEntry): void {
if (entry.level < breadcrumbLevel) return;
if (entry.level < minLevel) {
if (entry.level < eventLevel) {
sentry.addBreadcrumb({
message: entry.message,
level: toSentrySeverity(entry.level),

@ -1,3 +1,6 @@
import { LogLevel } from './types.ts';
import type { LevelsConfig } from './types.ts';
/**
* Runtime identifier of the logging engine. Used for:
* - `[engine_logger]` prefix in console output
@ -8,3 +11,44 @@
* filesystem detail; this identifier describes the engine itself.
*/
export const logger = 'engine_logger';
/**
* Every log level in ascending severity order (excluding `NONE`).
*/
export const ALL_LEVELS: readonly LogLevel[] = [
LogLevel.TRACE,
LogLevel.DEBUG,
LogLevel.INFO,
LogLevel.WARN,
LogLevel.ERROR,
LogLevel.FATAL
];
/**
* Build a `LevelsConfig` that enables every level at or above `min`.
* Sugar for the most common acceptance pattern.
*
* @example
* addTransport({
* name: 'sentry',
* levels: levelsAtLeast(LogLevel.ERROR), // ERROR + FATAL
* write: ...
* });
*/
export function levelsAtLeast(min: LogLevel): LevelsConfig {
const record: LevelsConfig = {};
for (const l of ALL_LEVELS) {
if (l >= min) record[l] = { enabled: true };
}
return record;
}
/**
* Build a `LevelsConfig` that enables every level. Handy as an explicit
* "accept everything" override when some other transport restricts levels.
*/
export function allLevels(): LevelsConfig {
const record: LevelsConfig = {};
for (const l of ALL_LEVELS) record[l] = { enabled: true };
return record;
}

@ -202,7 +202,7 @@ function buildLogger(
for (const transport of state.transports) {
if (transport === skipTransport) continue;
if (transport.minLevel !== undefined && entry.level < transport.minLevel) continue;
if (!transportAcceptsLevel(transport, entry)) continue;
if (transport.filter && !transport.filter(entry)) continue;
const bufferSize = transport.buffer ?? 0;
@ -214,6 +214,22 @@ function buildLogger(
}
}
/**
* Decide whether the transport accepts an entry at its level, using the
* per-level `levels` config:
* - no `levels` set → all levels pass
* - level missing or `enabled: false` → drop
* - `enabled: true` → apply the optional per-level filter
*/
function transportAcceptsLevel(transport: Transport, entry: LogEntry): boolean {
const cfg = transport.levels;
if (!cfg) return true;
const entryCfg = cfg[entry.level];
if (!entryCfg || !entryCfg.enabled) return false;
if (entryCfg.filter && !entryCfg.filter(entry)) return false;
return true;
}
function writeOne(transport: Transport, entry: LogEntry, isFailureReport: boolean): void {
try {
const ret = transport.write(entry);

@ -1,3 +1,4 @@
export * from './consts.ts';
export * from './engine-logger.ts';
export * from './transports.ts';
export * from './types.ts';

@ -29,7 +29,7 @@ import {
type Transport
} from '../index.ts';
import { extractError } from '../source.ts';
import { logger as LOGGER_NAME } from '../consts.ts';
import { logger as LOGGER_NAME, levelsAtLeast } from '../consts.ts';
function capture(): { transport: Transport; entries: LogEntry[] } {
const entries: LogEntry[] = [];
@ -412,7 +412,7 @@ describe('createEngineLogger — per-transport minLevel and filter', () => {
const entries: LogEntry[] = [];
const log = createEngineLogger({ level: LogLevel.TRACE });
log.removeAllTransports();
log.addTransport({ name: 't', minLevel: LogLevel.WARN, write: (e) => entries.push(e) });
log.addTransport({ name: 't', levels: levelsAtLeast(LogLevel.WARN), write: (e) => { entries.push(e); } });
log.info('t', 'dropped');
log.warn('t', 'kept');
expect(entries.map((e) => e.message)).toEqual(['kept']);
@ -425,7 +425,7 @@ describe('createEngineLogger — per-transport minLevel and filter', () => {
log.addTransport({
name: 't',
filter: (e) => e.category === 'kept',
write: (e) => entries.push(e)
write: (e) => { entries.push(e); }
});
log.info('dropped', 'a');
log.info('kept', 'b');
@ -444,7 +444,7 @@ describe('createEngineLogger — transport failure and deniedFor', () => {
throw new Error('sync boom');
}
};
const other: Transport = { name: 'good', write: (e) => seenByOther.push(e) };
const other: Transport = { name: 'good', write: (e) => { seenByOther.push(e); } };
log.addTransport(failing);
log.addTransport(other);
@ -475,7 +475,7 @@ describe('createEngineLogger — transport failure and deniedFor', () => {
throw new Error('async boom');
}
};
const other: Transport = { name: 'good', write: (e) => seenByOther.push(e) };
const other: Transport = { name: 'good', write: (e) => { seenByOther.push(e); } };
log.addTransport(failing);
log.addTransport(other);
@ -546,7 +546,7 @@ describe('callbackTransport', () => {
it('respects minLevel option', () => {
const hits: LogEntry[] = [];
const cb = callbackTransport((e) => hits.push(e), { minLevel: LogLevel.ERROR });
const cb = callbackTransport((e) => hits.push(e), { levels: levelsAtLeast(LogLevel.ERROR) });
const log = createEngineLogger({
level: LogLevel.TRACE,
transports: [cb]
@ -572,7 +572,7 @@ describe('createEngineLogger — entry id', () => {
const seen: LogEntry[] = [];
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport({ name: 'bad', write: () => { throw new Error('x'); } });
log.addTransport({ name: 'good', write: (e) => seen.push(e) });
log.addTransport({ name: 'good', write: (e) => { seen.push(e); } });
log.error('t', 'msg');
const failureEntry = seen.find((e) => e.deniedFor !== undefined);
expect(failureEntry?.id).toBeDefined();
@ -584,7 +584,7 @@ describe('createEngineLogger — buffering', () => {
it('default buffer=0 delivers immediately', () => {
const calls: LogEntry[] = [];
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport({ name: 'nobuf', write: (e) => calls.push(e) });
log.addTransport({ name: 'nobuf', write: (e) => { calls.push(e); } });
log.info('t', 'a');
expect(calls).toHaveLength(1);
});
@ -595,7 +595,7 @@ describe('createEngineLogger — buffering', () => {
log.addTransport({
name: 'buf',
buffer: 3,
write: (e) => writeCalls.push(e)
write: (e) => { writeCalls.push(e); }
});
log.info('t', 'a');

@ -6,7 +6,7 @@ import type {
} from './types.ts';
import { LogLevel } from './types.ts';
import { logger } from './consts.ts';
import { levelsAtLeast } from './consts.ts';
// ============================================================================
// CONSOLE TRANSPORT
@ -28,12 +28,14 @@ export function consoleTransport(options: ConsoleTransportOptions = {}): Transpo
timestamp: showTimestamp = true,
prefix: showPrefix = true,
source: showSource = true,
minLevel
levels,
filter
} = options;
return {
name: 'console',
minLevel,
levels,
filter,
write(entry: LogEntry): void {
const args: unknown[] = [];
@ -122,7 +124,8 @@ export function httpTransport(options: HttpTransportOptions): Transport {
return {
name: 'http',
minLevel: options.minLevel ?? LogLevel.ERROR,
levels: options.levels ?? levelsAtLeast(LogLevel.ERROR),
filter: options.filter,
async write(entry: LogEntry): Promise<void> {
await post(serialize(entry));
},
@ -158,11 +161,15 @@ export function httpTransport(options: HttpTransportOptions): Transport {
*/
export function callbackTransport(
fn: (entry: LogEntry) => void,
options: { minLevel?: LogLevel; filter?: (entry: LogEntry) => boolean; name?: string } = {}
options: {
levels?: import('./types.ts').LevelsConfig;
filter?: (entry: LogEntry) => boolean;
name?: string;
} = {}
): Transport {
return {
write: fn,
minLevel: options.minLevel,
levels: options.levels,
filter: options.filter,
name: options.name ?? 'callback'
};

@ -146,6 +146,33 @@ export interface LogFilters {
* Each transport decides whether to process each entry via `minLevel` and
* `filter` — both applied after the logger's global level filter.
*/
/**
* Per-level acceptance + filter for a transport. More flexible than a simple
* min-level cutoff: you can enable individual levels, skip specific ones in
* the middle, and attach a filter only to certain levels.
*
* - Undefined key → disabled (the entry is skipped).
* - `enabled: false` → also skipped.
* - `enabled: true` with no `filter` → pass every entry of that level.
* - `enabled: true` with `filter` → pass only entries for which the filter
* returns `true`.
*
* @example
* // Sentry: only ERROR and FATAL
* { [LogLevel.ERROR]: { enabled: true }, [LogLevel.FATAL]: { enabled: true } }
*
* @example
* // INFO limited to auth category, WARN passes everything, ERROR too
* {
* [LogLevel.INFO]: { enabled: true, filter: (e) => e.category === 'auth' },
* [LogLevel.WARN]: { enabled: true },
* [LogLevel.ERROR]: { enabled: true }
* }
*/
export type LevelsConfig = Partial<
Record<LogLevel, { enabled: boolean; filter?: (entry: LogEntry) => boolean }>
>;
export interface Transport {
/**
* Process a single entry. Sync or async.
@ -184,14 +211,17 @@ export interface Transport {
*/
flushIntervalMs?: number;
/**
* Transport-level minimum severity. If undefined, only the logger's global
* level applies. Useful to send only ERROR+ to Sentry while keeping the
* logger at DEBUG.
* Per-level acceptance config. When undefined, every level passes (subject
* only to the logger's global level filter). See `LevelsConfig`.
*
* Use `levelsAtLeast(LogLevel.X)` as a shorthand for the common
* "everything from level X and above" case.
*/
minLevel?: LogLevel;
levels?: LevelsConfig;
/**
* Arbitrary predicate. If it returns `false`, the entry is skipped.
* Useful to filter by category, tag, traceId, etc.
* Global arbitrary predicate applied after the per-level filter. If it
* returns `false`, the entry is skipped. Useful to drop a category/tag
* regardless of level.
*/
filter?(entry: LogEntry): boolean;
/**
@ -212,14 +242,25 @@ export interface ConsoleTransportOptions {
prefix?: boolean;
/** Include the `source` in parentheses. @default true */
source?: boolean;
minLevel?: LogLevel;
levels?: LevelsConfig;
/**
* Predicate that skips noisy entries (vitals, high-volume debug streams)
* without affecting other transports. Returns `false` to drop the entry
* from the console only.
*
* @example
* consoleTransport({ filter: (e) => !e.tags?.includes('vitals') })
* consoleTransport({ filter: (e) => e.category !== 'http-debug' })
*/
filter?: (entry: LogEntry) => boolean;
}
export interface HttpTransportOptions {
url: string;
headers?: Record<string, string>;
/** @default LogLevel.ERROR */
minLevel?: LogLevel;
/** @default levelsAtLeast(LogLevel.ERROR) */
levels?: LevelsConfig;
filter?: (entry: LogEntry) => boolean;
}
// ============================================================================

@ -0,0 +1,166 @@
import type { EngineLogger } from './types.ts';
import { LogLevel } from './types.ts';
/**
* Web Vitals integration.
*
* Turns `web-vitals` observations into structured log entries, so every
* transport (console, Sentry, Datadog, Loki, OTel, ...) automatically
* sees them — useful for pinpointing slow routes, regressions and
* device-specific problems without building a separate pipeline.
*
* @example
* import * as webVitals from 'web-vitals';
* import { registerWebVitals } from '$logr/vitals';
*
* registerWebVitals(logger, webVitals);
* // Every LCP/INP/CLS/FCP/TTFB sample is now a LogEntry in the pipeline.
*/
/**
* Standard Web Vital names. INP replaced FID as a Core Web Vital in March 2024.
*/
export type WebVitalName = 'LCP' | 'INP' | 'CLS' | 'FCP' | 'TTFB';
/**
* Rating buckets computed by `web-vitals` based on Google's recommended
* thresholds.
*/
export type WebVitalRating = 'good' | 'needs-improvement' | 'poor';
/**
* Shape of the metric object emitted by `web-vitals` (subset used by logr).
*/
export interface WebVitalMetric {
name: WebVitalName;
value: number;
rating: WebVitalRating;
delta: number;
id: string;
navigationType?: string;
}
/**
* Duck-typed subset of the `web-vitals` package. Each observer is optional
* so the caller can opt into specific metrics without importing the rest.
*/
export interface WebVitalsLike {
onLCP?: (cb: (metric: WebVitalMetric) => void, opts?: { reportAllChanges?: boolean }) => void;
onINP?: (cb: (metric: WebVitalMetric) => void, opts?: { reportAllChanges?: boolean }) => void;
onCLS?: (cb: (metric: WebVitalMetric) => void, opts?: { reportAllChanges?: boolean }) => void;
onFCP?: (cb: (metric: WebVitalMetric) => void, opts?: { reportAllChanges?: boolean }) => void;
onTTFB?: (cb: (metric: WebVitalMetric) => void, opts?: { reportAllChanges?: boolean }) => void;
}
export interface VitalsOptions {
/** Category assigned to every vitals entry. @default 'vitals' */
category?: string;
/**
* Rating → LogLevel mapping. Defaults keep `good` quiet (DEBUG) so
* production noise stays low, promote `needs-improvement` to INFO,
* and raise `poor` to WARN so it stands out in dashboards.
*/
levels?: Partial<Record<WebVitalRating, LogLevel>>;
/** Which metrics to observe. @default all available */
metrics?: WebVitalName[];
/** Extra tags appended to every vitals entry. */
tags?: string[];
/**
* When `true`, the `web-vitals` observer calls back on every change
* (useful for CLS / INP which accumulate). @default false
*/
reportAllChanges?: boolean;
/**
* Include `window.location.pathname` in the entry context.
* Use this to correlate metrics with routes in dashboards.
* @default true
*/
includeRoute?: boolean;
}
const DEFAULT_LEVELS: Record<WebVitalRating, LogLevel> = {
good: LogLevel.DEBUG,
'needs-improvement': LogLevel.INFO,
poor: LogLevel.WARN
};
const ALL_METRICS: WebVitalName[] = ['LCP', 'INP', 'CLS', 'FCP', 'TTFB'];
/**
* Register Web Vitals observers that emit `LogEntry` via the provided logger.
*
* Safe to call in SSR — no-op when `window` is undefined. Safe to call more
* than once; every call registers its own observers (the `web-vitals`
* internals deduplicate, but avoid double registration if possible).
*
* The observer API of `web-vitals` cannot be detached once registered — the
* function returns nothing. To stop reporting, change the log level or add a
* filter on the transport side.
*/
export function registerWebVitals(
logger: EngineLogger,
webVitals: WebVitalsLike,
options: VitalsOptions = {}
): void {
if (typeof window === 'undefined') return;
const category = options.category ?? 'vitals';
const levels = { ...DEFAULT_LEVELS, ...(options.levels ?? {}) };
const enabled = new Set(options.metrics ?? ALL_METRICS);
const extraTags = options.tags ?? [];
const includeRoute = options.includeRoute ?? true;
const reportAllChanges = options.reportAllChanges ?? false;
const observerOpts = reportAllChanges ? { reportAllChanges: true } : undefined;
const report = (metric: WebVitalMetric) => {
const level = levels[metric.rating];
const method = levelToMethod(level);
logger[method](
category,
`${metric.name}: ${metric.value.toFixed(2)} (${metric.rating})`,
{
tags: ['vitals', metric.name.toLowerCase(), metric.rating, ...extraTags],
durationMs: metric.value,
context: {
metric: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
metricId: metric.id,
navigationType: metric.navigationType,
...(includeRoute && typeof location !== 'undefined'
? { route: location.pathname }
: {})
}
}
);
};
if (enabled.has('LCP')) webVitals.onLCP?.(report, observerOpts);
if (enabled.has('INP')) webVitals.onINP?.(report, observerOpts);
if (enabled.has('CLS')) webVitals.onCLS?.(report, observerOpts);
if (enabled.has('FCP')) webVitals.onFCP?.(report, observerOpts);
if (enabled.has('TTFB')) webVitals.onTTFB?.(report, observerOpts);
}
function levelToMethod(
level: LogLevel
): 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' {
switch (level) {
case LogLevel.TRACE:
return 'trace';
case LogLevel.DEBUG:
return 'debug';
case LogLevel.INFO:
return 'info';
case LogLevel.WARN:
return 'warn';
case LogLevel.ERROR:
return 'error';
case LogLevel.FATAL:
return 'fatal';
default:
return 'info';
}
}

@ -4,9 +4,11 @@
LogLevel,
callbackTransport,
consoleTransport,
createEngineLogger
createEngineLogger,
levelsAtLeast
} from '$logr';
import { sentryTransport } from '$logr/adapters/sentry';
import { registerWebVitals } from '$logr/vitals';
import { browser } from '$app/environment';
// Capturamos las entries localmente para mostrarlas en una tabla.
@ -26,7 +28,15 @@
env: 'test',
sessionId: 'sess-' + Math.random().toString(36).slice(2, 8)
},
transports: [consoleTransport({ minLevel: LogLevel.DEBUG }), captureTransport]
transports: [
// Filter vitals out of the console to keep it readable — they
// still flow to Sentry/Datadog/Loki/Capture.
consoleTransport({
levels: levelsAtLeast(LogLevel.DEBUG),
filter: (e) => !e.tags?.includes('vitals')
}),
captureTransport
]
});
const LEVELS = [
@ -117,7 +127,7 @@
function attachFakeSentry(): void {
const fakeSentry: Transport = {
name: 'fake-sentry',
minLevel: LogLevel.ERROR,
levels: levelsAtLeast(LogLevel.ERROR),
write(entry) {
console.log('[fake-sentry] captureException/Message', {
message: entry.message,
@ -174,6 +184,24 @@
refreshTransports();
}
// -------------------------------------------------------------------------
// Web Vitals integration
// -------------------------------------------------------------------------
let vitalsEnabled = $state(false);
async function enableVitals(): Promise<void> {
if (!browser || vitalsEnabled) return;
const webVitals = await import('web-vitals');
registerWebVitals(logger, webVitals, {
// Custom tag so we can filter this page's vitals easily in dashboards.
tags: ['test-page'],
// Promote 'poor' to ERROR to stand out visually in the table.
levels: { poor: LogLevel.ERROR }
});
vitalsEnabled = true;
}
// -------------------------------------------------------------------------
// Sentry integration
// -------------------------------------------------------------------------
@ -219,7 +247,7 @@
const Sentry = await import('@sentry/browser');
sentryRemove = logger.addTransport(
sentryTransport(Sentry, {
minLevel: LogLevel.ERROR,
eventLevel: LogLevel.ERROR,
breadcrumbLevel: LogLevel.INFO,
name: 'sentry'
})
@ -378,6 +406,28 @@
</div>
</section>
<section class="vitals">
<h2>Web Vitals (RUM)</h2>
<p class="hint">
Activa observadores de <code>LCP</code>, <code>INP</code>, <code>CLS</code>,
<code>FCP</code>, <code>TTFB</code>. Cada métrica se emite como una <code>LogEntry</code> con
categoría <code>vitals</code> y <code>durationMs</code> = valor. Los transports downstream
(Sentry, Datadog, Loki, OTel) las reciben automáticamente; el console está filtrado para
evitar ruido.
</p>
<div class="btn-row">
<button type="button" onclick={enableVitals} disabled={vitalsEnabled}>
{vitalsEnabled ? '✓ Web Vitals activados' : 'Activar Web Vitals'}
</button>
</div>
{#if vitalsEnabled}
<p class="hint success">
✓ Escuchando. Interactuá con la página (click, scroll, tab) o recargá para disparar
métricas. Aparecerán en el historial con <code>tag: vitals</code>.
</p>
{/if}
</section>
<section class="sentry">
<h2>Integración con Sentry</h2>
<p class="hint">
@ -433,8 +483,8 @@
{#each activeTransports as t, i (i)}
<div class="transport">
<strong>{t.name ?? 'anonymous'}</strong>
{#if t.minLevel !== undefined}
<span class="badge">minLevel: {levelName(t.minLevel)}</span>
{#if t.levels}
<span class="badge">levels: {Object.keys(t.levels).map((k) => levelName(Number(k))).join(', ')}</span>
{/if}
{#if t.filter}
<span class="badge">filter</span>
@ -728,6 +778,24 @@
.sentry h2 {
margin-top: 0;
}
.vitals {
background: #f0f7ff;
border: 1px solid #b3d1f0;
padding: 1rem 1.25rem;
border-radius: 8px;
margin: 1.5rem 0;
}
.vitals h2 {
margin-top: 0;
}
.vitals .btn-row button {
padding: 0.5rem 0.9rem;
font-size: 0.9rem;
}
.vitals .btn-row button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.sentry-form {
margin-top: 0.75rem;
}

Loading…
Cancel
Save

Powered by TurnKey Linux.