How gbfm (goosebumps.fm) wires its observability. Sentry on both ends, OTel for backend traces, Pino for stdout, Effect as the wiring layer. Code is inlined (not linked) so the patterns stay readable as the repo moves.
What the join looks like in Sentry
When a user hits a 500, Sentry shows:
- The frontend error with React stack, route, user ID, breadcrumbs (clicks, navigation, console)
- The trace, started in the browser, propagated to the backend via
traceparent - The backend exception with the same trace ID, the request ID, the handler that failed
One error, three views, all linked. requestId is the manual join key, trace ID is the automatic one. See Correlated logging for the general pattern.
Stack
- Backend: Hono + Effect + Pino + Sentry (Bun) + OTel
- Frontend: React + Sentry (React) + OTel
- Correlation: Hono
requestId()middleware, Sentry scopes,traceparentheaders
Backend: middleware chain
The full chain on every request. Order matters: CORS, request ID, favicon, logging, rate limiter.
// apps/vps/src/lib/create-app.ts
app.use('*', cors(corsConfig))
app.use(requestId())
.use(serveEmojiFavicon('🪿'))
.use(effectLogger())
.use(standardRateLimiter())
app.onError((err, c) => {
runAppFork(
Effect.gen(function* () {
const sentry = yield* SentryService
yield* sentry.captureException(err, {
path: c.req.path,
method: c.req.method,
requestId: c.get('requestId')
})
})
)
return onError(err, c)
})
requestId() middleware sets the ID, c.get('requestId') retrieves it, the Sentry call carries it. Every backend error in Sentry has a requestId extra that joins to the frontend via traceparent propagation. One error, two reports, one ID.
Backend: logger (Pino + Sentry dual sink)
Every Effect.log* call ends up in BOTH Pino (stdout or file) and Sentry's structured logs:
// apps/vps/src/services/logger.service.ts
export const AppLogger = Logger.make(({ logLevel, message, cause, fiber, date }) => {
const msg = formatMessage(message)
const data = redactValue({ cause, fiberId: fiber.id, date })
const payload = { ...(isRecord(data) ? data : {}), logLevel }
pinoInstance[pinoLevel(logLevel)](payload, msg)
if (!Sentry.getClient()) return
const sentryLogger = Sentry.logger
switch (logLevel) {
case 'Trace':
case 'Debug': sentryLogger.debug(msg, payload); break
case 'Info': sentryLogger.info(msg, payload); break
case 'Warn': sentryLogger.warn(msg, payload); break
case 'Error':
case 'Fatal': sentryLogger.error(msg, payload); break
default: sentryLogger.info(msg, payload)
}
})
Two sinks, one switch on logLevel. Pino uses string levels, Sentry its own enum, Effect its own. The pinoLevel helper does the same mapping for Pino.
Pino itself has a redaction list, so secrets never reach stdout or Sentry:
const REDACT_PATHS = [
'password', 'token', 'authorization', 'cookie', 'email',
'*.password', '*.token', '*.authorization', '*.cookie', '*.email',
'req.headers.authorization', 'req.headers.cookie',
'accessToken', 'refreshToken', 'spotifyAccessToken', 'betterAuthSession'
]
const pinoInstance = pino({
level: config.app.logLevel || defaultLevel,
redact: { paths: REDACT_PATHS, censor: '[Redacted]' }
}, config.app.nodeEnv === 'production' ? undefined : pretty())
Redaction at the logger, not in the caller. Same rule as Log quality & hygiene.
Backend: OTel spans + perf monitoring
Each request runs inside an active OTel span. The span is named after the matched route, so transaction aggregates work:
// apps/vps/src/middlewares/effect-logger.ts
const tracer = trace.getTracer('gbfm.vps')
const spanName = `${c.req.method} ${c.req.path}`
const loggingEffect = Effect.gen(function* () {
yield* Effect.tryPromise({
try: () =>
tracer.startActiveSpan(spanName, async (span) => {
try {
await next()
} catch (error) {
span.recordException(error)
span.setStatus({ code: SpanStatusCode.ERROR, message: error.message })
throw error
} finally {
const routePattern = Reflect.get(c.req, 'routePath') ?? c.req.path
span.updateName(`${c.req.method} ${routePattern}`)
span.setAttribute('http.method', c.req.method)
span.setAttribute('http.route', routePattern)
span.setAttribute('http.status_code', c.res.status)
span.setAttribute('http.duration_ms', Date.now() - start)
span.end()
}
})
})
const performanceEffects = [
duration > VERY_SLOW_REQUEST_THRESHOLD
? Effect.logError('[Performance] Very slow request detected', { /* fields */ })
: duration > SLOW_REQUEST_THRESHOLD
? Effect.logWarning('[Performance] Slow request detected', { /* fields */ })
: Effect.void,
recordRequest(duration, c.res.status >= 400),
checkPerformanceHealth,
Effect.log(`[INFO] ${c.req.method} ${c.req.path} ${c.res.status} - ${duration}ms`)
]
yield* Effect.all(performanceEffects, { concurrency: 'unbounded' })
})
Two patterns:
- Route pattern, not URL.
c.req.pathfor individual requests,c.req.routePathfor aggregation./content/posts/fooand/content/posts/barboth surface as/content/posts/:slugin the trace list - Slow-request logs as parallel effects. The performance effects run via
Effect.all(..., { concurrency: 'unbounded' }), separate from the main flow. 500ms threshold logs a warning, 2s logs an error, same fields both times. Lets you alert on "very slow" without instrumenting each handler
Frontend: Sentry init
The interesting parts are tracePropagationTargets and the beforeSend filter:
// apps/www/src/main.tsx
const tracePropagationTargets = env.isDev
? [/^\//, 'http://127.0.0.1:3003', 'http://localhost:3003']
: [/^\//, 'https://goosebumps.fm', 'https://www.goosebumps.fm', 'https://vps.goosebumps.fm']
Sentry.init({
dsn: env.sentryDsn,
environment: env.sentryEnvironment ?? (env.isDev ? 'development' : 'production'),
release: env.sentryRelease,
debug: env.isDev,
enableLogs: true,
integrations: [
Sentry.browserTracingIntegration(),
Sentry.replayIntegration({ maskAllText: false, blockAllMedia: false })
],
tracesSampleRate: 1.0,
tracePropagationTargets,
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: env.isDev ? 0 : 1.0,
sendDefaultPii: false,
beforeSend: (event) => (hasLocalUrl(event) ? null : event),
beforeSendTransaction: (event) => (hasLocalUrl(event) ? null : event)
})
tracePropagationTargetslists the backend origin. Without it, Sentry doesn't attachsentry-traceandbaggageheaders to fetch calls, so the backend gets a fresh trace context and the join is lostbeforeSendandbeforeSendTransactiondrop events with local URLs in dev. Otherwise dev reloads spam Sentry with localhost transactionsreplaysOnErrorSampleRate: 1.0in prod. On error, full replay.replaysSessionSampleRate: 0(no baseline replays, those are expensive)