Logging
There is one logging API on each side of the wire. On the client it is log
from @tinycld/core/lib/logger; on the server it is logging.ForPackage from
tinycld.org/core/logging. Both attach the right context for Sentry so a
report can be traced back to a package and a user. Neither is console.*,
which Biome bans in runtime code (suspicious/noConsole is an error; only
scripts, tests, and a short allowlist inside core may use it).
Client: log
import { log } from '@tinycld/core/lib/logger'
export function useImport() {
const run = async (filename: string) => {
log.debug('example.import', 'starting', { filename })
try {
await pb.collection('example').create({ filename })
} catch (err) {
log.error('example.import.create', err, { filename })
throw err
}
}
return { run }
}
Four levels, one shape:
| Call | Arguments |
|---|---|
log.debug(context, message, extra?) | dev tracing |
log.info(context, message, extra?) | routine events worth a breadcrumb |
log.warn(context, message, extra?) | something off that a person should see before it escalates |
log.error(context, error, extra?) | a caught failure — pass the error object, never a string |
context is a short, stable, dotted string Sentry groups on — pick something
specific (mail.openDraft.fetchBody, not mail.error) and never interpolate
user data into it. extra is the bag for variable detail; Sentry’s scrubbing
runs on it.
What a call does:
- Every call becomes a Sentry breadcrumb, so the trail leading up to an exception is in the report.
- Calls at or above the configured level also become Sentry events. The
level comes from
coreConfig.logLeveland defaults towarnin release builds anddebugin development (where Sentry is inert anyway). log.erroralways routes throughcaptureException, so Sentry gets the real stack rather than a stringified message.- In development, each call also prints a console line. In production it does not — there is nothing to strip.
captureException from @tinycld/core/lib/errors still exists and is an alias
for log.error; existing code using it needs no change.
Form validation errors
When a useMutation fails because PocketBase rejected a field, the right
handler is handleMutationErrorsWithForm({ setError, getValues }) from
@tinycld/core/lib/errors. It maps validation errors back onto the
react-hook-form fields and routes everything else into a root error you can
render with <FormErrorSummary />. See Forms.
Don’t combine it with log.error — a validation failure is not a bug.
Server: logging.ForPackage
import "tinycld.org/core/logging"
var log = logging.ForPackage("example")
func flush(ctx context.Context, id string) {
log.WarnContext(ctx, "refusing to flush a card from another board", "cardID", id)
}
ForPackage(slug) returns a *slog.Logger stamped with a pkg attribute, so
there is no need for hand-written "example: " message prefixes. Records fan
out to three sinks:
| Sink | Level |
|---|---|
| stderr | info and above |
the PocketBase _logs table | whatever the admin dashboard’s log level is set to |
| Sentry | warn and above |
Prefer the *Context variants (InfoContext, WarnContext, ErrorContext)
whenever a ctx is in scope: the per-request Sentry hub carries the user id,
so those calls get user attribution for free. A call without a ctx still
logs and still reaches Sentry, just unattributed. Do not add a ctx parameter
to a function solely to log.
What not to do
- Don’t
console.*in runtime code. Biome fails the check, and in production the output goes nowhere useful. - Don’t
log.errorfor control flow. Sentry events cost money and dilute the signal — fire one when something has actually gone wrong. - Don’t put unscrubbed user input in the
contextstring. Put it inextra. - Don’t swallow an error silently. If a caller has nothing actionable to do,
log.errorit and add a comment saying why the silence is deliberate — six months from now no one will remember otherwise.