Coverage is checked by a rule engine, not a bag of heuristics. Each rule has a stable id, a documented weight, a docs link, and a set of entry point kinds it applies to — and every finding carries the file and line it came from, so a verdict is always something you can go and look at.
Rules come in two categories, and the difference matters:
| Requirements | Opportunities | |
|---|---|---|
| Effect on the score | Costs points when it fails | None, ever |
| When it appears | Whenever it applies | Only when the project already uses the feature |
| In the report | FIX FIRST and THEN | GOING FURTHER |
| In the JSON | checks | suggestions |
| Can fail a CI gate | Yes | No |
Three statuses
Every rule returns one of three verdicts for an entry point.
| Status | Meaning |
|---|---|
pass | The rule looked and found what it wanted |
fail | The rule looked and did not — costs weight, if it is a requirement |
n/a | The question does not make sense here, or you disabled it |
n/a is doing real work. A handler that throws nothing is never asked whether its errors carry why and fix; a handler with no catch is never asked whether its catches log. Those used to pass for free, which made the report claim a file handled errors it did not have. Now they are dots in the matrix, and the score is calculated over the rules that actually applied.
Requirements
Six rules move the score. They apply to handler-like entry points — API handlers, middleware, scheduled jobs, and server actions — except fetch, which is the pages-only rule.
| Column | Id | Weight | Expects |
|---|---|---|---|
log | wide-event | 40 | useLogger() |
audit | audit | 25 | log.audit() |
err | structured-errors | 20 | createError({ why, fix }) |
fetch | page-error-handling | 20 | fetch error handling |
ctx | context | 15 | log.set() |
catch | error-handling | 15 | logging or rethrow in catch |
log — does this entry point emit a wide event?
The heaviest rule, because everything else depends on it. An entry point with no logger produces no event, and no amount of good error handling will tell you what happened inside it.
Satisfied by a resolved evlog logger (useLogger, createLogger, createRequestLogger, initLogger) or an evlog wrapper (withEvlog, withAudit).
The message depends on your framework. With evlog's Nitro plugin an event is emitted for every request whether the handler asks or not, so the failure there is not silence, it is emptiness:
handler adds nothing to its request event — only method, path and status are recorded
no useLogger() — handler is a dark event
export default defineEventHandler(async (event) => {
const log = useLogger(event)
})
export async function POST(request: Request) {
const log = useLogger()
}
ctx — is request context attached?
A logger with no log.set() produces a technically valid event that says nothing about the request it describes. That is the most common way a wide event ends up useless in production: you get one line per request, and none of them can answer a question.
Only set() on a resolved evlog logger counts. An unrelated Map.set() does not.
log.set({ user: { id }, order: { id, total } })
err — do thrown errors carry why and fix?
throw new Error('failed') reaches the client as a string with no cause and no remedy. createError({ why, fix }) is what makes an error actionable for whoever reads it at 3am.
Applies only when the handler raises something — a throw or a createError() call. The message names exactly what is missing:
throw new Error() — use createError({ why, fix })
createError() missing why and fix
createError() has why but missing fix
A createError() that is returned rather than thrown is checked too, since it still shapes the response.
throw createError({
status: 400,
message: 'what the caller sees',
why: 'what actually went wrong',
fix: 'what to do about it',
})
audit — does this sensitive entry point leave a trail?
Applies only where the sensitivity classifier found money or auth. Everywhere else an audit record would be noise, so the rule reports n/a rather than passing for free.
log.audit() satisfies it, including through optional chaining — log.audit?.deny() counts.
The suggested action is read off the route, so /api/auth/login suggests auth.login and /api/orders/[id]/refund suggests orders.refund:
log.audit({
action: 'auth.login',
actor: { type: 'user', id: user.id },
})
catch — is every caught error logged or rethrown?
A swallowed error is worse than an unhandled one: the request looks successful, the event says nothing, and the failure is invisible until a user reports it.
A catch counts as handled when it logs (log.error, log.warn, log.set, log.audit, captureException, or any console.*), rethrows, or returns. Two failures are reported separately:
empty catch block swallows errors
catch block swallows error without logging or rethrow
A handler with no catch at all is n/a, not a gap. evlog's framework integrations hook the runtime's error channel, so an exception that escapes your handler is still recorded on the event with its status.
catch (error) {
log.error(error)
}
fetch — does this page survive its data fetch failing?
The only pages rule. Applies only to pages that actually fetch something server-side — a purely presentational page has nothing to fail.
Satisfied by a catch, a .catch(), an onError handler, or an error binding destructured from the fetch itself.
<script setup lang="ts">
const { data, error } = await useFetch('/api/orders')
if (error.value) log.error(error.value)
</script>
try {
const orders = await getOrders()
} catch (error) {
log.error(error)
}
Opportunities
Four rules suggest going further. They never touch the score, and they only ever fire when the project has already adopted the feature somewhere — the point is to get more out of what you chose, not to sell you something.
Adoption is confirmed against the AST, not by searching text, so a feature mentioned in a comment does not count.
| Column | Id | Fires when | Scope |
|---|---|---|---|
catalog | error-catalog | The project declares a catalog and the same inline error appears in two or more files | Per entry point |
audit+ | audit-coverage | The project records audit events, and this handler changes state without one | Per entry point |
ai | ai-logging | ai is a dependency and the AI SDK is called without evlog/ai | Once per project |
identity | auth-identity | better-auth is a dependency and evlog/better-auth is not installed | Once per project |
catalog — should these duplicated errors become catalog entries?
Deliberately narrow. An earlier version fired on any inline createError(), which meant handlers with perfectly good errors got lectured. Duplication is the one signal that makes the case on its own: the same status and message maintained in three places will drift.
"402 Card declined" is spelled out here and in 2 other files — one catalog entry would cover them
The suggestion names a catalog you already have, rather than inventing one. Catalogs →
audit+ — should this state change be on the audit trail too?
Complements the audit requirement, which only fires on money and auth. This one is softer and wider: once you have an audit trail, every state change is a candidate, and you are the one who knows which ones matter. It looks for create, update, insert, upsert, delete, or destroy calls in a handler with no audit record. Entry points already covered by the audit requirement are skipped, so a gap is never reported twice. Recording audit events →
ai — are model calls, tokens and latency on the event?
Fires on generateText, streamText, generateObject, streamObject, embed, and embedMany when evlog/ai is not imported. Without it the event records that the request happened but not what the model cost, which is usually the most expensive and most variable part of it. Reported once for the whole project — one wrapped model serves every handler. AI SDK →
identity — do events carry the authenticated user?
Fires on entry points where auth is actually in play: the auth routes themselves, or a handler that reads the session. evlog/better-auth attaches the user and session to every event, which is what turns "a request failed" into "this user's request failed". Reported once for the whole project — it is one plugin, installed once. Better Auth →
How evlog helpers are recognised
Every rule that looks for a logger asks the same question: is this evlog's? It is answered from the AST, which is why a locally defined stub does not earn you points.
- Imported from evlog —
import { useLogger } from 'evlog', including subpath imports. - Auto-imported — in Nuxt and Nitro, evlog's module injects
useLoggerandcreateEvlogError, so an un-imported call counts. Unless the file declares its own, in which case the local declaration wins. - Re-exported from a local module —
import { useLogger } from '@/lib/evlog'counts when that module forwards evlog's export, which is the shape the Next.js guide recommends. Exports destructured from a factory (export const { useLogger } = createEvlog(...)) are resolved too. - Through an evlog wrapper —
withEvlogandwithAuditinstrument a handler without it ever naming a logger.
Two things deliberately do not count:
- A local stub.
function useLogger() { ... }in your own file, or an import from./my-loggerthat does not forward evlog's export, is not evlog's logger. - evlog's
logexport.log.info()is the simple logging API — it emits its own line rather than contributing to the request's wide event, and it has nosetoraudit. It does not satisfy thelogcolumn.
Disabling a check
A static analyser you cannot argue with is one you stop running. When a rule is wrong about your code — or right about it and you have decided not to care — turn it off with a comment, next to the code it is about:
// evlog-map-disable-next-line wide-event, context -- liveness probe, deliberately silent
export default defineEventHandler(() => ({ ok: true }))
Three forms, matching what you would expect from a linter:
| Directive | Covers |
|---|---|
// evlog-map-disable-next-line <ids> | The line below the comment |
// evlog-map-disable-line <ids> | The line the comment is on, as a trailing comment |
// evlog-map-disable <ids> | The whole file, wherever it appears |
The ids are the ones in the tables above — wide-event, audit, structured-errors, context, error-handling, page-error-handling, and any opportunity id. Separate several with commas or spaces. Name no id and the directive covers every rule, which is the right shape for a generated or vendored file and the wrong one for a handler you simply have not got to yet. Block comments work the same way, which is what you want at the top of a file: /* evlog-map-disable -- generated by the SDK */.
Everything after -- is your reason. It is optional, and worth writing anyway: it is what the report shows, so the next person reads the decision rather than guessing at it.
What a disabled check does to the score
It becomes n/a with your reason attached — the same status as a rule that never applied — so it costs no points, and a --min-score gate stops failing on it.
The rule still runs. What a directive waives is the finding, not the question: a check that would have passed is still reported as a pass, and a rule that never applied to that handler stays a plain n/a. So a file-wide directive on an already-instrumented handler disables nothing, and the number of disabled checks is the number of verdicts you actually chose not to see.
Which means a disabled check has to stay visible, or the escape hatch would quietly become a way to score 100 on an app that logs nothing. It shows up in three places:
○ 2 checks disabled by comment in 1 entry point
CHECKS
✓ log.set context attached with log.set()
○ useLogger disabled at line 1 — liveness probe, deliberately silent
In --all, a disabled cell is ○ rather than the · of a rule that did not apply. And in the JSON, the check carries "suppressed": true next to its n/a, with evidence pointing at the comment — so a CI job can report how much of a green score is suppressed, and summary.suppressedChecks gives the project total.
A typo is loud
An id no rule answers to does not silently do nothing — the scan warns above the report, and the check keeps failing:
⚠ server/api/probe.get.ts:1 disables "wide-evnt", which is not a check evlog map runs
Believing a check is off while it is still failing is worse than either state on its own, so this one is never quiet.
Exempt entry points
evlog's own client-log ingest endpoints (/api/evlog/ingest and friends) are plumbing, not app code. Every rule reports n/a for them with the reason attached, they are never listed as something to fix, and the summary counts them as exempt rather than as your own instrumented entry points.
Reporting a wrong verdict
Rules are one file each with their own test bench, so a wrong verdict is a local fix rather than an archaeology session. If evlog map <file> claims something you can see is untrue, that is a bug worth opening an issue for — include the entry point's output and the handler, and the fix lands in one rule.
In the meantime, disable the check on that line. A false positive should cost you one comment, not your CI gate.
Next
map
A static observability score for your app, Lighthouse-style. Scans every entry point in a Nuxt, Nitro, Next.js, or TanStack Start project, scores wide-event coverage, and names the entry points to fix first.
Scoring
How evlog map turns rule results into a score — per-entry weights, the weighted project average, grade thresholds, coverage classification, and how routes are flagged as money, auth, or PII.