docs(arts): A2 ES->EN — clipboard (full) + langs/logger (light passes)

First A2 batch (Spanish -> English, faithful; code blocks + i18n example data
left untouched):
- clipboard: full translation (was fully Spanish).
- langs: light pass — the "Handoff 2026-05-12" Spanish paragraph only (the rest
  was already English; example i18n records like 'Aceptar' are data, not prose).
- logger: light pass — the "Framework Logger Contract" + "Framework Diagnostics"
  Spanish spans (rest already English).

No residual Spanish prose in the three files (verified by grep). arts:check 0/0/22.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent ed151250c3
commit 65c6ae4559

@ -1,20 +1,20 @@
# clipboard
`arts/clipboard` contiene `ActiveClipboard`, un servicio pequeño para escribir
texto en el portapapeles sin que los componentes lean `navigator.clipboard`
directamente.
`arts/clipboard` contains `ActiveClipboard`, a small service for writing text to
the clipboard without components reading `navigator.clipboard` directly.
## Ownership
- `ActiveApp` puede declararlo con `defineActiveClipboard(...)`.
- `ActiveUix` standalone lo crea por defecto y permite desactivarlo con
- `ActiveApp` can declare it with `defineActiveClipboard(...)`.
- Standalone `ActiveUix` creates it by default and lets you disable it with
`clipboard:false`.
- `ActiveUix` attach no crea sustitutos: si una capa pide `uix.clipboard` y
`app.clipboard` no existe, falla con un error explicito.
- `ActiveDom` no es el dueño de esta capacidad. El portapapeles es una
capability del entorno, no una mutacion visual de nodos DOM.
- Attached `ActiveUix` creates no substitutes: if a layer asks for
`uix.clipboard` and `app.clipboard` does not exist, it fails with an explicit
error.
- `ActiveDom` does not own this capability. The clipboard is an environment
capability, not a visual mutation of DOM nodes.
## Uso directo
## Direct use
```ts
import { createActiveClipboard } from '$clipboard';
@ -25,9 +25,9 @@ await clipboard.writeText('Copied text');
clipboard.dispose();
```
## Inyeccion
## Injection
Tests, SSR shells y apps con permisos propios pueden inyectar un writer:
Tests, SSR shells and apps with their own permissions can inject a writer:
```ts
const clipboard = createActiveClipboard({
@ -39,7 +39,7 @@ const clipboard = createActiveClipboard({
});
```
Si no hay writer ni `navigator.clipboard.writeText`, `writeText(...)` lanza
With no writer and no `navigator.clipboard.writeText`, `writeText(...)` throws
`ActiveClipboardUnavailableError`.
## ActiveApp
@ -59,6 +59,6 @@ await App.clipboard.writeText('Copied text');
## UIX / Soma
Los componentes Soma que necesitan escribir al portapapeles consumen
`uix.clipboard` a traves de su scope de capa. No importan `$clipboard` ni leen
`navigator` directamente.
Soma components that need to write to the clipboard consume `uix.clipboard`
through their layer scope. They do not import `$clipboard` or read `navigator`
directly.

