Logging
Structured logging helps you understand your application
Encore offers built-in support for structured logging, which combines a free-form log message with type-safe key-value pairs. Structured fields can be searched, filtered, grouped, and aggregated without parsing the message text.
Logging is integrated with distributed tracing. A log message emitted while handling a request is recorded in that request's trace, alongside the API calls, database queries, and Pub/Sub operations that Encore captures on its own. Application logs add business context that Encore cannot infer from these operations.
Usage
Import the logger and call one of error, warn, info, debug, or trace:
import log from "encore.dev/log";
log.info("order created", {
orderId: "order_123",
customerId: "customer_456",
total: 14900,
currency: "SEK",
});
The first parameter is the message, followed by a single object of key-value pairs.
Pass variable data as fields rather than interpolating it into the message:
// Prefer
log.info("order created", { orderId, customerId });
// Avoid
log.info(`order ${orderId} created for customer ${customerId}`);
Interpolated values have to be parsed back out before they can be queried, and they make every message unique, so equivalent events can no longer be grouped or counted.
Logging errors
error and warn accept an error as their first argument:
try {
await chargeCustomer(customerId, amount);
} catch (err) {
log.error(err, "payment authorization failed", {
customerId,
amount,
currency,
});
throw err;
}
The logger records the original error's type, message, stack trace, and cause. Converting it to String(err) discards that information before it reaches the logger.
Contextual loggers
When several messages share the same fields, log.with() returns a logger that carries them into every subsequent event:
const logger = log.with({ orderId, customerId });
logger.info("order rejected", { reason: "payment_declined" });
logger.info("order cancellation requested", { requestedBy: "customer" });
Use it for context that belongs to a domain operation and that Encore does not already know, such as an import ID or a tenant. Encore already attaches the service, endpoint, trace ID, and authenticated user ID to every log line; do not repeat these fields in a contextual logger.
What to log
- A business state transition, such as a subscription activating or an order shipping.
- A decision the application made, together with the inputs that drove it.
- A fallback or retry that allowed an operation to complete.
- A failure, at the boundary that owns the recovery decision.
- An interaction with an external system that needs domain context to interpret.
- A security-relevant or administrative action.
log.info("order rejected", {
event: "order.rejected",
orderId,
reason: "insufficient_inventory",
productId,
requestedQuantity,
availableQuantity,
});
log.warn("serving cached exchange rate", {
event: "exchange_rate.fallback_used",
currencyPair: "EUR_SEK",
cacheAgeSeconds,
providerErrorCode,
});
What Encore already records
Encore traces API requests, database queries, service-to-service calls, cache operations, and Pub/Sub activity, and the local development dashboard renders them as a timeline. Logs that narrate the same execution flow duplicate the trace:
// All redundant with the trace
log.info("request started");
log.info("querying database");
log.info("calling payments service");
log.info("request completed");
Logging every successful query or function entry and exit also duplicates information already present in the trace.
Logs, metrics, and traces
- Metrics answer how often something happens. Use counters and gauges for rates, totals, and levels.
- Traces answer where the time went and what called what.
- Logs record what happened, with the identifiers needed to investigate an individual event.
Use a counter instead of emitting one info log per event when only the number of events is needed, such as for cache hits.
Log levels
error is for an operation that failed unexpectedly and could not complete: an unavailable dependency, data that violates an invariant, or a background job that has permanently failed. Expected outcomes such as a rejected login, a missing resource, failed validation, or a declined payment should not use error. Logging expected outcomes at error prevents the level from serving as a reliable alerting signal.
warn is for something unexpected that the application recovered from or continued through in a degraded state: falling back to stale data, ignoring malformed optional input, succeeding on retry, approaching a soft limit. A condition that occurs continuously during normal operation is not a warning.
info is for meaningful, relatively low-volume business events: a user-visible state transition, a scheduled job completing, a configuration change, a batch summary.
debug is for detail that helps an engineer investigate behavior and is not needed to operate the application.
trace is for high-volume internal state, such as each iteration of a matching loop.
The minimum level is set by log_level in encore.app and applies to the whole application. Without this setting, every level is emitted, including trace. A trace call in frequently executed code is therefore emitted in production unless the level is configured.
Message and field conventions
Use the same message for equivalent events and preserve the variable data in fields.
For event families that will be queried, counted, or consumed by automation, add a machine-readable event field alongside the human-readable message:
log.info("payment authorized", {
event: "payment.authorized",
paymentId,
orderId,
amount,
currency,
});
Dashboards and alerts built on event continue to work if the message changes. Name events using <domain>.<past-tense-event>, such as order.created, payment.failed, and subscription.cancelled.
Field names should mean the same thing everywhere, and a given field should keep its type across events. amountMinorUnits: 14900 can be summed and compared; amount: "149.00 SEK" has to be parsed first, and the parser breaks on the first entry written in another currency's format. Prefer enum-like values over prose, so reason: "insufficient_inventory" instead of a sentence describing the shortage.
Encore's own fields use snake_case (trace_id, service, endpoint, uid). Application fields using camelCase do not replace them. Use one convention for application fields and one name for each concept.
Use encore.dev/log rather than console.log for application events. Encore does not intercept console, so its output skips the structured field handling, never reaches the trace, and arrives as unparsed text when logs are streamed.
Sensitive data
Encore writes log fields without redacting them.
Never log passwords or password hashes, API keys, access or refresh tokens, session identifiers, cookies, authorization headers, secret configuration values, or payment card and bank account details. Avoid logging whole request or response objects, which tend to contain several of these at once.
Prefer internal identifiers over personal values, so { userId } rather than { oldEmail, newEmail }. When a value is needed for correlation but should not be stored, log a stable non-reversible fingerprint of it instead.
Logs are durable production data. They are written to your cloud provider's log store, where access control, retention, exports, and backups govern who can read them. Fields are also recorded in the trace, so a sensitive value may be stored under two separate retention policies.
Ordinary application logs are also not an audit log. A formal audit trail usually needs explicit event schemas, defined retention, restricted access, tamper resistance, reliable delivery, and clear actor identity. For compliance-sensitive actions, write a dedicated audit event to durable storage; an application log alongside it is still useful for debugging.
Log an error once
Recording the same failure at every layer produces duplicate entries for one event:
async function loadOrder(id: string) {
try {
return await db.queryRow`...`;
} catch (err) {
log.error(err, "failed to load order");
throw err;
}
}
async function createInvoice(id: string) {
try {
const order = await loadOrder(id);
// ...
} catch (err) {
log.error(err, "failed to create invoice"); // second entry, same failure
throw err;
}
}
Log at the layer that owns the decision to retry, fall back, or abort, which is also the layer with the domain context to describe what failed. Lower layers let the error propagate:
async function loadOrder(id: string) {
return db.queryRow`...`;
}
async function createInvoice(orderId: string) {
try {
const order = await loadOrder(orderId);
// ...
} catch (err) {
log.error(err, "invoice creation failed", { orderId });
throw err;
}
}
A lower layer logs when it suppresses the error, retries, detects a broken internal invariant, or holds information the propagating error will lose. Returning structured error information to the caller is often better than logging on the spot.
Operation and batch summaries
At the end of an operation, record its outcome together with relevant counts and its duration:
log.info("account reconciliation completed", {
event: "account.reconciliation_completed",
accountId,
transactionCount,
matchedCount,
unmatchedCount,
adjustmentsCreated,
durationMs,
});
For a long-running operation, a start event distinguishes an operation that has not started from one that started but did not finish. The trace records the intermediate steps.
A batch should record a summary. Logging each item in a 50,000-item batch produces 50,000 entries:
log.info("user import completed", {
event: "user_import.completed",
importId,
totalCount,
succeededCount,
skippedCount,
failedCount,
durationMs,
});
Individual failures can still be logged when they need investigation:
log.warn("user import row rejected", { importId, rowNumber, reason: "invalid_email" });
For very large batches, aggregate the failures by reason or log a bounded sample rather than every rejected record.
Controlling log volume
Log volume affects ingestion, storage, indexing, and query costs. High-volume entries in frequently executed code make relevant entries harder to find.
Provider responses, database records, and configuration trees may contain large fields, encoded data, or secrets. Log selected metadata instead of the complete value:
// Avoid
log.debug("provider response received", { response });
// Prefer
log.debug("provider response received", {
provider: "example-payments",
statusCode: response.status,
requestId: response.headers.get("x-request-id"),
itemCount: response.data.items.length,
});
For collections, log counts and boundary identifiers instead of contents:
log.info("orders selected for reconciliation", {
orderCount: orders.length,
firstOrderId: orders[0]?.id,
lastOrderId: orders.at(-1)?.id,
});
Truncate long strings, cap arrays at a few representative entries, and record a byte length instead of a document body:
const MAX_ERROR_BODY_LENGTH = 1_000;
log.warn("provider returned an invalid response", {
provider: "example-payments",
statusCode,
responseBodyLength: responseBody.length,
responseBodyPreview: responseBody.slice(0, MAX_ERROR_BODY_LENGTH),
responseBodyTruncated: responseBody.length > MAX_ERROR_BODY_LENGTH,
});
Truncation limits volume but does not remove sensitive data. Review previews for secrets and personal data before logging them.
Debug logs may be emitted in every environment and included in configured exports. Apply the same volume limits to debug fields as to info fields.
Logs inside loops, Pub/Sub consumers, and frequently called helpers can produce most of an application's log volume. Review volume by service, level, event name, and deployment version to find these call sites.
Live-streaming logs
Stream logs from any environment directly to your terminal:
$ encore logs --env=prod