Logger / Telemetry
A headless, vendor-neutral telemetry core that makes logging trivial for complex async flows — AI interviews, live meetings, streaming pipelines. Internally Faro-backed, but Faro is fully hidden: your only wiring is an optional collector endpoint. Mirrors the @refraction-ui/ai provider pattern.
Installation
pnpm add @refraction-ui/react-loggerThe React adapter pulls in the @refraction-ui/logger core. The Grafana Faro packages are optional peer dependencies — install them only when you configure an endpoint; without them the logger silently stays console-only.
# core (always)
pnpm add @refraction-ui/logger
# React adapter
pnpm add @refraction-ui/react-logger
# optional — only needed when you set an `endpoint`
pnpm add @grafana/faro-web-sdk @grafana/faro-web-tracingcreateTelemetry
The entire core public surface is a single factory. It returns a Telemetry — which is a logger bound to an empty root context, plus sink management.
import { createTelemetry } from '@refraction-ui/logger'
// Create ONCE, outside React render.
export const telemetry = createTelemetry({
app: 'interview-service',
env: process.env.NODE_ENV === 'production' ? 'production' : 'development',
endpoint: process.env.NEXT_PUBLIC_TELEMETRY_ENDPOINT, // optional
sampleRate: 0.25, // optional
redactKeys: ['password', 'token', 'authorization'], // optional
enabled: true, // optional (default true)
})
telemetry.warn('rate limited', { route: '/v1/answer' })
// Child loggers carry bound context onto every downstream record.
const session = telemetry.child({ sessionId: 's-123' })
const turn = session.child({ turnId: 't-7' })
const span = turn.startSpan('llm-call', { model: 'gpt' })
try {
// ... async work ...
span.end()
} catch (err) {
span.end({ error: err })
}
await telemetry.flush()Config
| Prop | Type | Default | Description |
|---|---|---|---|
app | string | -- | Logical app/service name attached to every record. Required. |
env | 'development' | 'production' | -- | Selects a behavior preset (level, batching, sampling, flush). Required. |
endpoint | string | -- | Remote collector URL. When omitted, telemetry stays console-only and no network engine is constructed. |
enabled | boolean | true | Master kill switch. When false a tree-shakeable noop logger is returned — zero records, no engine import, zero runtime cost. |
sampleRate | number | preset | Fraction of records kept, 0..1. Defaults to the preset value (1 in development, 0.25 in production). |
redactKeys | string[] | [] | Context keys to strip (deep, case-insensitive) before a record is emitted. Use for PII / secrets. |
Development vs Production
The env field selects a behavior preset. Call sites never change — only the runtime behavior does.
| Prop | Type | Default | Description |
|---|---|---|---|
min level | dev: debug | prod: warn | development emits everything; production drops below warn. |
delivery | dev: sync | prod: batched (size 20) | development delivers immediately; production buffers and flushes in batches. |
sample rate | dev: 1 | prod: 0.25 | development keeps every record; production keeps a quarter by default. |
console output | dev: pretty | prod: structured JSON | development is human-readable; production is machine-parseable. |
beacon flush on exit | dev: no | prod: yes | production flushes on pagehide + visibilitychange (hidden) so in-flight data is not lost. |
// Zero-config dev: no endpoint -> console-only, pretty, level=debug, sync.
const dev = createTelemetry({ app: 'studio', env: 'development' })
dev.debug('cache miss', { key: 'user:42' }) // printed immediately, pretty
// Production: batched + sampled + level>=warn + structured JSON +
// beacon flush on pagehide / visibilitychange. Same call sites,
// different runtime behavior — no code changes needed.
const prod = createTelemetry({
app: 'studio',
env: 'production',
endpoint: 'https://collector.example/ingest',
})
prod.debug('cache miss') // dropped (below warn)
prod.error('checkout failed', { orderId: 'o-9' }) // buffered, sampled, flushedKill Switch
enabled: false returns a tree-shakeable noop logger: zero emissions, no engine import, zero runtime cost. Wire it to a build-time flag or environment variable to turn telemetry fully off without touching call sites.
// enabled: false returns a tree-shakeable noop logger. Every method is a
// no-op, no engine is ever imported, and bundlers can drop the call sites.
// Zero runtime cost — the ideal "logging off in this build" switch.
const telemetry = createTelemetry({
app: 'interview-service',
env: 'production',
enabled: process.env.NEXT_PUBLIC_TELEMETRY !== 'off', // flip per build/env
})
// When disabled these compile and run, but emit nothing and cost nothing:
telemetry.info('app booted')
telemetry.child({ sessionId: 's-1' }).warn('slow turn')
telemetry.startSpan('noop-span').end()
await telemetry.flush() // resolves immediatelyReact: TelemetryProvider
Create the telemetry instance once at module scope and pass it to TelemetryProvider. Everything below gets the logger through hooks — no prop drilling.
// app/providers.tsx
'use client'
import { TelemetryProvider } from '@refraction-ui/react-logger'
import { createTelemetry } from '@refraction-ui/logger'
// Created once at module scope — never re-created on render.
const telemetry = createTelemetry({
app: 'interview-app',
env: process.env.NODE_ENV === 'production' ? 'production' : 'development',
endpoint: process.env.NEXT_PUBLIC_TELEMETRY_ENDPOINT,
})
export function Providers({ children }: { children: React.ReactNode }) {
return <TelemetryProvider value={telemetry}>{children}</TelemetryProvider>
}TelemetryProvider Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | Telemetry | -- | A telemetry instance from createTelemetry(). Create it once, outside render. |
children | React.ReactNode | -- | Subtree that gains access to the logger via useLogger / useSpan. |
React: useLogger(scope)
useLogger(scope) returns a child logger bound to { scope }, stable across renders. Use one scope per component or feature.
'use client'
import { useLogger } from '@refraction-ui/react-logger'
export function AnswerButton({ questionId }: { questionId: string }) {
// Scope string -> bound context { scope }. Stable across renders.
const log = useLogger('answer-button')
return (
<button
onClick={() => {
log.info('answer submitted', { questionId })
}}
>
Submit answer
</button>
)
}React: useSpan()
useSpan() returns a stablestartSpan bound to the provider's logger — ideal for timing one-off async actions inside event handlers.
'use client'
import { useSpan } from '@refraction-ui/react-logger'
export function TranscribeControl({ clipId }: { clipId: string }) {
// useSpan() returns a stable startSpan bound to the provider's logger.
const startSpan = useSpan()
async function transcribe() {
const span = startSpan('transcribe-clip', { clipId })
try {
await fetch('/api/transcribe', { method: 'POST' })
span.end()
} catch (err) {
span.end({ error: err })
}
}
return <button onClick={transcribe}>Transcribe</button>
}React: TelemetryErrorBoundary
A React error boundary that logs any thrown render error at error level (with the component stack) through the nearest provider, then renders a fallback.
// app/providers.tsx
'use client'
import {
TelemetryProvider,
TelemetryErrorBoundary,
} from '@refraction-ui/react-logger'
import { createTelemetry } from '@refraction-ui/logger'
const telemetry = createTelemetry({ app: 'interview-app', env: 'production' })
export function Providers({ children }: { children: React.ReactNode }) {
return (
<TelemetryProvider value={telemetry}>
{/* Any thrown render error is logged at error level with the
component stack, then the fallback is shown. */}
<TelemetryErrorBoundary
fallback={<p>Something went wrong. The team has been notified.</p>}
>
{children}
</TelemetryErrorBoundary>
</TelemetryProvider>
)
}AI Interview / Live Meeting (async spans)
The reason this library exists: long-lived sessions with many nested, overlapping async spans. Child loggers thread sessionId / interviewId / turnId onto every record and span, so an entire interview — speech-to-text, the LLM answer, text-to-speech — is one query in your collector. Unmount mid-turn closes the session span and flushes.
'use client'
import { useEffect } from 'react'
import { useLogger } from '@refraction-ui/react-logger'
/**
* AI interview / live meeting: a long-lived session with many nested,
* overlapping async spans. Child loggers thread sessionId / interviewId /
* turnId onto every record and span so a whole interview is one query.
*/
export function useInterviewTelemetry(interviewId: string) {
const log = useLogger('ai-interview')
useEffect(() => {
// One logger per interview; turn loggers branch off it.
const interview = log.child({ interviewId })
const sessionSpan = interview.startSpan('interview-session')
interview.info('interview started')
let cancelled = false
async function runTurn(turnId: string, prompt: string) {
const turn = interview.child({ turnId })
// Outer span covers the whole turn; inner spans cover each phase.
const turnSpan = turn.startSpan('turn')
try {
const sttSpan = turn.startSpan('speech-to-text')
const transcript = await transcribe(prompt)
sttSpan.end({ attributes: { chars: transcript.length } })
const llmSpan = turn.startSpan('llm-answer', { model: 'gpt' })
const answer = await askModel(transcript)
llmSpan.end()
const ttsSpan = turn.startSpan('text-to-speech')
await speak(answer)
ttsSpan.end()
turn.info('turn complete')
turnSpan.end()
} catch (err) {
turn.error('turn failed', { phase: 'unknown' })
turnSpan.end({ error: err })
}
}
void (async () => {
await runTurn('t-1', 'Tell me about yourself.')
if (!cancelled) await runTurn('t-2', 'Why this role?')
sessionSpan.end()
interview.info('interview finished')
// Force-deliver everything (matters under the production preset).
await interview.flush()
})()
return () => {
cancelled = true
// Page/route exit while a turn is mid-flight: close the session span
// with the reason and flush before unmount.
sessionSpan.end({ attributes: { endedBy: 'unmount' } })
void interview.flush()
}
}, [interviewId, log])
}
// --- stand-ins for your real async pipeline ---
declare function transcribe(input: string): Promise<string>
declare function askModel(transcript: string): Promise<string>
declare function speak(text: string): Promise<void>Logger
| Prop | Type | Default | Description |
|---|---|---|---|
debug / info / warn / error / fatal | (message: string, context?: LogContext) => void | -- | Emit a structured record at that level. Dropped if below the preset min level or filtered by the sample rate. |
child | (context: LogContext) => Logger | -- | Derive a logger with merged bound context (e.g. sessionId, interviewId, turnId). The parent is unaffected. |
startSpan | (name: string, attributes?: LogContext) => Span | -- | Begin a timed span for an async flow. Call span.end() to record its duration. |
flush | () => Promise<void> | -- | Deliver buffered records (batched presets) and flush every sink. Resolves once delivery is attempted. |
Span
| Prop | Type | Default | Description |
|---|---|---|---|
end | (opts?: { error?: unknown; attributes?: LogContext }) => void | -- | End the span and emit a SpanRecord. opts.error sets status to "error"; opts.attributes merges in. Idempotent — a second call is a no-op. |
Telemetry (extends Logger)
| Prop | Type | Default | Description |
|---|---|---|---|
sinks | readonly string[] | -- | Registered sink names, in insertion order. |
addSink | (sink: TelemetrySink) => void | -- | Register an additional sink (e.g. a custom collector). Same name replaces the existing sink. |
removeSink | (name: string) => void | -- | Remove a sink by name. |