Skip to main content

Logging basics

The first time you run an application, it talks:

{"time":"2026-08-10T11:44:28.577Z","level":"INFO","severity":9,"msg":"Server listening at http://127.0.0.1:8080"}

Nothing in your manifest asked for that. This page explains where it comes from and how to turn it up, down, or off — which is most of what anyone needs from logging on day one.

Your app already logs​

There is no logging to switch on. An application with no logging: block behaves exactly as if a single console sink were declared, and the format follows where the output is going:

On a terminal — human-readable, aligned, coloured:

11:44:50.558  INFO   Server listening at http://127.0.0.1:8080

Piped or redirected — one JSON object per line, ready for whatever collects it:

{"time":"2026-08-10T11:44:28.577Z","level":"INFO","severity":9,"msg":"Server listening at http://127.0.0.1:8080"}

Same records, two encodings. You do not configure the switch; it follows the destination, so telo ./telo.yaml reads well by hand and telo ./telo.yaml > app.log produces something machine-readable.

Records are structured, not text lines. Each carries a timestamp, a severity, a message, the resource that emitted it, any attributes that resource attached, and — when it happened inside a dispatch — the trace and span it belongs to. That is why the JSON form has fields rather than a formatted string: nothing has to be parsed back out.

Turning the volume down​

One field on the application:

kind: Telo.Application
metadata:
name: GreetingApi
logging:
level: warn

Six levels are nameable: trace, debug, info (the default), warn, error, fatal. At warn the listening line above disappears, because it is an info record — the app runs identically and simply says less.

This is worth reaching for sooner than you would think. A test that stands a server up spends most of its output on request logs it does not care about; level: warn is usually the difference between a readable test run and a wall of JSON.

Choosing the level per environment​

The level is an ordinary field, so it takes an expression like any other — bind a variable and read it:

variables:
logLevel:
env: LOG_LEVEL
type: string
default: info
logging:
level: !cel "variables.logLevel"

LOG_LEVEL=debug telo ./telo.yaml for a noisy local run, the default in production, and no separate logging configuration file in either case.

Secrets do not reach the log​

Values bound to secrets: are redacted automatically, with no configuration — this is the promise Configuring an application makes when it tells you to put sensitive values there. Redaction happens before serialization and before any sink sees the record, and the key is preserved while the value is replaced, so you can still tell the field was present.

One distinction that catches people: what you deliberately print is not a log record. Console.writeLine writes to standard output, so a secret you interpolate into it yourself is printed exactly as written. Redaction governs the log pipeline, not your own output.

Where to go next​

Everything above is the day-one surface. When you need more —

  • Sending records somewhere other than the console — file sinks, several sinks at once, buffering and durability.
  • Turning one noisy dependency up or down without changing the global level.
  • Redacting paths of your own, beyond the automatic secret handling.
  • Sampling a repeated message so a hot path cannot flood the log.
  • Shipping records to an OTLP collector.
  • Emitting records from a controller you are writing yourself.

— all of it is in Logging.