V Platform Features

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
timeISO 8601 timestamp, to the millisecond.
severityTRACE, DEBUG, INFO, WARN or ERROR.
messageThe first argument.
attributesThe second argument, when given.
contextThe component and operation that wrote the entry. Added for you.
trace_id, span_idThe 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

OTLPFrom
bodythe message
attributesthe attributes passed to the call, plus namespace, component, operation
severityText, severityNumberthe severity
trace and span idsthe trace the entry was written in

Records are exported under one resource per process:

Resource attributeValue
service.namethe application’s name, or exposition for the gateway
service.namespacethe application’s name
deployment.environment.namethe 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:

messageProcess failed
severityERROR
reasonuncaught exception, unhandled rejection or command failed
errorthe 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:

LevelUse for
debugDevelopment and troubleshooting. Not enabled in production.
infoThe flow of the application: what was decided.
warnSomething that may need attention.
errorA 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.

Search
Search the documentation