Observability for a Next.js Site in an Afternoon (And the One Default That Would Have Dropped Every Event)
This site is small. A contact form, a newsletter box, a members' area, and a free compliance Quick Check that fetches other people's pages. None of that justifies Datadog. All of it can break quietly on a Saturday, and until this week I would have found out from an email three days later, if at all.
So I spent an afternoon wiring in Tiny Owl, the observability tool from Net Partner 011, the studio I build JustBeforeGo with. This is the write-up: what I log, how the wrapper is shaped so logging can never hurt the site, and the one SDK default I'm glad I checked before deploying.
What "observability" means for a site this size
Not traces. Not dashboards with forty panels. I wanted answers to four questions:
- Did the contact form actually send, or did SMTP fail silently?
- Is anyone using the Quick Check, and what scores are they getting?
- Is someone hammering an endpoint or probing it with private addresses?
- Did a server route throw something I didn't catch?
Each of those is one event with a little context. That's the whole requirement.
The wrapper
Everything goes through one server-only module. The rules it enforces are more important than the SDK behind it:
- No-op without keys. If
TINYOWL_API_KEYandTINYOWL_PROJECT_SECRETare unset, calls do nothing (and print to the console in development). Local dev, preview deployments, and the build all work without the service. - Lazy, single client. The SDK is imported on first use, once per runtime.
- Never throws, never blocks. A logging failure is swallowed and surfaced once on the console. Call sites fire and forget with
void logEvent(...). The thing you observe must never go down because the observer did. - Small, boring context. Ids, hosts, counts, error codes. Never emails, message bodies, tokens or request bodies. If an event would be embarrassing in a breach report, don't send it.
// lib/observability.ts
import type { TinyOwl } from '@tiny-owl-kit/observability';
const API_KEY = process.env.TINYOWL_API_KEY ?? '';
const PROJECT_SECRET = process.env.TINYOWL_PROJECT_SECRET ?? '';
const BASE_URL = process.env.TINYOWL_BASE_URL || 'https://be.tiny-owl-kit.io/api';
let clientPromise: Promise<TinyOwl | null> | null = null;
async function getClient() {
if (!API_KEY || !PROJECT_SECRET) return null;
clientPromise ??= import('@tiny-owl-kit/observability').then(
(m) =>
new m.TinyOwl({
apiKey: API_KEY,
projectSecret: PROJECT_SECRET,
baseUrl: BASE_URL,
timeout: 3000,
defaultContext: { service: 'belikenikola.com' },
}),
() => null
);
return clientPromise;
}
export async function logEvent(
message: string,
opts: { severity?: 'info' | 'warning' | 'error'; context?: Record<string, unknown> } = {}
) {
const client = await getClient();
if (!client) return;
try {
await client.log(message, { severity: opts.severity ?? 'info', context: opts.context });
} catch {
/* logged once elsewhere; never rethrow */
}
}
Sixty lines with the error helper. The SDK's own API is exactly what it looks like: log(message, { severity, context }), with every request HMAC-signed and timestamped for you.
The default that would have dropped everything
Here is the part worth the price of admission. The SDK's compiled default for baseUrl is:
http://localhost:5001/api
That is the right default for someone running the backend locally. It is the wrong default for a Vercel function, where localhost:5001 is nothing, the request fails, the SDK swallows it, and every production event vanishes without a trace. You would see a green deploy and an empty dashboard and blame yourself.
I only caught it because I read the type definitions before trusting the README. The hosted endpoint is in the README's configuration example; I set it explicitly and made it an env override. If you take one thing from this post: set baseUrl explicitly, and send one test event from your laptop with the real keys before you ship.
The test is one line:
node --input-type=module --env-file=.env.local -e "const {TinyOwl}=await import('@tiny-owl-kit/observability');const c=new TinyOwl({apiKey:process.env.TINYOWL_API_KEY,projectSecret:process.env.TINYOWL_PROJECT_SECRET,baseUrl:'https://be.tiny-owl-kit.io/api'});console.log(await c.log('hello from my laptop',{severity:'info'}))"
A good answer looks like success: true with an eventId and hmacVerified: true.
Where the events go in
Route handlers that already catch their own errors get explicit calls at the moments that matter:
// a contact form route
void logEvent('Contact form sent', { context: { subjectLength: subject.length } });
// ...
} catch (error) {
void logError('Contact form failed', error);
}
// the Quick Check route
void logEvent('Quick Check run', {
context: { actor: userId ? 'user' : 'anon', host, score, findings: result.findings.length },
});
void logEvent('Quick Check rate limited', { severity: 'warning', context: { actor: 'anon' } });
Everything I didn't catch goes through Next.js's onRequestError hook in instrumentation.ts, which fires for unhandled errors in server components, route handlers and the proxy:
import type { Instrumentation } from 'next';
export const onRequestError: Instrumentation.onRequestError = async (err, request, context) => {
const { logError } = await import('@/lib/observability');
await logError('Unhandled server error', err, {
path: request.path,
method: request.method,
routeType: context.routeType,
});
};
That is the net under everything else, and it cost six lines.
What I deliberately did not do
- No client-side logging. The keys stay on the server. If I want browser events later, they go through my own API route.
- No request bodies. Tiny Owl's Next.js adapter can auto-capture method, route and status, and nothing else, which is the right default. I went one step further and wrote the context by hand per event.
- No alerting on info. Alerts are for
error, and for the onewarningI care about: Quick Check requests aimed at private network addresses, which means someone is probing.
Was it worth an afternoon?
The first real event arrived from production about four minutes after the deploy: a Quick Check run from an anonymous visitor, score 62, host example.com, because I was testing. The second was a blocked-target warning, because I was testing that too. Both landed with service: belikenikola.com and env: production in the context, exactly where I'd look for them at 02:14.
For a site this size that is the whole job: know when it breaks, know what people use, don't pay an enterprise bill for it. Tiny Owl does that. It is a Net Partner 011 product and we promote each other's tools, so the links on this site are tagged; the code above is the honest part.