@ -6,10 +6,10 @@ translation resolution with fallback chain, pluralization via
## Handoff 2026-05-12
`Langs` no es `locale` y no debe decidir formatos. En la arquitectura a cerrar,
`ActiveLangs` consume la preferencia de idioma (`prefs.language`) que le pasa la
composicion (`ActiveApp` o `ActiveUix` standalone). El locale de formato vive
en `prefs.locale` y pertenece a `Format`.
`Langs` is not `locale` and must not decide formats. In the target architecture,
`ActiveLangs` consumes the language preference (`prefs.language`) handed to it by
the composition (`ActiveApp` or standalone `ActiveUix`). The formatting locale
lives in `prefs.locale` and belongs to `Format`.
---
@ -60,7 +60,9 @@ langs/
Configured in `svelte.config.js`:
```js
alias: { $langs: 'src/arts/langs' }
alias: {
$langs: 'src/arts/langs';
}
```
Imports:
@ -93,8 +95,8 @@ All locale keys are optional — no locale is hardcoded.
```ts
(params: { name: string }) => ({
es: `Hola, ${params.name}`,
en: `Hello, ${params.name}`
es: `Hola, ${params.name}`,
en: `Hello, ${params.name}`
});
```
@ -108,16 +110,16 @@ JSON contains `"Hola, {{name}}"` regardless of whether the function uses
import { p } from '$langs';
p({
es: { one: '{{count}} mensaje', other: '{{count}} mensajes' },
en: { one: '{{count}} message', other: '{{count}} messages' },
ar: {
zero: 'لا توجد رسائل',
one: 'رسالة واحدة',
two: 'رسالتان',
few: '{{count}} رسائل',
many: '{{count}} رسالة',
other: '{{count}} رسالة'
}
es: { one: '{{count}} mensaje', other: '{{count}} mensajes' },
en: { one: '{{count}} message', other: '{{count}} messages' },
ar: {
zero: 'لا توجد رسائل',
one: 'رسالة واحدة',
two: 'رسالتان',
few: '{{count}} رسائل',
many: '{{count}} رسالة',
other: '{{count}} رسالة'
}
});
```
@ -128,8 +130,8 @@ Internally uses `Intl.PluralRules`. Forms: `zero`, `one`, `two`, `few`,
### LangRef — alias to another key
```ts
'#?common.ok'; // resolves the value at `common.ok`
'#?common.ok|fallback'; // literal fallback if the key does not exist
'#?common.ok'; // resolves the value at `common.ok`
'#?common.ok|fallback'; // literal fallback if the key does not exist
```
Prefix `#?`, separator `|` for the fallback literal. Up to 3 levels of
@ -142,21 +144,21 @@ import { p } from '$langs';
import type { LangNode } from '$langs';
export const translations = {
common: {
ok: { es: 'Aceptar', en: 'OK' },
cancel: { es: 'Cancelar', en: 'Cancel' }
},
greet: (params: { name: string }) => ({
es: `Hola, ${params.name}`,
en: `Hello, ${params.name}`
}),
messages: {
unread: p({
es: { one: '{{count}} mensaje sin leer', other: '{{count}} mensajes sin leer' },
en: { one: '{{count}} unread message', other: '{{count}} unread messages' }
})
},
ref: '#?common.ok'
common: {
ok: { es: 'Aceptar', en: 'OK' },
cancel: { es: 'Cancelar', en: 'Cancel' }
},
greet: (params: { name: string }) => ({
es: `Hola, ${params.name}`,
en: `Hello, ${params.name}`
}),
messages: {
unread: p({
es: { one: '{{count}} mensaje sin leer', other: '{{count}} mensajes sin leer' },
en: { one: '{{count}} unread message', other: '{{count}} unread messages' }
})
},
ref: '#?common.ok'
} satisfies LangNode;
export type TranslationSchema = typeof translations;
@ -175,12 +177,12 @@ import { translations } from './translations';
const langs = createEngineLangs(translations, 'es');
// `t()` and `ts()` require an explicit locale on the pure engine.
langs.t('common.ok', undefined, 'es'); // 'Aceptar'
langs.t('greet', { name: 'Ana' }, 'en'); // 'Hello, Ana'
langs.t('messages.unread', { count: 5 }, 'en'); // '5 unread messages'
langs.t('common.ok', undefined, 'es'); // 'Aceptar'
langs.t('greet', { name: 'Ana' }, 'en'); // 'Hello, Ana'
langs.t('messages.unread', { count: 5 }, 'en'); // '5 unread messages'
langs.ts({ es: 'Hola', en: 'Hello' }, 'en'); // 'Hello'
langs.ts('#?common.ok', 'es'); // 'Aceptar'
langs.ts({ es: 'Hola', en: 'Hello' }, 'en'); // 'Hello'
langs.ts('#?common.ok', 'es'); // 'Aceptar'
```
### `ts()` and the `LangString` type
@ -199,15 +201,15 @@ shapes transparently, and components call `langs.ts(message)` to render.
```ts
// 1) Plain string — passthrough, no lookup
langs.ts('Static text', 'en'); // 'Static text'
langs.ts('Static text', 'en'); // 'Static text'
// 2) LangRecord — inline per-locale values
langs.ts({ es: 'Guardar', en: 'Save', ar: 'حفظ' }, 'en'); // 'Save'
langs.ts({ es: 'Solo español' }, 'en'); // 'Solo español' (single-locale fallback)
langs.ts({ es: 'Guardar', en: 'Save', ar: 'حفظ' }, 'en'); // 'Save'
langs.ts({ es: 'Solo español' }, 'en'); // 'Solo español' (single-locale fallback)
// 3) LangRef — alias to a schema key (optional literal fallback)
langs.ts('#?common.ok', 'es'); // resolves to 'Aceptar'
langs.ts('#?missing.key|Default', 'es'); // fallback literal wins
langs.ts('#?common.ok', 'es'); // resolves to 'Aceptar'
langs.ts('#?missing.key|Default', 'es'); // fallback literal wins
```
Practical pattern — a generic button component that accepts a translatable label:
@ -215,12 +217,12 @@ Practical pattern — a generic button component that accepts a translatable lab
```svelte
<!-- MyButton.svelte -->
<script lang="ts">
import type { LangString } from '$langs';
import { getContext } from 'svelte';
import type { ActiveLangs } from '$langs';
import type { LangString } from '$langs';
import { getContext } from 'svelte';
import type { ActiveLangs } from '$langs';
let { label, onclick }: { label: LangString; onclick: () => void } = $props();
const langs = getContext<ActiveLangs>('langs');
let { label, onclick }: { label: LangString; onclick: () => void } = $props();
const langs = getContext<ActiveLangs>('langs');
</script>
<button {onclick}>{langs.ts(label)}</button>
@ -239,19 +241,25 @@ The three guards help narrow a `LangString` when needed:
```ts
import { isLangRef, isLangRecord, isLangString } from '$langs';
if (isLangRef(value)) { /* value is `#?...` */ }
if (isLangRecord(value)) { /* value is { es?, en?, ar?, ... } */ }
if (isLangString(value)) { /* value is one of the three */ }
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
<script lang="ts">
import { createActiveLangs } from '$langs/active-langs.svelte';
import { translations } from './translations';
import { createActiveLangs } from '$langs/active-langs.svelte';
import { translations } from './translations';
const langs = createActiveLangs(translations, 'es');
const langs = createActiveLangs(translations, 'es');
</script>
<h1>{langs.t('common.ok')}</h1>
@ -267,8 +275,8 @@ Signatures on the reactive wrapper make `locale` **optional** — it defaults
to the active reactive locale:
```ts
langs.t('common.ok'); // uses active locale
langs.t('common.ok', undefined, 'en'); // explicit locale override
langs.t('common.ok'); // uses active locale
langs.t('common.ok', undefined, 'en'); // explicit locale override
```
### Fallback chain
@ -288,19 +296,19 @@ deep-merges existing keys:
```ts
langs.extend('shop', {
product: { es: 'Producto', en: 'Product' }
product: { es: 'Producto', en: 'Product' }
});
langs.t('shop.product'); // 'Producto'
// Dotted namespace — creates nested structure
langs.extend('app.settings', {
title: { es: 'Ajustes', en: 'Settings' }
title: { es: 'Ajustes', en: 'Settings' }
});
// Subscribe to schema changes (triggers re-render in Svelte automatically)
const unsub = langs.onSchemaChange(() => {
// schema changed
// schema changed
});
```
@ -336,18 +344,18 @@ child shares locale state with the parent:
```ts
const child = langs.register('admin', {
dashboard: { es: 'Panel', en: 'Dashboard' }
dashboard: { es: 'Panel', en: 'Dashboard' }
});
child.t('admin.dashboard'); // 'Panel'
child.t('common.ok'); // 'Aceptar' (inherits parent schema)
child.t('common.ok'); // 'Aceptar' (inherits parent schema)
langs.setLocale('en');
child.getLocale(); // 'en' (synced)
child.getLocale(); // 'en' (synced)
child.dispose(); // detach from parent
child.dispose(); // detach from parent
langs.setLocale('es');
child.getLocale(); // 'en' (stopped syncing)
child.getLocale(); // 'en' (stopped syncing)
```
---
@ -362,11 +370,11 @@ import { createActiveMonoLangs } from '$langs/mono-langs.svelte';
const langs = createActiveMonoLangs();
langs.t('common.ok'); // 'common.ok'
langs.t('common.ok|Aceptar'); // 'Aceptar'
langs.t('greet|Hello {{name}}', { name: 'Ada' }); // 'Hello Ada'
langs.ts('plain text'); // 'plain text'
langs.ts({ es: 'Hola', en: 'Hello' }); // 'Hola' (first non-empty entry)
langs.t('common.ok'); // 'common.ok'
langs.t('common.ok|Aceptar'); // 'Aceptar'
langs.t('greet|Hello {{name}}', { name: 'Ada' }); // 'Hello Ada'
langs.ts('plain text'); // 'plain text'
langs.ts({ es: 'Hola', en: 'Hello' }); // 'Hola' (first non-empty entry)
```
The locale is held as `$state` so consumers that subscribe via
@ -381,9 +389,9 @@ configure real translations or to add `|fallback` text. Calls that include
```ts
const langs = createActiveMonoLangs({ logger: app.logger });
langs.t('users.profile.name'); // warns once
langs.t('users.profile.name'); // dedup, no second warn
langs.t('users.profile.email'); // different path -> warns once
langs.t('users.profile.name'); // warns once
langs.t('users.profile.name'); // dedup, no second warn
langs.t('users.profile.email'); // different path -> warns once
```
`createActiveMonoLangs()` is available as an explicit null-object translator
@ -451,8 +459,8 @@ In addition to the above (with `locale` made optional on `t` / `ts`):
| `LangParams` | `Record<string, unknown>` — canonical params type |
| `PluralConfig` | `{ [K in SupportedLocale]?: PluralForms }` |
| `PluralForms` | `{ other: string } & Partial<Record<PluralCategory, string>>` |
| `EngineLangs<S>` | Pure engine public interface |
| `ActiveLangs<S>` | Reactive wrapper public interface |
| `EngineLangs<S>` | Pure engine public interface |
| `ActiveLangs<S>` | Reactive wrapper public interface |
| `Logger` | Shared logger contract from `$libs/logger`; `setLogger(logger)` is set-once and diagnostics flow through `LangDiagnostics` |
---
@ -508,8 +516,8 @@ const record = { en: 'Hello', 'es-MX': 'Qué onda', 'pt-BR': 'Oi' };
resolve(record, 'es-MX'); // 'Qué onda' — exact
resolve(record, 'en-GB'); // 'Hello' — region → base
resolve(record, 'es-AR'); // missing — no exact, no base, no sibling search
resolve(record, 'pt'); // 'Oi' — bare → only sibling
resolve(record, 'es'); // 'Qué onda' — bare → first sibling (single match, no warning)
resolve(record, 'pt'); // 'Oi' — bare → only sibling
resolve(record, 'es'); // 'Qué onda' — bare → first sibling (single match, no warning)
```
With multiple siblings the warning fires:

