# Logger

> Write structured logs with log/slog, tagged with each request's ID, readable in a terminal and JSON in production.

This guide covers logging in Tinka. You will learn how to:

- Write logs at six levels, with structured attributes
- Tag every log written during a request with the request's ID
- Configure readable logs for development and JSON logs for production
- Write logs to files and to several outputs at once
- Create child loggers that carry context into every record
- Hide sensitive values from log output

## Overview

Tinka logs with the standard library's `log/slog`. The `log` package (`github.com/mucolabs/tinka/log`) builds a configured `*slog.Logger`, so every Go library that accepts a logger works with yours, and every `slog.Handler` written for Go works with Tinka. Records are written as JSON lines in production, ready for log collectors, and as readable lines in your terminal during development.

The logger integrates with the App. Every request gets an ID, and every record you log with the request's context carries it as `request_id`, so you can follow one request through the logs.

## Writing your first log

Create a logger with `log.New` and call one of its methods. During development, `log.Pretty` writes a readable line with the time, the level and the message:

```go title="main.go"
package main

import (
	"github.com/mucolabs/tinka/log"
)

func main() {
	logger := log.New(log.Config{Format: log.Pretty})
	logger.Info("Processing home page request")
}
```

```text title="Output"
[10:24:36.842] INFO: Processing home page request
```

`slog` has a method for each of its four levels, and `Log` writes at any level, including the two Tinka adds, trace and fatal:

```go
logger.Log(ctx, log.LevelTrace, "Using config") // the most verbose
logger.Debug("User details", "user_id", 1)
logger.Info("Creating new post")
logger.Warn("Rate limit approaching")
logger.Error("Failed to save post", "err", err)
logger.Log(ctx, log.LevelFatal, "Database connection lost") // the most severe
```

### Adding context to logs

Pass key and value pairs after the message to add attributes to the record. They become fields of the JSON output, so log collectors can filter on them:

```go
logger.Info("User logged in", "user_id", user.ID, "email", user.Email)
```

Log errors under the `err` key, which `slog` writes as the error's message:

```go
if err := publish(post); err != nil {
	logger.Error("Publishing failed", "err", err, "post_id", post.ID)
}
```

:::tip
Keep values out of the message and pass them as attributes instead: `"User logged in", "user_id", 42` stays searchable by `user_id`, while `"User 42 logged in"` doesn't.
:::

## Request-aware logging

Log with the request's context, and the record carries the ID the App gave the request. The ID is the client's `X-Request-Id` header when it sends a valid one, and the same ID is sent back in the response and written in the App's access log:

```go title="posts.go"
package main

import (
	"log/slog"

	"github.com/mucolabs/tinka"
)

type Posts struct {
	Log *slog.Logger
}

func (h Posts) Show(c *tinka.Ctx) (map[string]int64, error) {
	id, err := c.ParamInt("id")
	if err != nil {
		return nil, err
	}
	h.Log.InfoContext(c, "Fetching post", "post_id", id)
	return map[string]int64{"id": id}, nil
}
```

```text title="Output"
[10:24:36.842] INFO: Fetching post post_id=42 request_id=6ZQ7K2M4XW1NB8P3R5T9VY0DCF
[10:24:36.843] INFO: request method=GET path=/posts/42 status=200 took=1.2ms request_id=6ZQ7K2M4XW1NB8P3R5T9VY0DCF
```

Pass the same logger to the App, so its access log and error records use your configuration:

```go
logger := log.New(log.Config{Format: log.Pretty})
app := tinka.New(tinka.HTTPConfig{Logger: logger})
app.Get("/posts/{id}", Posts{Log: logger}.Show)
```

To log from code that has the context but not the logger, make yours the default with `slog.SetDefault(logger)` and call `slog.InfoContext(ctx, …)`.

You can add attributes of your own to a context with `log.With`, and every record logged with that context carries them:

```go
ctx := log.With(c, slog.String("order_id", order.ID))
logger.InfoContext(ctx, "Order paid") // order_id and request_id
```

## Configuring the logger

Build the logger's configuration from the environment at boot, with the rest of your [configuration](../../configuration.md). Here the level comes from `LOG_LEVEL`, and the format depends on the Mode: readable lines in development, JSON elsewhere.

```go title="config.go"
package main

import (
	"github.com/mucolabs/tinka/env"
	"github.com/mucolabs/tinka/log"
)

func loggerConfig(e *env.Env) log.Config {
	format := log.JSON
	if e.Mode() == env.Development {
		format = log.Pretty
	}
	return log.Config{
		Name:   e.String("APP_NAME", "blog"),
		Level:  log.Level(e.OneOf("LOG_LEVEL", log.Levels, "info")),
		Format: format,
	}
}
```

In production the same calls write one JSON object per line to standard output, where containers and platforms collect logs:

```text title="Output"
{"time":"2026-10-20T10:24:36.842Z","level":"INFO","msg":"Fetching post","name":"blog","post_id":42,"request_id":"6ZQ7K2M4XW1NB8P3R5T9VY0DCF"}
```

### Configuration reference

::::options

:::option{name="Disabled" type="bool"}
Discard every record. Use it to silence a logger in tests.
:::

:::option{name="Name" type="string"}
Added to every record as `name`, to tell Apps or parts of an App apart in shared logs.
:::

:::option{name="Level" type="slog.Leveler"}
The least severe level written. Records below it are skipped before any work is done. Defaults to `log.LevelInfo`.
:::

