CLI

Map rules

Every check evlog map runs — the six requirements that move the score, the four suggestions that never do, what satisfies each one, and how evlog helpers are recognised in your code.

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.

This list is not final. Ten rules is where the engine starts, not where it ends. Rules will be added, existing ones will get more precise about what counts, and a sharper rule can change a verdict on code you did not touch. Ids and weights are documented so the change is always explainable — but do not treat a score as comparable across CLI versions.

Rules come in two categories, and the difference matters:

RequirementsOpportunities
Effect on the scoreCosts points when it failsNone, ever
When it appearsWhenever it appliesOnly when the project already uses the feature
In the reportFIX FIRST and THENGOING FURTHER
In the JSONcheckssuggestions
Can fail a CI gateYesNo

Three statuses

Every rule returns one of three verdicts for an entry point.

StatusMeaning
passThe rule looked and found what it wanted
failThe rule looked and did not — costs weight, if it is a requirement
n/aThe 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.

ColumnIdWeightExpects
logwide-event40useLogger()
auditaudit25log.audit()
errstructured-errors20createError({ why, fix })
fetchpage-error-handling20fetch error handling
ctxcontext15log.set()
catcherror-handling15logging 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:

Nuxt / Nitro
handler adds nothing to its request event — only method, path and status are recorded
Next.js / TanStack Start
no useLogger() — handler is a dark event
export default defineEventHandler(async (event) => {
  const log = useLogger(event)
})

Wide events →

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

Wide events →

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',
})

Structured errors →

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 },
})

Audit logs →

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

Structured errors →

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>

Lifecycle →

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.

ColumnIdFires whenScope
catalogerror-catalogThe project declares a catalog and the same inline error appears in two or more filesPer entry point
audit+audit-coverageThe project records audit events, and this handler changes state without onePer entry point
aiai-loggingai is a dependency and the AI SDK is called without evlog/aiOnce per project
identityauth-identitybetter-auth is a dependency and evlog/better-auth is not installedOnce 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 evlogimport { useLogger } from 'evlog', including subpath imports.
  • Auto-imported — in Nuxt and Nitro, evlog's module injects useLogger and createEvlogError, so an un-imported call counts. Unless the file declares its own, in which case the local declaration wins.
  • Re-exported from a local moduleimport { 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 wrapperwithEvlog and withAudit instrument 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-logger that does not forward evlog's export, is not evlog's logger.
  • evlog's log export. log.info() is the simple logging API — it emits its own line rather than contributing to the request's wide event, and it has no set or audit. It does not satisfy the log column.

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:

server/api/health.get.ts
// 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:

DirectiveCovers
// 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:

evlog map
○ 2 checks disabled by comment in 1 entry point
evlog map <file>
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

  • Scoring — how weights become a number, and how sensitivity is decided
  • CI — gate a pull request on requirements without suggestions getting in the way