72,683 lines of app code and no console.log, and 42 of our 65 error lines end in the same word
I went looking for what Nakodo writes to its log, mostly because I could not remember deciding anything about it. The answer turned out to be more consistent than any decision I remember making. $ grep -rno "console\.
I went looking for what Nakodo writes to its log, mostly
because I could not remember deciding anything about it. The answer turned out
to be more consistent than any decision I remember making.
$ grep -rno "console\.[a-z]*" src --include='*.ts' --include='*.tsx' \
| sed 's/.*console/console/' | sort | uniq -c
65 console.error
8 console.warn
That is the whole list. 72,683 lines of TypeScript and TSX, 73 calls, and no
console.log, console.info, console.debug or process.stdout.write
anywhere in the app.
The repository does contain 74 console.log calls. All of them are in
scripts/, the command line tools I run by hand. A script is talking to me and
can be chatty. The app is talking to a log, where every line costs attention
later, so a line has to be worth it.
One sink
There is no logging library in package.json: no pino, no winston, and no
error reporting service. Next's onRequestError hook is not implemented. The
only thing instrumentation.ts does is wrap console.error to drop one noisy
message that the image optimiser emits for signed avatar links that have
expired.
So the sink is the platform's runtime logs, and that is it. 72 of the 73 calls
run on the server. Exactly one runs in the browser, and it is the app's error
boundary:
$ for f in $(grep -rl "console\." src); do
head -3 "$f" | grep -q '"use client"' && echo "$f";
done
src/app/(app)/error.tsx
The grammar
Pulling out the first argument of all 65 console.error calls shows a house
style nobody wrote down:
"Launch run failed"
"Outreach send failed"
"Results email failed"
"Stripe sync failed"
"Stripe webhook failed"
"Recording AI usage failed"
"Deleting Stripe customer failed"
"admin: changing a plan failed"
"inbound email: forwarding failed"
"support: a reply could not be filed"
42 of the 65 end in the word "failed". 15 carry a lowercase subsystem prefix
(admin:, cron:, inbound email:, inbound spam check:, support:,
notice <id>:). Nine are template literals, so they can name the job type, the
batch size or the rejected host.
The best ones are the three that say what happened next:
console.error(`Rate limit check failed (${rule}); letting the request through`, e);
console.error("Stripe rejected Managed Payments; opening Checkout without it", e);
console.error("Writing a fee reply failed; sending the plain one", threadId, e);
A line like that is not a complaint, it is a record of a decision. When I read
it a week later I do not have to go and work out whether the request got
through.
Two more are not failures at all, they are instructions to whoever deploys:
console.error("Rate limiting is off: set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN.");
console.error("inbound email: RESEND_WEBHOOK_SECRET is not set");
What never gets logged
The interesting half of a logging census is the failures that are deliberately
not in it. Every Server Action in the app funnels its errors through one
function:
export function failure(e: unknown, { context, fallback = GENERIC } = {}): ActionFailure {
unstable_rethrow(e);
if (e instanceof LimitError) return { ok: false, error: e.message, upgrade: true };
if (e instanceof UserError) return { ok: false, error: e.message };
if (context) console.error(context, e);
else console.error(e);
return { ok: false, error: fallback };
}
An error with a message written for a person is shown to that person and never
logged. "You have used this month's research runs" is not an incident, it is
the product working. Everything else is logged and replaced with a generic
sentence, so internals never reach the page.
Two error types get the same treatment at the call site rather than in the
funnel:
if (!(e instanceof GeocodeError)) console.error("Place search failed", e);
if (!(e instanceof CrawlError)) console.error("Research failed", campaignId, e);
A place name that does not geocode and a website that will not be read are both
the result of something a person typed. Logging them would mean the log fills
up with other people's typing.
Warnings mean something specific here
Eight console.warn calls, and they share an idea: the request was fine, and a
human might want to look at this later.
console.warn("one-click unsubscribe: unknown token");
console.warn("outreach opt-out: unknown token");
console.warn("A reply's answer quoted amounts it wasn't given", invented);
console.warn("Ended a trial at once: its card had one on another account", subscription.id);
console.warn(`Possible Managed Payments misroute: a sale to ${country} with no VAT collected, ...`);
The third one is my favourite class of warning: a guard that checks a number
written by a model against the numbers it was actually given, and says so
quietly when they disagree instead of failing a send.
The log we actually promise
None of the above is the log our users are told about. That one is a database
table with 67 named actions, it is exported with the account, and
the privacy notice describes it in two sentences:
a log of account activity such as sign-up, accepting the terms, plan changes,
exports and outreach you start, approve or stop. The log does not record IP
addresses.
Those two logs have opposite properties. The audit log is append-only, has a
fixed vocabulary, is readable by the person it is about, and is kept
deliberately. The console log is free-form, is readable by me, and has whatever
retention the platform happens to give it.
Keeping them separate is the useful part. Nothing in the audit table is there
for debugging, so no debugging need ever adds a column to it. And the thing
that writes it has its own failure line:
console.error("audit log write failed", e);
The log of the log failing, which goes to the log with no retention promise. I
do not have a better idea, but it is worth knowing it is there.
What the census says is missing
Three things, all honest gaps rather than plans.
There is no request or run identifier on any line. Two pipeline runs that
overlap write interleaved lines that cannot be told apart, which is exactly when
you want to read them. The job lines already print a job id, so some of
the ids exist, they are just not part of the format.
The bare console.error(e) branch in failure() writes a stack with no
context, and it is the branch that fires for bugs, which is the branch where
context matters most. Passing a context string is optional, which means it is
sometimes missing.
And nothing alerts. 42 lines ending in "failed" and not one of them pages
anybody, so the first signal of a real problem is
somebody using the in-app help to tell me about it before I noticed. The
service runs on a schedule rather than
waiting for clicks, which is exactly the shape of product where that is not
good enough. That is the gap I would close first.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.