Write Logs
A customer says their order was not approved, and you have to find out why. An operation leaves
that answer behind by writing a log entry through context.logs. Nothing is declared for it:
every component has context.logs.
§Write an entry
The operation is approve of store.orders, as Refuse a Request writes it: it debits the
customer’s wallet by calling billing.wallets.debit with the order’s total and, as ref, the
order’s id, and approves the order unless the debit is refused. Two entries are added:
// components/store.orders/operations/approve.ts
import type { Context, State } from '../types/index.d.ts'
export async function transition (input: null, entry: State, context: Context) {
if (entry.status !== 'pending')
return new Error('NOT_PENDING')
const reply = await context.remote.billing.wallets.debit({
query: { id: entry.customer },
input: { amount: entry.total, ref: entry.id }
})
if (reply instanceof Error) {
context.logs.warn('Order not approved', { order: entry.id, reason: reply.code })
return reply
}
entry.status = 'approved'
context.logs.info('Order approved', { order: entry.id, total: entry.total })
return entry
} There are four methods, one per severity, with the same signature:
context.logs.debug(message: string, attributes?: object): void
context.logs.info(message: string, attributes?: object): void
context.logs.warn(message: string, attributes?: object): void
context.logs.error(message: string, attributes?: object): void They return nothing and are not awaited.
§See it
Restart the application, call the operation with the id of a pending order, and read what the
components wrote:
$ npm run restart
$ npx toa call store.orders.approve "{ query: { id: '7d8e…' } }"
$ pm2 logs app app is the process the components run in, kept by PM2, which collects its output. The process
prints one JSON line per entry. error entries go to stderr, the rest to stdout:
{
"time": "2026-01-01T12:00:00.000Z",
"severity": "INFO",
"message": "Order approved",
"attributes": { "order": "7d8e…", "total": 4200 },
"context": { "namespace": "store", "component": "orders", "operation": "approve" },
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7"
} | Field | |
|---|---|
time | ISO 8601 timestamp, to the millisecond. |
severity | TRACE, DEBUG, INFO, WARN or ERROR. |
message | The first argument. |
attributes | The second argument, when given. |
context | The component and operation that wrote the entry. Added for you. |
trace_id, span_id | The trace and span in which it was written. Added for you. |
TRACE is not a method. It is the severity of the entries written for completed spans; see Follow a Request.
§Find everything about one request
Every entry written while serving one request carries the same trace_id, in every component the
request reached. Filter the log backend by it to get the whole story of that request. This works
for every request, whether or not its trace was sampled.
Do not order entries of different components by their timestamps: the clocks are different
machines’. The trace shows the order of steps; span_id ties an entry to its step.
Entries of one process within the same millisecond have no reliable order either, and two
identical entries within the same millisecond may be stored by a backend as one.
§Log an error
An Error among the attributes is written with its message, code, stack and its cause chain, so pass the error itself instead of picking fields out of it:
try {
await deliver(message)
} catch (error) {
context.logs.error('Delivery failed', { to: input.to, error })
throw error
} An Error may also be passed as the attributes argument on its own.
§Choose what is written
The Context sets the minimum severity that is written at all. Add telemetry to it:
# context.toa.yaml
telemetry:
logs:
level: info # trace < debug < info < warn < error info is the default. An entry below the level is dropped where it is written and reaches no
destination. A level usually differs by environment:
telemetry:
logs:
level: debug
level@production: info §One component at a different level
A key under logs that is a component id overrides the level for that component, so one
component can be examined in detail while the rest stay quiet:
telemetry:
logs:
level: info
store.orders:
level: debug Changing a level takes a deployment: it is part of the Context, not of a component’s configuration.
§Send entries to a log backend
There are two destinations, independent of each other:
- the console, which writes the JSON line and is on unless turned off;
otlp, which sends entries to an OTLP/HTTP endpoint such as Loki or an OpenTelemetry Collector, and is off until an endpoint is given.
# context.toa.yaml
telemetry:
logs:
level: info
exporters:
otlp:
endpoint: http://loki:3100/otlp With this, entries go to both. To stop writing to the console:
telemetry:
logs:
exporters:
console: false
otlp:
endpoint: http://loki:3100/otlp Turn the console off where the output of every process is already collected into the same backend, or each entry arrives twice.
The level applies to both destinations. Exporters are set for the whole application; a
per-component key under logs changes the level only.
The endpoint’s other options and what happens when the backend is unavailable are in Send Telemetry to Grafana.
§What a record carries over OTLP
| OTLP | From |
|---|---|
| body | the message |
| attributes | the attributes passed to the call, plus namespace, component, operation |
severityText, severityNumber | the severity |
| trace and span ids | the trace the entry was written in |
Records are exported under one resource per process:
| Resource attribute | Value |
|---|---|
service.name | the application’s name, or exposition for the gateway |
service.namespace | the application’s name |
deployment.environment.name | the environment |
§Find out why a replica restarted
A process that dies of an exception nobody caught, a promise rejection nobody handled, or a
failure to start prints the error to stderr and exits with code 1. Before exiting it also sends
one record to the otlp exporter, so the reason is in the backend even where the console is off:
| message | Process failed |
| severity | ERROR |
reason | uncaught exception, unhandled rejection or command failed |
error | the error: its message, code and stack |
Search the backend for Process failed.
§What is not a log
Output of the toa command line has no severity and no context and reaches no exporter. The same
is true for anything an operation prints with the global console: use context.logs.
§Write good entries
Keep the message constant; put what varies into attributes. A constant message can be searched, counted and alerted on.
// no
context.logs.info(`Order ${order.id} approved`)
// yes
context.logs.info('Order approved', { order: order.id }) State what happened, briefly. A message is not an explanation or an instruction.
// no
context.logs.error('Failed to send the email, please check the mail server configuration')
// yes
context.logs.error('Failed to send the email', { reason: 'SMTP error', status: response.status }) Do not log what users supplied. Names, addresses, message texts and the like are personal data; log identifiers.
// no
context.logs.info('Customer signed in', { name: customer.name })
// never
context.logs.error('Password is incorrect', { password: input.password })
context.logs.info('Payment received', { card: input.card }) Do not log the configuration object. Log the specific values you need.
// no
context.logs.debug('Configuration', context.configuration)
// yes
context.logs.debug('Limits', { limit: context.configuration.limit }) Choose the level by who needs the entry:
| Level | Use for |
|---|---|
debug | Development and troubleshooting. Not enabled in production. |
info | The flow of the application: what was decided. |
warn | Something that may need attention. |
error | A failure that needs attention now. |
Do not log an error just because an operation returns one: a refusal is the operation working as designed. Log what somebody will want to find later.