LibraryHeadlessReact

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-logger

The 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.

bash
# 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-tracing

createTelemetry

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.

tsx
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

PropTypeDefaultDescription
appstring--Logical app/service name attached to every record. Required.
env'development' | 'production'--Selects a behavior preset (level, batching, sampling, flush). Required.
endpointstring--Remote collector URL. When omitted, telemetry stays console-only and no network engine is constructed.
enabledbooleantrueMaster kill switch. When false a tree-shakeable noop logger is returned — zero records, no engine import, zero runtime cost.
sampleRatenumberpresetFraction of records kept, 0..1. Defaults to the preset value (1 in development, 0.25 in production).
redactKeysstring[][]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.

PropTypeDefaultDescription
min leveldev: debugprod: warndevelopment emits everything; production drops below warn.
deliverydev: syncprod: batched (size 20)development delivers immediately; production buffers and flushes in batches.
sample ratedev: 1prod: 0.25development keeps every record; production keeps a quarter by default.
console outputdev: prettyprod: structured JSONdevelopment is human-readable; production is machine-parseable.
beacon flush on exitdev: noprod: yesproduction flushes on pagehide + visibilitychange (hidden) so in-flight data is not lost.
tsx
// 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, flushed

Kill 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.

tsx
// 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 immediately

React: TelemetryProvider

Create the telemetry instance once at module scope and pass it to TelemetryProvider. Everything below gets the logger through hooks — no prop drilling.

tsx
// 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

PropTypeDefaultDescription
valueTelemetry--A telemetry instance from createTelemetry(). Create it once, outside render.
childrenReact.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.

tsx
'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.

tsx
'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.

tsx
// 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.

tsx
'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

PropTypeDefaultDescription
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

PropTypeDefaultDescription
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)

PropTypeDefaultDescription
sinksreadonly 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.