You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

998 lines
25 KiB

<script lang="ts">
import type { LogEntry, Transport } from '$logger';
import {
LogLevel,
callbackTransport,
consoleTransport,
createEngineLogger
} from '$logger';
import { sentryTransport } from '$logger/adapters/sentry';
import { registerWebVitals } from '$logger/vitals';
import { browser } from '$app/environment';
// Capturamos las entries localmente para mostrarlas en una tabla.
const captured: LogEntry[] = $state([]);
const captureTransport: Transport = {
name: 'ui-capture',
write(entry) {
captured.push(entry);
}
};
const logger = createEngineLogger({
level: LogLevel.DEBUG,
captureSource: true,
globalContext: {
appVersion: '1.0.0',
env: 'test',
sessionId: 'sess-' + Math.random().toString(36).slice(2, 8)
},
transports: [
// Filter vitals out of the console to keep it readable — they
// still flow to Sentry/Datadog/Loki/Capture.
consoleTransport({
level: LogLevel.DEBUG,
filter: (e) => !e.tags?.includes('vitals')
}),
captureTransport
]
});
const LEVELS = [
{ name: 'TRACE', level: LogLevel.TRACE, color: '#8a8f96' },
{ name: 'DEBUG', level: LogLevel.DEBUG, color: '#6a737d' },
{ name: 'INFO', level: LogLevel.INFO, color: '#0366d6' },
{ name: 'WARN', level: LogLevel.WARN, color: '#b08800' },
{ name: 'ERROR', level: LogLevel.ERROR, color: '#d73a49' },
{ name: 'FATAL', level: LogLevel.FATAL, color: '#6f42c1' }
] as const;
let currentMinLevel = $state(LogLevel.TRACE);
let counter = $state(1);
let traceIdFilter = $state('');
// Referencias a transports dinámicos para poder mostrarlos / removerlos.
const attached: { name: string; remove: () => void }[] = $state([]);
// Forzamos un contador para refrescar la lista de transports (readonly array).
let transportsVersion = $state(0);
function refreshTransports(): void {
transportsVersion++;
}
function levelName(l: LogLevel): string {
return LogLevel[l] ?? String(l);
}
function logSample(level: LogLevel): void {
const message = `Test event #${counter}`;
const fns = {
[LogLevel.TRACE]: logger.trace,
[LogLevel.DEBUG]: logger.debug,
[LogLevel.INFO]: logger.info,
[LogLevel.WARN]: logger.warn,
[LogLevel.ERROR]: logger.error,
[LogLevel.FATAL]: logger.fatal
} as const;
fns[level as keyof typeof fns]?.('demo', message, {
context: { n: counter },
tags: ['demo', `counter-${counter % 3}`],
traceId: `trace-${Math.floor(counter / 3)}`
});
counter++;
}
function logWithError(): void {
try {
JSON.parse('{ not valid json }');
} catch (err) {
logger.error('parse', 'JSON parse error', {
error: err,
tags: ['parse', 'critical'],
context: { input: '{ not valid json }' }
});
}
}
function logWithChildContext(): void {
const requestLogger = logger.child({
requestId: 'req-' + Math.random().toString(36).slice(2, 6),
userId: 42
});
requestLogger.info('auth', 'Authenticated request');
requestLogger.warn('auth', 'Token near expiry');
}
function logWithLazyMessage(): void {
// El thunk solo se evalúa si supera el filtro de nivel global.
logger.debug('perf', () => {
const t0 = performance.now();
// Simular formatting caro
const data = Array.from({ length: 1000 }, (_, i) => i).reduce((a, b) => a + b, 0);
return `Lazy-computed sum=${data} in ${(performance.now() - t0).toFixed(2)}ms`;
});
}
async function logWithTimer(): Promise<void> {
logger.time('fake-request');
await new Promise((resolve) => setTimeout(resolve, 150 + Math.random() * 250));
logger.timeEnd('fake-request', 'perf', 'Simulated request completed');
}
// -------------------------------------------------------------------------
// Demo: transports dinámicos
// -------------------------------------------------------------------------
function attachFakeSentry(): void {
const fakeSentry: Transport = {
name: 'fake-sentry',
level: LogLevel.ERROR,
write(entry) {
console.log('[fake-sentry] captureException/Message', {
message: entry.message,
error: entry.error?.message,
tags: entry.tags
});
}
};
const remove = logger.addTransport(fakeSentry);
attached.push({ name: fakeSentry.name!, remove });
refreshTransports();
}
function attachMetricsSubscriber(): void {
let errorCount = 0;
const unsub = logger.subscribe((entry) => {
if (entry.level >= LogLevel.ERROR) {
errorCount++;
console.log(`[metrics] errorCount=${errorCount}`, { category: entry.category });
}
});
attached.push({ name: 'metrics (subscriber)', remove: unsub });
refreshTransports();
}
function attachSyncFailingTransport(): void {
const failing: Transport = {
name: 'sync-fail',
write() {
throw new Error('Simulated sync transport failure');
}
};
const remove = logger.addTransport(failing);
attached.push({ name: failing.name!, remove });
refreshTransports();
}
function attachAsyncFailingTransport(): void {
const failing: Transport = {
name: 'async-fail',
async write() {
await new Promise((r) => setTimeout(r, 50));
throw new Error('Simulated async transport rejection');
}
};
const remove = logger.addTransport(failing);
attached.push({ name: failing.name!, remove });
refreshTransports();
}
function detach(idx: number): void {
attached[idx].remove();
attached.splice(idx, 1);
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'],
// In this test page we promote every rating to ERROR so Sentry shows
// them as Issues — useful to validate the pipeline reaches the
// dashboard. In production you would keep the defaults (good=DEBUG,
// needs-improvement=INFO, poor=WARN) and route vitals through
// breadcrumbs or a metrics sink, not as individual Issues.
levels: {
good: LogLevel.ERROR,
'needs-improvement': LogLevel.ERROR,
poor: LogLevel.ERROR
}
});
vitalsEnabled = true;
}
// -------------------------------------------------------------------------
// Sentry integration
// -------------------------------------------------------------------------
const DSN_KEY = 'logr-test-sentry-dsn';
let sentryDsn = $state(browser ? sessionStorage.getItem(DSN_KEY) ?? '' : '');
let sentryStatus = $state<'idle' | 'initializing' | 'ready' | 'error'>('idle');
let sentryErrorMsg = $state('');
let sentryAttached = $state(false);
let sentryRemove: (() => void) | null = null;
function persistDsn(): void {
if (browser) sessionStorage.setItem(DSN_KEY, sentryDsn);
}
async function initSentry(): Promise<void> {
if (!sentryDsn.trim()) {
sentryStatus = 'error';
sentryErrorMsg = 'Pegá una DSN válida';
return;
}
sentryStatus = 'initializing';
try {
const Sentry = await import('@sentry/browser');
Sentry.init({
dsn: sentryDsn.trim(),
environment: 'logr-test',
release: 'logr-test@1.0.0',
sampleRate: 1.0,
// Browser tracing captures native Web Vitals (LCP/INP/CLS/FCP/TTFB)
// and sends them as Performance data — visible under
// "Insights → Browser → Web Vitals" in the Sentry dashboard.
integrations: [Sentry.browserTracingIntegration()],
// 1.0 = every page load sends a transaction; lower it in production.
tracesSampleRate: 1.0
});
persistDsn();
sentryStatus = 'ready';
sentryErrorMsg = '';
} catch (err) {
sentryStatus = 'error';
sentryErrorMsg = err instanceof Error ? err.message : String(err);
}
}
async function attachSentry(): Promise<void> {
if (sentryStatus !== 'ready') return;
const Sentry = await import('@sentry/browser');
sentryRemove = logger.addTransport(
sentryTransport(Sentry, {
eventLevel: LogLevel.ERROR,
breadcrumbLevel: LogLevel.INFO,
name: 'sentry'
})
);
sentryAttached = true;
refreshTransports();
}
function detachSentry(): void {
sentryRemove?.();
sentryRemove = null;
sentryAttached = false;
refreshTransports();
}
async function sendTestToSentry(): Promise<void> {
// Preparamos breadcrumbs
logger.info('sentry-test', 'Preparing Sentry test', {
context: { step: 1 }
});
logger.warn('sentry-test', 'About to trigger test error', {
context: { step: 2 },
tags: ['pre-flight']
});
// Evento real
try {
throw new Error('Logr → Sentry test: ' + new Date().toISOString());
} catch (err) {
logger.error('sentry-test', 'Test error captured', {
error: err,
traceId: 'sentry-test-' + Math.random().toString(36).slice(2, 8),
tags: ['sentry-test', 'demo'],
context: { intentional: true }
});
}
}
// -------------------------------------------------------------------------
function changeLevel(l: LogLevel): void {
currentMinLevel = l;
logger.setLevel(l);
}
function clearHistory(): void {
logger.clear();
captured.splice(0, captured.length);
}
function downloadSerialized(): void {
const json = logger.serialize();
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `logr-${new Date().toISOString()}.json`;
a.click();
URL.revokeObjectURL(url);
}
const filtered = $derived(
traceIdFilter ? captured.filter((e) => e.traceId === traceIdFilter) : captured
);
const stats = $derived({
total: captured.length,
byLevel: LEVELS.map((l) => ({
...l,
count: captured.filter((e) => e.level === l.level).length
}))
});
const activeTransports = $derived.by(() => {
void transportsVersion; // fuerza re-evaluación al cambiar lista
return logger.transports();
});
// Usado solo en el efecto callback de captureSubscriber (no aplica aquí).
// Removemos callbackTransport del import si no se usa.
void callbackTransport;
function fileShort(file?: string): string {
if (!file) return '';
const idx = file.lastIndexOf('/');
const short = idx >= 0 ? file.slice(idx + 1) : file;
return short.split('?')[0];
}
</script>
<svelte:head>
<title>/test/logr</title>
</svelte:head>
<main>
<header>
<h1>Prueba del módulo logr</h1>
<p class="subtitle">
Logger profesional, <strong>desacoplado de i18n</strong>. Transports dinámicos con filtros
per-sink, dispatch sync con soporte async, <code>deniedFor</code> en fallos,
<code>globalContext</code>, <code>child()</code>, <code>time()</code>, mensajes lazy,
<code>source</code> auto-capturado, error estructurado, <code>traceId</code>,
<code>tags</code>.
</p>
</header>
<section class="controls">
<div class="group">
<h3>Nivel mínimo</h3>
<div class="btn-row">
{#each LEVELS as { name, level } (level)}
<button
type="button"
class:active={currentMinLevel === level}
onclick={() => changeLevel(level)}
>
{name}
</button>
{/each}
</div>
<p class="hint">Actual: <strong>{levelName(currentMinLevel)}</strong></p>
</div>
<div class="group">
<h3>Emitir básico</h3>
<div class="btn-row">
{#each LEVELS as { name, level, color } (level)}
<button
type="button"
style:background={color}
style:color="#fff"
onclick={() => logSample(level)}
>
{name}
</button>
{/each}
</div>
<p class="hint">Cada 3 logs comparten <code>traceId</code>.</p>
</div>
<div class="group">
<h3>Features</h3>
<div class="btn-row">
<button type="button" onclick={logWithError}>error + Error</button>
<button type="button" onclick={logWithChildContext}>child(ctx)</button>
<button type="button" onclick={logWithTimer}>time/timeEnd</button>
<button type="button" onclick={logWithLazyMessage}>lazy msg</button>
</div>
</div>
<div class="group">
<h3>Historial</h3>
<div class="btn-row">
<button type="button" onclick={clearHistory}>Vaciar</button>
<button type="button" onclick={downloadSerialized}>Descargar JSON</button>
</div>
</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">
Pegá la DSN de tu proyecto Sentry (se guarda en <code>sessionStorage</code>). El SDK se
importa lazy para no cargarlo si no se usa.
</p>
<div class="sentry-form">
<label>
<span>DSN:</span>
<input
type="text"
bind:value={sentryDsn}
placeholder="https://xxxx@xxxx.ingest.sentry.io/xxxx"
spellcheck="false"
autocomplete="off"
/>
</label>
<div class="btn-row">
<button
type="button"
onclick={initSentry}
disabled={sentryStatus === 'initializing' || sentryStatus === 'ready'}
>
{sentryStatus === 'ready' ? '✓ Sentry inicializado' : 'Inicializar Sentry'}
</button>
{#if sentryStatus === 'ready' && !sentryAttached}
<button type="button" onclick={attachSentry} class="primary">
Attach sentryTransport (ERROR+ event, INFO/WARN breadcrumb)
</button>
{/if}
{#if sentryAttached}
<button type="button" onclick={detachSentry} class="remove-btn">Detach Sentry</button>
<button type="button" onclick={sendTestToSentry} class="primary">
Enviar test (2 breadcrumbs + 1 event)
</button>
{/if}
</div>
{#if sentryStatus === 'error'}
<p class="error-msg">Error: {sentryErrorMsg}</p>
{/if}
{#if sentryAttached}
<p class="hint success">
✓ Sentry attached como transport. Ahora cualquier log ≥ ERROR irá al dashboard de Sentry;
INFO y WARN como breadcrumbs del próximo error.
</p>
{/if}
</div>
</section>
<section>
<h2>Transports activos ({activeTransports.length})</h2>
<div class="transports">
{#each activeTransports as t, i (i)}
<div class="transport">
<strong>{t.name ?? 'anonymous'}</strong>
{#if t.level !== undefined}
<span class="badge">level: {levelName(t.level)}+</span>
{/if}
{#if t.filter}
<span class="badge">filter</span>
{/if}
</div>
{/each}
</div>
<h3 class="attach-title">Añadir dinámicamente:</h3>
<div class="btn-row">
<button type="button" onclick={attachFakeSentry}>+ fake-sentry (ERROR+)</button>
<button type="button" onclick={attachMetricsSubscriber}>+ metrics (subscriber)</button>
<button type="button" onclick={attachSyncFailingTransport}>+ sync-fail</button>
<button type="button" onclick={attachAsyncFailingTransport}>+ async-fail</button>
</div>
{#if attached.length > 0}
<h3 class="attach-title">Attached runtime:</h3>
<div class="attached">
{#each attached as a, i (i)}
<div class="attached-item">
<code>{a.name}</code>
<button type="button" class="remove" onclick={() => detach(i)}>Quitar</button>
</div>
{/each}
</div>
{/if}
<p class="hint">
Emite un log de nivel &ge; ERROR con el <code>sync-fail</code> o <code>async-fail</code>
adjunto y observá cómo aparece una entry <code>[logr]</code> con
<code>deniedFor: [&quot;sync-fail&quot;]</code> — el reporte del fallo NO se vuelve a enviar
al transport que falló.
</p>
</section>
<section>
<h2>Estadísticas</h2>
<div class="stats">
<div class="stat"><strong>{stats.total}</strong><span>Total</span></div>
{#each stats.byLevel as { name, count, color } (name)}
<div class="stat" style:border-color={color}>
<strong style:color>{count}</strong><span>{name}</span>
</div>
{/each}
</div>
</section>
<section>
<h2>Filtro por traceId</h2>
<label class="filter">
<span>traceId:</span>
<input type="text" bind:value={traceIdFilter} placeholder="trace-0" />
{#if traceIdFilter}
<button type="button" onclick={() => (traceIdFilter = '')}>Limpiar</button>
{/if}
<span class="hint-inline">
({filtered.length} de {captured.length} coinciden)
</span>
</label>
</section>
<section>
<h2>Historial ({filtered.length})</h2>
{#if filtered.length === 0}
<p class="empty">Sin entradas. Usá los botones de arriba.</p>
{:else}
<table>
<thead>
<tr>
<th>#</th>
<th>Nivel</th>
<th>Cat</th>
<th>Mensaje</th>
<th>traceId</th>
<th>tags</th>
<th>deniedFor</th>
<th>source</th>
<th>dur</th>
<th>context</th>
<th>error</th>
</tr>
</thead>
<tbody>
{#each filtered as entry, i (i)}
<tr class:failure={entry.deniedFor !== undefined}>
<td>{i + 1}</td>
<td>
<span
class="level"
style:background={LEVELS.find((l) => l.level === entry.level)?.color}
>
{levelName(entry.level)}
</span>
</td>
<td><code>{entry.category}</code></td>
<td>{entry.message}</td>
<td>
{#if entry.traceId}
<code class="trace">{entry.traceId}</code>
{:else}
<span class="muted">—</span>
{/if}
</td>
<td>
{#if entry.tags?.length}
{#each entry.tags as t (t)}
<span class="tag">{t}</span>
{/each}
{:else}
<span class="muted">—</span>
{/if}
</td>
<td>
{#if entry.deniedFor?.length}
{#each entry.deniedFor as d (d)}
<span class="denied">{d}</span>
{/each}
{:else}
<span class="muted">—</span>
{/if}
</td>
<td>
{#if entry.source}
<code class="source" title={entry.source.file}
>{fileShort(entry.source.file)}:{entry.source.line}</code
>
{:else}
<span class="muted">—</span>
{/if}
</td>
<td>
{#if entry.durationMs !== undefined}
<code>{entry.durationMs.toFixed(1)}ms</code>
{:else}
<span class="muted">—</span>
{/if}
</td>
<td>
{#if entry.context}
<code class="ctx">{JSON.stringify(entry.context)}</code>
{:else}
<span class="muted">—</span>
{/if}
</td>
<td>
{#if entry.error}
<details>
<summary><strong>{entry.error.name}</strong>: {entry.error.message}</summary>
{#if entry.error.stack}
<pre class="stack">{entry.error.stack}</pre>
{/if}
</details>
{:else}
<span class="muted">—</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
</main>
<style>
main {
max-width: 1280px;
margin: 2rem auto;
padding: 0 1.5rem;
font-family:
system-ui,
-apple-system,
sans-serif;
line-height: 1.5;
color: #222;
}
h1 {
margin: 0 0 0.5rem;
}
h2 {
border-bottom: 1px solid #eee;
padding-bottom: 0.3rem;
margin-top: 2rem;
font-size: 1.15rem;
}
h3 {
margin: 0 0 0.5rem;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #586069;
}
.subtitle {
color: #666;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
background: #f6f8fa;
padding: 1rem;
border-radius: 8px;
margin: 1.5rem 0;
}
.group {
display: flex;
flex-direction: column;
}
.btn-row {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.btn-row button {
padding: 0.4rem 0.8rem;
border-radius: 5px;
border: 1px solid #d1d5da;
background: #fafbfc;
cursor: pointer;
font-size: 0.85rem;
font-family: inherit;
}
.btn-row button.active {
background: #0366d6;
color: #fff;
border-color: #0366d6;
}
.hint {
font-size: 0.8rem;
color: #666;
margin: 0.5rem 0 0;
}
.hint-inline {
font-size: 0.8rem;
color: #666;
margin-left: 0.5rem;
}
.transports {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.transport {
background: #f0fff4;
border: 1px solid #bef5cb;
padding: 0.4rem 0.7rem;
border-radius: 6px;
font-size: 0.85rem;
}
.badge {
display: inline-block;
background: #e1ecf4;
color: #39739d;
padding: 0.05em 0.4em;
margin-left: 0.4em;
border-radius: 3px;
font-size: 0.72rem;
}
.attach-title {
margin-top: 1rem;
}
.attached {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-top: 0.5rem;
}
.attached-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
}
.remove {
padding: 0.2rem 0.5rem;
border-radius: 3px;
border: 1px solid #d73a49;
background: #fff;
color: #d73a49;
cursor: pointer;
font-size: 0.75rem;
}
.sentry {
background: #fff5f7;
border: 1px solid #f0b3c0;
padding: 1rem 1.25rem;
border-radius: 8px;
margin: 1.5rem 0;
}
.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;
}
.sentry-form label {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.sentry-form label span {
font-weight: 600;
min-width: 3rem;
}
.sentry-form input {
flex: 1;
padding: 0.5rem 0.7rem;
border: 1px solid #d1d5da;
border-radius: 4px;
font-family: 'SF Mono', 'Menlo', monospace;
font-size: 0.85rem;
}
.sentry-form button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.sentry-form button.primary {
background: #6f42c1;
color: #fff;
border-color: #553098;
}
.sentry-form button.remove-btn {
background: #fff;
color: #d73a49;
border-color: #d73a49;
}
.error-msg {
background: #ffeef0;
color: #86181d;
padding: 0.5rem;
border-radius: 4px;
margin-top: 0.5rem;
font-size: 0.85rem;
}
.hint.success {
color: #22863a;
}
.stats {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.stat {
background: #fff;
border: 2px solid #e1e4e8;
border-radius: 8px;
padding: 0.75rem 1.2rem;
display: flex;
flex-direction: column;
align-items: center;
min-width: 80px;
}
.stat strong {
font-size: 1.5rem;
}
.stat span {
font-size: 0.7rem;
color: #666;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.empty {
color: #666;
font-style: italic;
}
.filter {
display: flex;
align-items: center;
gap: 0.5rem;
}
.filter input {
padding: 0.3rem 0.5rem;
border: 1px solid #d1d5da;
border-radius: 4px;
font-family: 'SF Mono', 'Menlo', monospace;
font-size: 0.85rem;
}
.filter button {
padding: 0.3rem 0.6rem;
border-radius: 4px;
border: 1px solid #d1d5da;
background: #fafbfc;
cursor: pointer;
font-size: 0.8rem;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
th,
td {
padding: 0.4rem 0.5rem;
text-align: left;
border-bottom: 1px solid #eee;
vertical-align: top;
}
th {
background: #f6f8fa;
font-weight: 600;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #586069;
white-space: nowrap;
}
tr.failure {
background: #fff5f5;
}
.level {
display: inline-block;
padding: 0.1em 0.5em;
border-radius: 3px;
color: #fff;
font-size: 0.7rem;
font-weight: 600;
}
.muted {
color: #bbb;
}
.tag {
display: inline-block;
background: #e1ecf4;
color: #39739d;
padding: 0.05em 0.4em;
margin: 0 0.15em 0.15em 0;
border-radius: 3px;
font-size: 0.72rem;
}
.denied {
display: inline-block;
background: #ffeef0;
color: #86181d;
padding: 0.05em 0.4em;
margin: 0 0.15em 0.15em 0;
border-radius: 3px;
font-size: 0.72rem;
font-weight: 600;
}
.trace {
color: #6f42c1;
background: #f5f0ff;
}
.source {
color: #22863a;
background: #f0fff4;
font-size: 0.75rem;
}
.ctx {
font-size: 0.72rem;
max-width: 260px;
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
details summary {
cursor: pointer;
font-size: 0.82rem;
}
.stack {
font-size: 0.7rem;
background: #fff5f5;
color: #86181d;
padding: 0.4rem;
margin-top: 0.3rem;
border-radius: 3px;
overflow-x: auto;
max-width: 300px;
}
code {
background: #f4f4f4;
padding: 0.05em 0.3em;
border-radius: 3px;
font-size: 0.78rem;
}
</style>

Powered by TurnKey Linux.