feat(diary): channels, components and config/log.yaml
- stderr and null drivers; per-component levels from log.components - SUMMER_LOG__ overrides parse as strings and overlay the defaults key by key - Validation names the key: driver, format, level, days, stack children and cycles, daily path outside the root - summer new writes config/log.yaml (stack of console and daily, river warn, http info) - docs/services/logging.md: channels, config/log.yaml, component loggers, daily files, redaction - Log keys in the configuration docs; ExampleFor backs the docs snippet
This commit is contained in:
@@ -25,9 +25,12 @@ Use cases:
|
||||
## Features
|
||||
|
||||
- Component loggers: `diary.For` returns a `*slog.Logger` whose records carry the attribute `component=<name>`. It is safe to create in a package `var` block: before setup its records go to the process default logger's handler, after `diary.Setup` they reach the configured channels, and a later `diary.Setup` or `diary.SetVerbosity` applies to loggers created earlier.
|
||||
- Channels: `console` (styled lines on a terminal, logfmt otherwise, or JSON with `format: json`), `daily` (one file per day) and `stack` (several channels at once), each with its own level.
|
||||
- Channels: `console` (styled lines on a terminal, logfmt otherwise, or JSON with `format: json`), `stderr` (logfmt or JSON, never styled), `daily` (one file per day), `stack` (several channels at once, nesting allowed) and `null` (discards), each with its own level.
|
||||
- Component levels: `log.components` sets a minimum level per component (`river: warn`, `blog: debug`); components without one use `log.level`.
|
||||
- Environment overrides: `SUMMER_LOG__` variables overlay the defaults key by key, and string values parse where YAML gives numbers or lists (`SUMMER_LOG__CHANNELS__DAILY__DAYS=30`, `SUMMER_LOG__CHANNELS__STACK__CHANNELS=console,daily`).
|
||||
- Validation: an unknown driver, format or level, a stack naming a missing channel or containing itself, and a relative daily path that leaves the application root fail `diary.Setup` with an error naming the key.
|
||||
- Console lines in bonfire's palette: time, level, the component as a fixed-width prefix, the message and `key=value` attributes. Control characters other than tab are removed from messages and values, so logged data cannot inject terminal escape sequences.
|
||||
- Daily files: the path `storage/logs/summer.log` gives files `storage/logs/summer-2026-10-21.log`, created mode 0640 in directories created 0750, opened on the first record, rotated at local midnight, and deleted after the retention in days. A relative path resolves against the application root.
|
||||
- Daily files: the path `storage/logs/summer.log` gives files `storage/logs/summer-2026-10-21.log`, created mode 0640 in directories created 0750, opened on the first record, rotated at local midnight, and deleted after the retention in days (only files of that name pattern). A relative path resolves against the application root. A directory that cannot be written logs one warning from component `diary` on the console and disables the channel; it never fails the start-up.
|
||||
- Redaction: every channel leaf is wrapped by `sunscreen.Wrap`.
|
||||
- Runtime verbosity: `diary.SetVerbosity` lets info (1) or debug (2) through for every component and adds source locations on styled lines (3); quiet raises only the console channels to warn.
|
||||
- The process default logger (`slog.Default`) and the standard `log` package log through the configured channels without a component.
|
||||
@@ -95,7 +98,7 @@ Tests that set diary up restore the previous process logger with `t.Cleanup(diar
|
||||
|
||||
## Configuration
|
||||
|
||||
Without a `config/log.yaml` an application uses these defaults:
|
||||
Without a `config/log.yaml` an application uses these defaults (`summer new` writes the same file, plus `http: info`):
|
||||
|
||||
```yaml
|
||||
# config/log.yaml
|
||||
@@ -118,16 +121,21 @@ components:
|
||||
river: warn
|
||||
```
|
||||
|
||||
The defaults also define `stderr` (`driver: stderr`, `format: logfmt`) and `null` (`driver: null`).
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|-----|---------|---------|
|
||||
| `log.default` | `stack` | The channel every record goes to. |
|
||||
| `log.level` | `info` | The minimum level of every component without its own level. |
|
||||
| `log.channels.<name>.driver` | | `console`, `daily` or `stack`. |
|
||||
| `log.level` | `info` | The minimum level of every component without its own level, and of records without a component. `debug`, `info`, `warn` (or `warning`) and `error`, in any letter case. |
|
||||
| `log.channels.<name>.driver` | required for a new channel | `console`, `stderr`, `daily`, `stack` or `null`. |
|
||||
| `log.channels.<name>.level` | none | The channel's own minimum level. |
|
||||
| `log.channels.<name>.format` | per driver | `auto` (console only), `json` or `logfmt`; console defaults to `auto`, daily to `json`. |
|
||||
| `log.channels.<name>.path` | | The daily file path, relative to the application root. |
|
||||
| `log.channels.<name>.days` | `0` | Daily retention in days; `0` keeps every file. |
|
||||
| `log.channels.<name>.channels` | | A stack's children. |
|
||||
| `log.channels.<name>.format` | `auto` for console, `logfmt` for stderr, `json` for daily | `auto` (console only: styled on a terminal, logfmt otherwise), `json` or `logfmt`. |
|
||||
| `log.channels.<name>.path` | `storage/logs/summer.log` for `daily` | The daily file path; a relative path resolves against the application root and may not leave it. |
|
||||
| `log.channels.<name>.days` | `14` for `daily` | Retention in days, a whole number of at least 0; `0` keeps every file. |
|
||||
| `log.channels.<name>.channels` | `[console, daily]` for `stack` | A stack's children, as a YAML list or a comma-separated string. |
|
||||
| `log.components.<name>` | `river: warn` | The minimum level of a component. Component names cannot contain dots. |
|
||||
|
||||
Environment variables use the `SUMMER_LOG__` prefix with a double underscore between path segments: `SUMMER_LOG__COMPONENTS__RIVER=error`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
@@ -141,4 +149,4 @@ components:
|
||||
go test ./modules/diary/...
|
||||
```
|
||||
|
||||
`TestDiaryTracerSmoke` loads a `config/log.yaml` through `compass`, logs one record from a component logger and checks that it reaches the console and the daily file redacted. The tests write to temporary directories and need no external services.
|
||||
`TestDiaryTracerSmoke` loads a `config/log.yaml` through `compass`, logs one record from a component logger and checks that it reaches the console and the daily file redacted. `TestConfigEnvSmoke` parses `SUMMER_LOG__` overrides given as strings and checks that every invalid value fails with an error naming its key. `TestDailyDegradesSmoke` points the daily channel at a read-only directory and checks that it warns once and the console keeps logging. The tests write to temporary directories and need no external services.
|
||||
|
||||
@@ -48,9 +48,10 @@ type channelBuilder struct {
|
||||
warnTo slog.Handler
|
||||
}
|
||||
|
||||
// buildChannels builds the channel named by cfg.Default. Every leaf is
|
||||
// wrapped by sunscreen.Wrap and gated by its own level; a stack fans out
|
||||
// through slog.NewMultiHandler.
|
||||
// buildChannels builds the channel named by cfg.Default. Every leaf
|
||||
// (console, stderr, daily and null) is wrapped by sunscreen.Wrap and gated by
|
||||
// its own level; a stack fans out through slog.NewMultiHandler. A relative
|
||||
// daily path resolves against Options.Root.
|
||||
func buildChannels(cfg Config, opts Options) (*channels, error) {
|
||||
b := &channelBuilder{
|
||||
cfg: cfg,
|
||||
@@ -115,6 +116,16 @@ func (b *channelBuilder) build(name string) (slog.Handler, error) {
|
||||
b.warnTo = wrapped
|
||||
}
|
||||
return wrapped, nil
|
||||
case DriverStderr:
|
||||
var leaf slog.Handler
|
||||
if ch.Format == FormatJSON {
|
||||
leaf = slog.NewJSONHandler(b.opts.Stderr, hopts)
|
||||
} else {
|
||||
leaf = slog.NewTextHandler(b.opts.Stderr, hopts)
|
||||
}
|
||||
return sunscreen.Wrap(leaf), nil
|
||||
case DriverNull:
|
||||
return sunscreen.Wrap(slog.DiscardHandler), nil
|
||||
case DriverDaily:
|
||||
path := ch.Path
|
||||
if !filepath.IsAbs(path) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -84,7 +85,9 @@ type ChannelConfig struct {
|
||||
|
||||
// DefaultConfig returns the framework defaults: a stack of a console channel
|
||||
// and a daily file at storage/logs/summer.log (JSON, level warn, kept 14
|
||||
// days), global level info, and River at warn.
|
||||
// days), global level info, and River at warn. A stderr channel (logfmt) and
|
||||
// a null channel are defined too, so log.default can name them without
|
||||
// defining them.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Default: "stack",
|
||||
@@ -92,6 +95,8 @@ func DefaultConfig() Config {
|
||||
Channels: map[string]ChannelConfig{
|
||||
"stack": {Driver: DriverStack, Channels: []string{"console", "daily"}},
|
||||
"console": {Driver: DriverConsole, Format: FormatAuto},
|
||||
"stderr": {Driver: DriverStderr, Format: FormatLogfmt},
|
||||
"null": {Driver: DriverNull},
|
||||
"daily": {
|
||||
Driver: DriverDaily,
|
||||
Path: "storage/logs/summer.log",
|
||||
@@ -150,6 +155,23 @@ func ParseConfig(cfg *compass.Config) (Config, error) {
|
||||
out.Channels[name] = ch
|
||||
}
|
||||
}
|
||||
if raw, ok := cfg.Lookup(ComponentsKey); ok {
|
||||
components, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return Config{}, fmt.Errorf("diary: %s must be a map of component levels", ComponentsKey)
|
||||
}
|
||||
for _, name := range slices.Sorted(maps.Keys(components)) {
|
||||
key := ComponentsKey + "." + name
|
||||
if _, nested := components[name].(map[string]any); nested {
|
||||
return Config{}, fmt.Errorf("diary: %s must be a level; component names cannot contain dots", key)
|
||||
}
|
||||
lvl, err := parseLevel(components[name])
|
||||
if err != nil {
|
||||
return Config{}, keyError(key, err)
|
||||
}
|
||||
out.Components[name] = lvl
|
||||
}
|
||||
}
|
||||
if err := out.validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
@@ -204,11 +226,11 @@ func (c Config) validate() error {
|
||||
ch := c.Channels[name]
|
||||
key := ChannelsKey + "." + name
|
||||
switch ch.Driver {
|
||||
case DriverConsole, DriverDaily, DriverStack:
|
||||
case DriverConsole, DriverStderr, DriverDaily, DriverStack, DriverNull:
|
||||
case "":
|
||||
return fmt.Errorf("diary: %s.driver is required", key)
|
||||
default:
|
||||
return fmt.Errorf("diary: %s.driver %q is not one of console, daily, stack", key, ch.Driver)
|
||||
return fmt.Errorf("diary: %s.driver %q is not one of console, stderr, daily, stack, null", key, ch.Driver)
|
||||
}
|
||||
switch ch.Format {
|
||||
case "", FormatJSON, FormatLogfmt:
|
||||
@@ -219,8 +241,48 @@ func (c Config) validate() error {
|
||||
default:
|
||||
return fmt.Errorf("diary: %s.format %q is not one of auto, json, logfmt", key, ch.Format)
|
||||
}
|
||||
if ch.Driver == DriverDaily && ch.Path == "" {
|
||||
return fmt.Errorf("diary: %s.path is required for the daily driver", key)
|
||||
if ch.Driver == DriverDaily {
|
||||
if ch.Path == "" {
|
||||
return fmt.Errorf("diary: %s.path is required for the daily driver", key)
|
||||
}
|
||||
if !filepath.IsAbs(ch.Path) && !filepath.IsLocal(filepath.Clean(ch.Path)) {
|
||||
return fmt.Errorf("diary: %s.path %q leaves the application root; use a path inside it or an absolute path", key, ch.Path)
|
||||
}
|
||||
}
|
||||
if ch.Driver == DriverStack {
|
||||
if len(ch.Channels) == 0 {
|
||||
return fmt.Errorf("diary: %s.channels must list at least one channel", key)
|
||||
}
|
||||
for _, child := range ch.Channels {
|
||||
if _, ok := c.Channels[child]; !ok {
|
||||
return fmt.Errorf("diary: %s.channels names channel %q, which is not defined in %s", key, child, ChannelsKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, name := range slices.Sorted(maps.Keys(c.Channels)) {
|
||||
if c.Channels[name].Driver != DriverStack {
|
||||
continue
|
||||
}
|
||||
if err := c.checkCycle(name, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkCycle reports a stack that contains itself through its children.
|
||||
func (c Config) checkCycle(name string, path []string) error {
|
||||
if slices.Contains(path, name) {
|
||||
return fmt.Errorf("diary: %s.%s.channels forms a cycle (%s)", ChannelsKey, path[0], strings.Join(append(path, name), " -> "))
|
||||
}
|
||||
ch := c.Channels[name]
|
||||
if ch.Driver != DriverStack {
|
||||
return nil
|
||||
}
|
||||
for _, child := range ch.Channels {
|
||||
if err := c.checkCycle(child, append(slices.Clip(path), name)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
135
modules/diary/config_smoke_test.go
Normal file
135
modules/diary/config_smoke_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package diary_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/diary"
|
||||
)
|
||||
|
||||
// openConfig opens a compass config from an empty config directory with
|
||||
// only the given environment.
|
||||
func openConfig(t *testing.T, environ ...string) *compass.Config {
|
||||
t.Helper()
|
||||
dir := filepath.Join(t.TempDir(), "config")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: append([]string{}, environ...)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// TestConfigEnvSmoke: SUMMER_LOG__ overrides arrive as strings, parse, and
|
||||
// overlay the framework defaults key by key; every invalid value fails with
|
||||
// an error naming its key.
|
||||
func TestConfigEnvSmoke(t *testing.T) {
|
||||
cfg := openConfig(t,
|
||||
"SUMMER_LOG__CHANNELS__DAILY__DAYS=30",
|
||||
"SUMMER_LOG__CHANNELS__STACK__CHANNELS=console,daily",
|
||||
"SUMMER_LOG__COMPONENTS__RIVER=error",
|
||||
)
|
||||
got, err := diary.ParseConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseConfig: %v", err)
|
||||
}
|
||||
want := diary.DefaultConfig()
|
||||
daily := want.Channels["daily"]
|
||||
daily.Days = 30
|
||||
want.Channels["daily"] = daily
|
||||
want.Components["river"] = slog.LevelError
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("parsed config:\n got %+v\nwant %+v", got, want)
|
||||
}
|
||||
if ch := got.Channels["stack"].Channels; !reflect.DeepEqual(ch, []string{"console", "daily"}) {
|
||||
t.Errorf("stack children = %q, want console, daily", ch)
|
||||
}
|
||||
|
||||
errorCases := []struct {
|
||||
name string
|
||||
environ []string
|
||||
key string
|
||||
}{
|
||||
{"level", []string{"SUMMER_LOG__LEVEL=loud"}, "log.level"},
|
||||
{"default", []string{"SUMMER_LOG__DEFAULT=missing"}, "log.default"},
|
||||
{"driver", []string{"SUMMER_LOG__CHANNELS__DAILY__DRIVER=syslog"}, "log.channels.daily.driver"},
|
||||
{"driver required", []string{"SUMMER_LOG__CHANNELS__EXTRA__LEVEL=info"}, "log.channels.extra.driver"},
|
||||
{"format", []string{"SUMMER_LOG__CHANNELS__CONSOLE__FORMAT=xml"}, "log.channels.console.format"},
|
||||
{"auto outside console", []string{"SUMMER_LOG__CHANNELS__DAILY__FORMAT=auto"}, "log.channels.daily.format"},
|
||||
{"channel level", []string{"SUMMER_LOG__CHANNELS__CONSOLE__LEVEL=verbose"}, "log.channels.console.level"},
|
||||
{"days", []string{"SUMMER_LOG__CHANNELS__DAILY__DAYS=-1"}, "log.channels.daily.days"},
|
||||
{"days text", []string{"SUMMER_LOG__CHANNELS__DAILY__DAYS=two"}, "log.channels.daily.days"},
|
||||
{"path escapes root", []string{"SUMMER_LOG__CHANNELS__DAILY__PATH=../outside/summer.log"}, "log.channels.daily.path"},
|
||||
{"stack child", []string{"SUMMER_LOG__CHANNELS__STACK__CHANNELS=console,nowhere"}, "log.channels.stack.channels"},
|
||||
{"stack cycle", []string{
|
||||
"SUMMER_LOG__DEFAULT=a",
|
||||
"SUMMER_LOG__CHANNELS__A__DRIVER=stack",
|
||||
"SUMMER_LOG__CHANNELS__A__CHANNELS=console,b",
|
||||
"SUMMER_LOG__CHANNELS__B__DRIVER=stack",
|
||||
"SUMMER_LOG__CHANNELS__B__CHANNELS=a",
|
||||
}, "log.channels.a.channels"},
|
||||
{"component level", []string{"SUMMER_LOG__COMPONENTS__RIVER=chatty"}, "log.components.river"},
|
||||
}
|
||||
for _, tc := range errorCases {
|
||||
_, err := diary.ParseConfig(openConfig(t, tc.environ...))
|
||||
if err == nil {
|
||||
t.Errorf("%s: ParseConfig accepted %q", tc.name, tc.environ)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.key) {
|
||||
t.Errorf("%s: error %q does not name %s", tc.name, err, tc.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDailyDegradesSmoke: a daily channel whose directory cannot be created
|
||||
// warns once on the console, is disabled, and never fails Setup or stops
|
||||
// later records from reaching the console.
|
||||
func TestDailyDegradesSmoke(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root ignores directory permissions")
|
||||
}
|
||||
root := t.TempDir()
|
||||
readOnly := filepath.Join(root, "readonly")
|
||||
if err := os.Mkdir(readOnly, 0o555); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(readOnly, 0o755) })
|
||||
|
||||
cfg := openConfig(t, "SUMMER_LOG__CHANNELS__DAILY__PATH=readonly/logs/summer.log")
|
||||
var buf bytes.Buffer
|
||||
t.Cleanup(diary.Reset)
|
||||
if err := diary.Setup(cfg, diary.Options{Root: root, Stderr: &buf, Color: bonfire.ColorNever}); err != nil {
|
||||
t.Fatalf("Setup failed because of the log file: %v", err)
|
||||
}
|
||||
|
||||
logger := diary.For("journal")
|
||||
logger.Warn("first warning")
|
||||
logger.Warn("second warning")
|
||||
logger.Info("still logging")
|
||||
|
||||
out := buf.String()
|
||||
if n := strings.Count(out, "daily log file disabled"); n != 1 {
|
||||
t.Errorf("daily warning appears %d times, want 1:\n%s", n, out)
|
||||
}
|
||||
if !strings.Contains(out, filepath.Join(readOnly, "logs")) {
|
||||
t.Errorf("warning does not name the path:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "component=diary") {
|
||||
t.Errorf("warning does not come from component diary:\n%s", out)
|
||||
}
|
||||
for _, want := range []string{"first warning", "second warning", "still logging"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("console lacks %q after the daily channel failed:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"charm.land/lipgloss/v2"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
)
|
||||
|
||||
|
||||
@@ -63,17 +63,23 @@ func (o Options) normalize() Options {
|
||||
// state is one installed configuration. It is replaced as a whole; only the
|
||||
// console level vars and the source switch change in place.
|
||||
type state struct {
|
||||
gen uint64
|
||||
root slog.Handler
|
||||
level slog.Level
|
||||
verbosity int
|
||||
quiet bool
|
||||
channels *channels
|
||||
gen uint64
|
||||
root slog.Handler
|
||||
level slog.Level
|
||||
components map[string]slog.Level
|
||||
verbosity int
|
||||
quiet bool
|
||||
channels *channels
|
||||
}
|
||||
|
||||
// floor is the minimum level for records of component name.
|
||||
// floor is the minimum level for records of component name: its
|
||||
// log.components level, else log.level, lowered by verbosity. Records without
|
||||
// a component use log.level.
|
||||
func (s *state) floor(name string) slog.Level {
|
||||
f := s.level
|
||||
if l, ok := s.components[name]; ok && name != "" {
|
||||
f = l
|
||||
}
|
||||
switch {
|
||||
case s.verbosity >= 2:
|
||||
f = min(f, slog.LevelDebug)
|
||||
@@ -152,12 +158,13 @@ func install(conf Config, ch *channels, opts Options) {
|
||||
}
|
||||
verbosity.Store(int64(opts.Verbosity))
|
||||
st := &state{
|
||||
gen: gens.Add(1),
|
||||
root: ch.root,
|
||||
level: conf.Level,
|
||||
verbosity: opts.Verbosity,
|
||||
quiet: opts.Quiet,
|
||||
channels: ch,
|
||||
gen: gens.Add(1),
|
||||
root: ch.root,
|
||||
level: conf.Level,
|
||||
components: conf.Components,
|
||||
verbosity: opts.Verbosity,
|
||||
quiet: opts.Quiet,
|
||||
channels: ch,
|
||||
}
|
||||
old := current.Swap(st)
|
||||
slog.SetDefault(slog.New(newComponentHandler("")))
|
||||
|
||||
34
modules/diary/example_test.go
Normal file
34
modules/diary/example_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package diary_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/diary"
|
||||
)
|
||||
|
||||
// blogLog is the component logger of a blog plugin, created once in a
|
||||
// package var block, before diary is set up.
|
||||
var blogLog = diary.For("blog")
|
||||
|
||||
func ExampleFor() {
|
||||
// Before diary.Setup runs, records go to the process default logger.
|
||||
// This one drops the time so the output is stable.
|
||||
previous := slog.Default()
|
||||
defer slog.SetDefault(previous)
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
|
||||
if len(groups) == 0 && a.Key == slog.TimeKey {
|
||||
return slog.Attr{}
|
||||
}
|
||||
return a
|
||||
},
|
||||
})))
|
||||
|
||||
// After diary.Setup the same record reaches the configured channels and
|
||||
// passes log.components.blog, else log.level.
|
||||
blogLog.InfoContext(context.Background(), "post published", "id", 7)
|
||||
// Output:
|
||||
// level=INFO msg="post published" component=blog id=7
|
||||
}
|
||||
Reference in New Issue
Block a user