Logging
Structured logging helps you understand your application
Encore offers built-in support for structured logging, which combines a free-form log message with 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 encore.dev/rlog and call one of Error, Warn, Info, or Debug:
import "encore.dev/rlog"
rlog.Info("order created",
"order_id", "order_123",
"customer_id", "customer_456",
"total", 14900,
"currency", "SEK",
)
The first parameter is the message. The remaining arguments alternate between string keys and values.
Pass variable data as fields rather than interpolating it into the message:
// Prefer
rlog.Info("order created", "order_id", orderID, "customer_id", customerID)
// Avoid
rlog.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
Pass errors as field values:
if err := chargeCustomer(ctx, customerID, amount); err != nil {
rlog.Error("payment authorization failed",
"err", err,
"customer_id", customerID,
"amount", amount,
"currency", currency,
)
return err
}
Passing the original error value allows rlog to apply its error serialization. Converting it with err.Error() before calling the logger passes a plain string instead.
Contextual loggers
When several messages share the same fields, rlog.With returns a logging context that includes them in every subsequent event:
logger := rlog.With("order_id", orderID, "customer_id", customerID)
logger.Info("order rejected", "reason", "payment_declined")
logger.Info("order cancellation requested", "requested_by", "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.
rlog.Info("order rejected",
"event", "order.rejected",
"order_id", orderID,
"reason", "insufficient_inventory",
"product_id", productID,
"requested_quantity", requestedQuantity,
"available_quantity", availableQuantity,
)
rlog.Warn("serving cached exchange rate",
"event", "exchange_rate.fallback_used",
"currency_pair", "EUR_SEK",
"cache_age_seconds", cacheAgeSeconds,
"provider_error_code", 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
rlog.Info("request started")
rlog.Info("querying database")
rlog.Info("calling payments service")
rlog.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 metrics for aggregate values.
- 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, or 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, or a batch summary.
Debug is for detail that helps an engineer investigate behavior and is not needed to operate the application.
The minimum level is set by log_level in encore.app and applies to the whole application. The default minimum is trace, so all four rlog levels are emitted unless the level is configured. A Debug call in frequently executed code is therefore emitted in production by default.
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:
rlog.Info("payment authorized",
"event", "payment.authorized",
"payment_id", paymentID,
"order_id", orderID,
"amount", amount,
"currency", 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. "amount_minor_units", 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 use "reason", "insufficient_inventory" instead of a sentence describing the shortage.
Encore's own fields use snake_case (trace_id, service, endpoint, uid). Use the same convention for application fields and one name for each concept.
Field names beginning with encore_ are reserved for internal use. rlog rewrites a field such as encore_key to x_encore_key.
Use encore.dev/rlog for application events that need structured fields or trace integration. The standard library's log package and fmt output do not provide rlog fields or create log events in the active trace.
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 values, which may contain several of these at once.
Prefer internal identifiers over personal values, such as "user_id", userID instead of old and new email addresses. 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 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:
func loadOrder(ctx context.Context, id string) (*Order, error) {
order, err := queryOrder(ctx, id)
if err != nil {
rlog.Error("failed to load order", "err", err)
return nil, err
}
return order, nil
}
func createInvoice(ctx context.Context, id string) error {
_, err := loadOrder(ctx, id)
if err != nil {
rlog.Error("failed to create invoice", "err", err) // second entry, same failure
return err
}
// ...
return nil
}
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 return the error:
func loadOrder(ctx context.Context, id string) (*Order, error) {
return queryOrder(ctx, id)
}
func createInvoice(ctx context.Context, orderID string) error {
_, err := loadOrder(ctx, orderID)
if err != nil {
rlog.Error("invoice creation failed", "err", err, "order_id", orderID)
return err
}
// ...
return nil
}
A lower layer logs when it suppresses the error, retries, detects a broken internal invariant, or holds information the returned error will not contain. Returning structured error information to the caller is often better than logging at the lower layer.
Operation and batch summaries
At the end of an operation, record its outcome together with relevant counts and its duration:
rlog.Info("account reconciliation completed",
"event", "account.reconciliation_completed",
"account_id", accountID,
"transaction_count", transactionCount,
"matched_count", matchedCount,
"unmatched_count", unmatchedCount,
"adjustments_created", adjustmentsCreated,
"duration_ms", duration.Milliseconds(),
)
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:
rlog.Info("user import completed",
"event", "user_import.completed",
"import_id", importID,
"total_count", totalCount,
"succeeded_count", succeededCount,
"skipped_count", skippedCount,
"failed_count", failedCount,
"duration_ms", duration.Milliseconds(),
)
Individual failures can still be logged when they need investigation:
rlog.Warn("user import row rejected",
"import_id", importID,
"row_number", 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 values may contain large fields, encoded data, or secrets. Log selected metadata instead of the complete value:
// Avoid
rlog.Debug("provider response received", "response", response)
// Prefer
rlog.Debug("provider response received",
"provider", "example-payments",
"status_code", response.StatusCode,
"request_id", response.Header.Get("X-Request-ID"),
"item_count", len(response.Items),
)
For collections, log counts and boundary identifiers instead of contents:
rlog.Info("orders selected for reconciliation", "order_count", len(orders))
Truncate long strings, cap slices at a few representative entries, and record a byte length instead of a document body:
const maxErrorBodyLength = 1000
previewLength := min(len(responseBody), maxErrorBodyLength)
rlog.Warn("provider returned an invalid response",
"provider", "example-payments",
"status_code", statusCode,
"response_body_length", len(responseBody),
"response_body_preview", string(responseBody[:previewLength]),
"response_body_truncated", len(responseBody) > maxErrorBodyLength,
)
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 subscribers, 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.
For more information about the API, see the rlog package documentation.
Live-streaming logs
Stream logs from any environment directly to your terminal:
$ encore logs --env=prod