@ -60,7 +60,9 @@ logger/
Configured in `svelte.config.js`:
```js
alias: { $logger: 'src/arts/logger' }
alias: {
$logger: 'src/arts/logger';
}
```
Imports:
@ -113,24 +115,24 @@ logger.debug('sql', () => `Query: ${expensiveFormat(query)}`);
```ts
enum LogLevel {
TRACE = 0,
DEBUG = 1,
INFO = 2,
WARN = 3,
ERROR = 4,
FATAL = 5,
NONE = 999 // disables all logs
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 | 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)
@ -140,15 +142,15 @@ instead of re-rolling another `switch (level)`:
```ts
import { LEVEL_LABELS, LEVEL_CONSOLE_METHOD, LogLevel } from '$logger';
LEVEL_LABELS[LogLevel.WARN]; // 'warn'
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
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
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).
@ -161,11 +163,11 @@ Both omit `LogLevel.NONE` (which is a routing decision, not an emitted level).
```ts
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?: 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
}
```
@ -183,15 +185,15 @@ logger.fatal(category, message, input?)
Where:
```ts
type LogMessage = string | (() => string); // lazy thunks supported
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
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
}
```
@ -206,16 +208,16 @@ logger.serialize() // JSON string (circular-safe)
### Runtime controls
```ts
logger.setLevel(LogLevel.WARN)
logger.setMaxLogs(500)
logger.setGlobalContext({ appVersion: '1.3.0' })
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
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
@ -245,14 +247,14 @@ extend `globalContext`.
```ts
logger.time('fetch');
await doWork();
logger.timeEnd('fetch', 'perf', 'operation done'); // emits INFO with durationMs
logger.timeEnd('fetch', 'perf', 'operation done'); // emits INFO with durationMs
```
---
## Framework Logger Contract
El contrato minimo para cualquier modulo vive en `$libs/logger`:
The minimal contract for any module lives in `$libs/logger`:
```ts
import type { Logger, LogInput, LogMessage } from '$libs/logger';
@ -269,19 +271,19 @@ export interface Logger {
}
```
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.
Artifacts must not declare local mini-loggers or narrowed per-module aliases.
If a module needs to log freely, it receives `logger?: Logger` and calls the
appropriate level.
`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.
`EngineLogger` lives in `arts/logger` and extends that contract with runtime
capabilities: transports, history, children, timers, flush and dispose. The rest
of the framework does not need to know those capabilities to emit 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.
`Diagnostics` is an optional layer on top of `Logger`, meant for repeatable
internal framework events: a listener that throws, reconnect exhausted, failed
validation, invalidated cache, etc.
```ts
import { createCatalogDiagnostics, LogLevel, type Diagnostics } from '$libs/logger';
@ -309,18 +311,18 @@ function createConnDiagnostics(logger?: Logger): Diagnostics<ConnEvent> {
}
```
La idea no es reemplazar `logger.info(...)` ni obligar a declarar cada log como
evento. La regla es:
The idea is not to replace `logger.info(...)` nor to force every log to be
declared as an event. The rule is:
- 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.
- Use `logger.info(...)`, `logger.warn(...)`, etc. for free-form business or
module logs.
- Use `Diagnostics.emit(...)` for catalogued internal events we want to keep
homogeneous, with category/message/level centralized.
- The diagnostic keeps `diagnostics.logger`, so it is still a normal logger
underneath.
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:
Diagnostics accept an optional threshold in the logger's style; if the catalog
descriptor falls below it, the event is dropped before reaching the logger:
```ts
const diagnostics = createCatalogDiagnostics({
@ -334,9 +336,9 @@ const diagnostics = createCatalogDiagnostics({
});
```
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.
`EngineLogger` remains the one that ultimately decides whether the entry is
emitted, stored, passes transport filters or is sent to Sentry/Datadog/Loki/etc.
`Diagnostics` only translates an internal event into a normal logger call.
---
@ -344,18 +346,18 @@ se guarda, pasa filtros de transporte o se manda a Sentry/Datadog/Loki/etc.
```ts
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;
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;
}
```
@ -397,10 +399,12 @@ 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
}));
logger.addTransport(
sentryTransport(Sentry, {
name: 'sentry',
failureThrottleMs: 60_000 // one failure entry per minute, max
})
);
```
Behavior:
@ -426,11 +430,11 @@ created via `child()` share the throttle state with the root.
```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
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
}
```
@ -467,10 +471,12 @@ 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
}));
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
@ -489,11 +495,11 @@ 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'
clientToken: 'pubXXXXXXXXXX',
site: 'datadoghq.eu',
service: 'my-app',
env: 'prod',
version: '1.0.0'
});
logger.addTransport(datadogTransport(datadogLogs.logger, { level: LogLevel.WARN }));
@ -521,13 +527,15 @@ 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
}));
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
@ -542,7 +550,9 @@ import { logs } from '@opentelemetry/api-logs';
import { LoggerProvider } from '@opentelemetry/sdk-logs';
import { otelTransport } from '$logger/adapters/otel';
const provider = new LoggerProvider({ /* ... */ });
const provider = new LoggerProvider({
/* ... */
});
logs.setGlobalLoggerProvider(provider);
const otelLogger = logs.getLogger('my-app');
@ -573,8 +583,8 @@ 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 }
// Promote 'poor' to WARN so dashboards light it up.
levels: { poor: LogLevel.WARN }
});
```
@ -590,12 +600,12 @@ By default each metric emits a single entry with:
```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
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
}
```
@ -607,14 +617,14 @@ 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: '...' })
]
transports: [
consoleTransport({
filter: (e) => !e.tags?.includes('vitals')
}),
// Loki/Sentry/Datadog still get the vitals entries.
sentryTransport(Sentry),
lokiTransport({ url: '...' })
]
});
```
@ -640,11 +650,11 @@ 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`) |
| 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.
@ -653,10 +663,12 @@ design**. In production you do not want every page load producing an Issue.
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
}));
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
@ -664,11 +676,11 @@ 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
}
levels: {
good: LogLevel.ERROR,
'needs-improvement': LogLevel.ERROR,
poor: LogLevel.ERROR
}
});
```
@ -679,6 +691,7 @@ 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,
@ -690,9 +703,9 @@ 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
dsn: '<your-dsn>',
integrations: [Sentry.browserTracingIntegration()],
tracesSampleRate: 0.1 // 10% of page loads send a transaction
});
```
@ -701,13 +714,13 @@ 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) |
| 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
@ -726,7 +739,7 @@ logger.addTransport(sentryTransport(Sentry));
// Counter of errors
logger.subscribe((entry) => {
if (entry.level >= LogLevel.ERROR) metrics.errorsPerMinute.inc();
if (entry.level >= LogLevel.ERROR) metrics.errorsPerMinute.inc();
});
```
@ -734,17 +747,17 @@ logger.subscribe((entry) => {
```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;
}
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;
}
}
```
@ -752,8 +765,8 @@ export async function handle(request: Request) {
```ts
if (flags.enableRemoteLogs) {
const detach = logger.addTransport(httpTransport({ url: '...' }));
onFlagDisable(() => detach());
const detach = logger.addTransport(httpTransport({ url: '...' }));
onFlagDisable(() => detach());
}
```

Loading…
Cancel
Save

Powered by TurnKey Linux.