:::option{name="Format" type="log.Format"}
`log.JSON` (the default), `log.Pretty` for people in a terminal, or `log.Text` for `slog`'s `key=value` lines.
:::

:::option{name="Output" type="io.Writer"}
Where records go. Defaults to `os.Stdout`.
:::

:::option{name="Time" type="log.TimeFormat"}
How each record's time is written. See [customizing timestamps](#customizing-timestamps).
:::

:::option{name="Redact" type="log.Redaction"}
Which attributes to hide. See [hiding sensitive values](#hiding-sensitive-values).
:::

:::option{name="AddSource" type="bool"}
Add the file and line of each logging call as `source`.
:::

::::

### Log levels

The logger has six levels, from the most to the least verbose. A logger writes records at its level and above.

| Level | Name in `LOG_LEVEL` | Use for |
|---|---|---|
| `log.LevelTrace` | `trace` | Extremely detailed tracing |
| `log.LevelDebug` | `debug` | Details useful during development |
| `log.LevelInfo` | `info` | General operational information |
| `log.LevelWarn` | `warn` | Conditions that should be reviewed |
| `log.LevelError` | `error` | Errors that need attention |
| `log.LevelFatal` | `fatal` | Critical failures that need action now |

`log.Level` turns a name into a level, and `log.Levels` lists the names for `env.OneOf`, which reports any other value at boot. To change the level while the App runs, pass a `*slog.LevelVar` and call its `Set` method.

### Customizing timestamps

By default, each record's time is written in RFC 3339 with nanoseconds. Set `Time` to `log.TimeUnixMilli` to write milliseconds since the Unix epoch, or to `log.TimeOff` to leave the time out when your platform stamps each line itself:

```go
log.Config{Time: log.TimeUnixMilli}
```

### Writing logs to a file

Pass any `io.Writer` as `Output`. To write to a file, open it for appending:

```go
f, err := os.OpenFile("/var/log/blog/app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640)
if err != nil {
	return err
}
logger := log.New(log.Config{Output: f})
```

Tinka doesn't rotate files. Let a system tool such as `logrotate` do it, with its `copytruncate` option so the App keeps writing to the same file.

### Writing to several outputs

`log.NewHandler` returns the handler behind a logger, and `slog.NewMultiHandler` sends every record to several of them. Each output keeps its own level and format, so you can read readable lines in your terminal while a file keeps JSON:

```go
logger := slog.New(slog.NewMultiHandler(
	log.NewHandler(log.Config{Format: log.Pretty, Level: log.LevelDebug}),
	log.NewHandler(log.Config{Output: f, Level: log.LevelWarn}),
))
```

## Using multiple loggers

When a part of your App needs its own logging, such as payments written to their own file, create a second logger with its own configuration and pass it where it's needed:

```go
appLog := log.New(loggerConfig(e))
paymentsLog := log.New(log.Config{Name: "payments", Output: paymentsFile})
```

## Passing the logger to your code

Give the code that logs a `*slog.Logger` as a field or a parameter, as `Posts` does above. Tests then pass a logger that writes to a buffer, or a disabled one, and code that runs during a request gets `request_id` from the context it already receives.

## Child loggers

`With` returns a child logger that adds attributes to every record it writes, and `WithGroup` nests the attributes that follow under a name. A child keeps its parent's level, format and redaction:

```go
orderLog := logger.With("order_id", "order_123")
orderLog.Info("Processing order") // order_id=order_123
orderLog.Info("Order complete")   // order_id=order_123
```

## Conditional logging

When building a record's data is expensive, check whether the level is enabled before doing the work:

```go
if logger.Enabled(ctx, log.LevelDebug) {
	logger.DebugContext(ctx, "Cart contents", "items", expensiveSummary(cart))
}
```

## Hiding sensitive values

Logs can expose sensitive data by accident. `Redact` replaces the values of the attributes you name with `[redacted]`. A path names an attribute inside groups with dots, and `*` matches any one group or key:

```go
logger := log.New(log.Config{
	Redact: log.Redaction{Paths: []string{"password", "*.password", "card.*"}},
})
logger.Info("User signup", "username", "virk", "password", "secret123")
```

```text title="Output"
{"time":"2026-10-20T10:24:36.842Z","level":"INFO","msg":"User signup","username":"virk","password":"[redacted]"}
```

Set `Censor` to use another placeholder, or `Remove` to drop the attributes entirely:

```go
log.Redaction{Paths: []string{"password"}, Censor: "[PRIVATE]"}
log.Redaction{Paths: []string{"password"}, Remove: true}
```

### Using env.Secret

An alternative to redaction is keeping the value in an `env.Secret`, which every logger writes as `[redacted]` wherever it appears. Values read with `e.Secret` from the [environment](../../configuration.md) already are:

```go
key := e.Secret("STRIPE_KEY")
logger.Info("Payments ready", "key", key) // key=[redacted]
```

## Using slog directly

A Tinka logger is a plain `*slog.Logger`, so you can wrap its handler, use handlers from other libraries, or call `slog` functions as you would without Tinka. For a logger that doesn't come from `log.New`, `log.Contextual` adds the context's attributes to its records, `request_id` included.

See also: [Configuration & Environment](../../configuration.md), [App](../../reference/app.md), [Exception handling](../basics/exception-handling.md)
