---
title: "Logcopter: Package-Scoped Logging for Go CLIs"
description: "Logcopter: Package-Scoped Logging for Go CLIs Logcopter is a small Go logging utility built on top of zerolog. Its purpose is not to replace zerolog, and not to invent a new logging backend. Its purpo…"
doc_version: 1
last_updated: 2026-10-17
canonical_url: "http://parc.yolo.scapegoat.dev/note/projects/2026/05/25/article-logcopter-package-scoped-logging-for-go-clis"
---

# Logcopter: Package-Scoped Logging for Go CLIs

Logcopter: Package-Scoped Logging for Go CLIs Logcopter is a small Go logging utility built on top of zerolog. Its purpose is not to replace zerolog, and not to invent a new logging backend. Its purpo…

## Tags

- `article`
- `project-report`
- `go`
- `logging`
- `zerolog`
- `glazed`
- `codegen`

## Content

# Logcopter: Package-Scoped Logging for Go CLIs

Logcopter is a small Go logging utility built on top of `zerolog`. Its purpose is not to replace `zerolog`, and not to invent a new logging backend. Its purpose is to give Go applications a stable way to say: this package belongs to this diagnostic area, this area has this effective level, and changing logging configuration should affect package-local loggers that already exist.

The implementation happened as LOGCOPTER-001 in the workspace `/home/manuel/workspaces/2026-05-25/logcopter`. The core repository is `/home/manuel/workspaces/2026-05-25/logcopter/logcopter`; the in-place CLI/config integration lives in `/home/manuel/workspaces/2026-05-25/logcopter/glazed/pkg/cmds/logging`.

> [!summary]
> Logcopter adds three missing pieces on top of zerolog: stable package area names, hierarchical per-area levels, and reload-aware generated package loggers.
>
> The important design choice is that normal filtering happens on per-area child loggers, not through a restrictive `zerolog.SetGlobalLevel`.
>
> Glazed owns command-line and config integration, while logcopter stays a small runtime and generator package.

## Why this project exists

Plain `zerolog` is fast, structured, and pleasant to use once a logger exists. The problem begins one layer above that: how does each package get the right logger, how does the application configure one package at `trace` while another remains at `warn`, and how do you avoid manually threading diagnostic loggers through every package just for package-scope instrumentation?

A large CLI application often has several different classes of logs:

- startup logs that should normally remain quiet;
- protocol or parser traces that are useful only while debugging one subsystem;
- database logs that may be too noisy at `debug`;
- UI rendering logs that sometimes need `trace` while everything else stays at `info`.

A single process-wide level such as `--log-level debug` is too blunt for that. It either turns on too much or suppresses the one subsystem you wanted to inspect. The needed shape is closer to this:

```yaml
logging:
  log-level: info
  areas:
    app.view.render: trace
    app.db: warn
    lib.protocol: debug
```

That configuration says something precise. It says the application default is `info`, but `app.view.render` is allowed to speak at `trace`, `app.db` should stay quiet until `warn`, and a reusable protocol library can be diagnosed independently under `lib.protocol`.

Zerolog can already represent this with child loggers and per-logger levels. What it does not provide is a package-wide naming convention, a generator that creates package-local loggers, a reload-aware wrapper, or Glazed integration that knows how to merge CLI and profile configuration into those per-area levels. Logcopter fills exactly that gap.

## The central mental model

The central object in logcopter is an **area**. An area is a dot-separated name such as `app.view.render`, `app.db.sql`, or `lib.ble.decoder`. It is not a Go import path, but it is usually derived from one. The area is the stable configuration key. Code uses the area; users configure the area.

A package normally gets a generated logger like this:

```go
// Code generated by logcopter-gen; DO NOT EDIT.
package render

import logcopter "github.com/go-go-golems/logcopter/pkg/logcopter"

var log = logcopter.Package("app.view.render")
```

The package then logs in normal zerolog style:

```go
func Render(name string) {
    log.Trace().Str("template", name).Msg("render start")
    log.Debug().Msg("cache lookup")
}
```

The key point is that `log` is not a raw `zerolog.Logger`. It is a small wrapper. Every time package code calls `log.Debug()` or `log.Trace()`, the wrapper asks the current manager state for the effective logger for `app.view.render`. That lookup is what makes reload possible. A raw logger would freeze its level at generation or initialization time; the wrapper defers that decision until the event is created.

The result is a three-layer system:

```mermaid
flowchart TD
    Pkg["Package code<br/>log.Debug().Msg(...)"] --> Wrapper["logcopter.Logger wrapper<br/>area = app.view.render"]
    Wrapper --> Manager[Manager current state<br/>atomic snapshot]
    Manager --> Resolve[Longest-prefix level lookup]
    Resolve --> Child[zerolog child logger<br/>area field + level]
    Child --> Output[stderr/stdout/file<br/>text or JSON]

    Config[Glazed logging config<br/>flags + YAML + profiles] --> Manager

    style Wrapper fill:#eef6ff,stroke:#4677c8
    style Manager fill:#fff3d6,stroke:#d99800
    style Resolve fill:#f0fff4,stroke:#2f855a
    style Output fill:#f7f7f7,stroke:#777
```

The wrapper does not own output. The manager does not parse CLI flags. The generator does not configure logging. Each part has one job.

## Project shape

The completed work spans two repositories.

### Logcopter repository

The logcopter repository contains the runtime, generator, examples, docs, and release-prep material.

Important paths:

```text
/home/manuel/workspaces/2026-05-25/logcopter/logcopter
├── pkg/logcopter/
│   ├── areas.go             # area validation and longest-prefix level lookup
│   ├── benchmark_test.go    # manager lookup benchmarks
│   ├── config.go            # runtime config shape
│   ├── global.go            # default process-wide manager helpers
│   ├── level.go             # zerolog level parsing and aliases
│   ├── logger.go            # reload-aware Logger wrapper
│   ├── manager.go           # immutable manager snapshots
│   └── output.go            # small output construction helpers
├── cmd/logcopter-gen/
│   ├── main.go              # generator CLI flags
│   ├── packages.go          # package discovery and area derivation
│   ├── render.go            # generated source rendering and check mode
│   └── integration_test.go  # generated-source temp-module compile test
├── pkg/doc/
│   ├── topics/logcopter-logging-architecture.md
│   └── tutorials/logcopter-package-logging.md
├── examples/basic/
├── examples/library-prefix/
├── README.md
└── CHANGELOG.md
```

### Glazed repository

Glazed owns application-facing configuration. The design deliberately avoided a separate `logcopter/pkg/glazedlogging` adapter package because applications already depend on Glazed and already use `glazed/pkg/cmds/logging`.

Important paths:

```text
/home/manuel/workspaces/2026-05-25/logcopter/glazed
├── pkg/cmds/logging/
│   ├── section.go           # fields and root persistent flags
│   ├── init.go              # final logger setup and logcopter configuration
│   ├── init-early.go        # early flag parsing before command discovery
│   └── logcopter_test.go    # integration tests
├── pkg/cmds/fields/
│   ├── parse.go             # TypeKeyValue accepts key:value and key=value
│   └── parse_test.go        # parser and pflag regression tests
├── cmd/glaze/main.go        # optional workspace loading of logcopter help docs
└── pkg/doc/topics/logging-section.md
```

That split is the project's most important architectural boundary: logcopter is the utility package; Glazed is the CLI/config integration layer.

## The runtime design

The runtime lives in `pkg/logcopter`. Its public surface is intentionally small:

```go
type Config struct {
    Output      string
    Format      string
    Level       string
    Caller      bool
    Timestamp   bool
    Areas       map[string]string
    StrictAreas bool
}

func Configure(base zerolog.Logger, cfg Config) error
func Package(area string) Logger
func For(area string) Logger
func EffectiveLevel(area string) zerolog.Level
```

A caller configures one base zerolog logger, then gives logcopter a `Config`. Package code calls `logcopter.Package("area.name")`, usually through generated code.

### Immutable snapshots

The manager uses `atomic.Value` to store a pointer to an immutable `state`:

```go
type Manager struct {
    mu    sync.Mutex
    state atomic.Value // stores *state
}

type state struct {
    base         zerolog.Logger
    defaultLevel zerolog.Level
    configured   map[string]zerolog.Level
    known        map[string]struct{}
    strictAreas  bool
    areaField    string
}
```

The reason for this shape is reload safety. A logging call should not hold a lock while it emits an event. It should load a snapshot, derive the child logger, and continue. Reconfiguration is less frequent than logging, so it can do the heavier work: parse levels, normalize areas, clone maps, validate strict areas, and then atomically swap the whole snapshot.

The algorithm is simple:

```text
Configure(base, cfg):
  parse cfg.Level, defaulting empty to info
  for each cfg.Areas entry:
    normalize area
    parse level
    store normalized area -> zerolog.Level

  lock manager
    clone old known-area set
    build next immutable state
    if strict mode:
      validate configured areas against known areas
      if invalid: return error without swapping
    atomic.Store(next)
```

The important property is that invalid reloads do not partially apply. If a reload contains `level: verbose` or an invalid area name, `Configure` returns an error and the old snapshot remains active. That gives callers a safe way to try new configuration without corrupting logging state.

### Longest-prefix level lookup

Areas are hierarchical. If the configured map contains `app.view: debug`, then `app.view.render` inherits `debug` unless a more specific entry exists. If the map also contains `app.view.render: trace`, then `app.view.render.partial` inherits `trace`.

The lookup is conceptually:

```text
resolveLevel(area, configured, default):
  current = area
  while current is not empty:
    if configured[current] exists:
      return configured[current]
    current = parent before last dot
  return default
```

This is the part that lets a profile target a whole subsystem without listing every package. A library can publish `lib.ble.decoder`, `lib.ble.transport`, and `lib.ble.rx`; a user can configure `lib.ble: trace` and catch them all.

### Known areas and strict validation

Every successful call to `Manager.For(area)` or `Manager.Package(area)` registers a known area. The manager then has two sets:

- configured areas, supplied by files or CLI flags;
- known areas, discovered from generated package loggers or manual registrations.

Strict validation asks whether every configured area matches something known. The match is intentionally flexible: a configured parent matches a known child, and a configured child matches a known parent. This allows users to configure `app.view` even if the only generated package is currently `app.view.render`.

This feature catches typos such as `app.veiw` while still allowing broad subsystem settings.

### Why the wrapper matters

The generated package variable is a wrapper:

```go
type Logger struct {
    manager *Manager
    area    string
    invalid error
}

func (l Logger) Debug() *zerolog.Event {
    logger := l.raw()
    return logger.Debug()
}

func (l Logger) raw() zerolog.Logger {
    if l.invalid != nil || l.area == "" {
        return zerolog.Nop()
    }
    m := l.manager
    if m == nil {
        m = DefaultManager()
    }
    return m.resolve(l.area)
}
```

This design pays for one manager lookup per log event. The benchmark added near the end of the implementation measured that cost before adding any cache:

```text
BenchmarkLoggerLookupDisabledLevel-8   143.2 ns/op   512 B/op   1 allocs/op
BenchmarkLoggerLookupEnabledLevel-8    211.2 ns/op   512 B/op   1 allocs/op
```

Those numbers are useful not because they are final, but because they establish a baseline. If a future optimization caches derived child loggers, it should be measured against this. Until a real workload proves this is a bottleneck, the simpler reload-aware wrapper is the right tradeoff.

## The generator design

The generator exists because area names should be stable and boring. Humans can write `var log = logcopter.Package(...)` by hand, but hand-written area names drift. The generator derives them from import paths.

A typical invocation looks like this:

```bash
go run ./cmd/logcopter-gen \
  -area-prefix app \
  -strip-prefix github.com/acme/server/internal \
  ./internal/...
```

The rule is:

```text
area = areaPrefix + "." + importPathWithoutStripPrefixWithSlashesChangedToDots
```

For example:

| Package path | Strip prefix | Area prefix | Generated area |
|---|---|---|---|
| `github.com/acme/server/internal/view/render` | `github.com/acme/server/internal` | `app` | `app.view.render` |
| `github.com/acme/ble/rx/decoder` | `github.com/acme/ble` | `lib.ble` | `lib.ble.rx.decoder` |
| `github.com/acme/server/internal` | `github.com/acme/server/internal` | `app` | `app` |

The implementation uses `golang.org/x/tools/go/packages` to load package names and file locations. It skips `main` packages by default, because command entry points often have custom startup code rather than package-local diagnostics. `-include-main` can opt them back in.

The generated file is intentionally tiny:

```go
// Code generated by logcopter-gen; DO NOT EDIT.

package decoder

import logcopter "github.com/go-go-golems/logcopter/pkg/logcopter"

var log = logcopter.Package("lib.ble.decoder")
```

There are two important validation modes:

- `-dry-run` prints planned writes without changing files.
- `-check` fails if generated files are stale, making it suitable for CI.

The repository now uses that shape in `make generate-check` and in GitHub Actions for the `examples/library-prefix` tree.

## Glazed integration

The Glazed integration is where logcopter becomes useful in real CLI applications. It adds area-aware settings to the existing Glazed logging package instead of creating a new adapter.

The settings struct now includes the previous fields plus logcopter-specific fields:

```go
type LoggingSettings struct {
    WithCaller     bool              `glazed:"with-caller"`
    LogLevel       string            `glazed:"log-level"`
    LogFormat      string            `glazed:"log-format"`
    LogFile        string            `glazed:"log-file"`
    LogToStdout    bool              `glazed:"log-to-stdout"`
    LogConfigFiles []string          `glazed:"log-config"`
    LogAreas       map[string]string `glazed:"log-area"`
    Areas          map[string]string `glazed:"areas"`
    StrictAreas    bool              `glazed:"strict-log-areas"`
}
```

The user-facing flags are:

```bash
--log-level info
--log-format text
--log-file /tmp/app.log
--with-caller
--log-to-stdout
--log-config ~/.config/logcopter/profiles/dev.yaml
--log-area app.view:debug
--log-area app.db=warn
--strict-log-areas
```

The `log-area` flag uses Glazed's `TypeKeyValue`. During implementation, that parser was improved to accept both `key:value` and `key=value`, because both syntaxes are natural on a CLI and the previous error text already implied `key=value` support. Cobra's `StringSlice` comma behavior is also tested, so this works:

```bash
myapp --log-area app.view=debug,app.db=warn
```

### Profile files

The integration supports two profile file shapes. The first is application-style and nests settings under `logging:`:

```yaml
logging:
  log-level: info
  log-format: text
  areas:
    app.view.render: trace
    app.db: warn
```

The second is a direct logcopter profile shape:

```yaml
level: info
format: text
areas:
  app.view.render: trace
  app.db: warn
```

The direct shape is useful for reusable profiles under paths such as `~/.config/logcopter/profiles/dev.yaml`. The wrapped shape is useful when an application already has a full config file with a `logging` section.

### Merge order

In the Cobra initialization path, the merge order is:

1. defaults;
2. explicit `--log-config` files, in command-line order;
3. direct CLI flags such as `--log-level` and `--log-area`.

That order lets a developer keep a reusable profile and override one area for a single run:

```bash
myapp serve \
  --log-config ~/.config/logcopter/profiles/dev.yaml \
  --log-area app.db=warn
```

The profile may enable `trace` broadly, while the direct CLI flag narrows a noisy subsystem.

### The global zerolog level trap

This is the non-obvious part of the design. Zerolog has a process-wide global level. If that global level is set to `info`, then a per-area child logger at `trace` may still be suppressed before it can emit. That would break the central logcopter use case.

Glazed therefore keeps the process-wide zerolog gate permissive:

```go
zerolog.SetGlobalLevel(zerolog.TraceLevel)
log.Logger = base.Level(defaultLevel)
```

The conventional global `log.Logger` still gets the user's default level. Logcopter package loggers get their own child levels. This is how one area can emit `trace` while another remains at `warn`.

Think of the global level as an emergency clamp, not the normal filter. Logcopter's normal filter is the child logger level selected by area lookup.

## How to use logcopter directly

The simplest direct use does not require Glazed:

```go
package main

import "github.com/go-go-golems/logcopter/pkg/logcopter"

var log = logcopter.Package("app.example.basic")

func main() {
    base := logcopter.NewLogger(logcopter.OutputConfig{
        Output:    logcopter.OutputStderr,
        Format:    logcopter.FormatText,
        Timestamp: true,
        NoColor:   true,
    })

    err := logcopter.Configure(base, logcopter.Config{
        Level: "info",
        Areas: map[string]string{
            "app.example.basic": "debug",
        },
    })
    if err != nil {
        panic(err)
    }

    log.Debug().Msg("debug enabled for this area")
    log.Info().Msg("hello from logcopter")
}
```

The direct runtime API is useful for libraries, small commands, tests, and applications that do not use Glazed. It deliberately avoids rotating file policy. `pkg/logcopter/output.go` can construct simple stderr/stdout JSON/text loggers, while Glazed remains responsible for rotating files through `lumberjack`.

## How to use logcopter in a Glazed application

A Glazed application should keep using the existing logging package:

```go
import "github.com/go-go-golems/glazed/pkg/cmds/logging"
```

At root command setup:

```go
logging.AddLoggingSectionToRootCommand(rootCmd, "my-app")
```

After Cobra parses flags:

```go
if err := logging.InitLoggerFromCobra(cmd); err != nil {
    return err
}
```

Package code uses generated logcopter loggers. Application startup remains Glazed-shaped. That means existing applications can adopt area logging without rewriting their bootstrap around a new adapter.

## Documentation and help entries

The project also added documentation that can be surfaced through Glazed's help system without making logcopter depend on Glazed.

The logcopter repository contains Markdown-only help entries:

```text
logcopter/pkg/doc/topics/logcopter-logging-architecture.md
logcopter/pkg/doc/tutorials/logcopter-package-logging.md
```

The Glaze binary optionally loads these files from a sibling workspace checkout. The dependency direction is important: logcopter contains Markdown, while Glazed owns the help loader. If logcopter imported Glazed just to register help pages, the utility package would gain an unnecessary application-framework dependency.

This is a small example of a broader rule used throughout the implementation: put integration behavior in the package that already owns integration.

## Validation and current status

The final implementation was validated across the local workspace.

Logcopter validation:

```bash
cd /home/manuel/workspaces/2026-05-25/logcopter/logcopter
go test ./...
make generate-check
go test ./pkg/logcopter -bench BenchmarkLoggerLookup -benchmem
docmgr doctor --ticket LOGCOPTER-001 --stale-after 30
```

Glazed validation:

```bash
cd /home/manuel/workspaces/2026-05-25/logcopter/glazed
go test ./pkg/cmds/fields ./pkg/cmds/logging ./cmd/glaze
```

Pinocchio smoke test:

```bash
cd /home/manuel/workspaces/2026-05-25/logcopter/pinocchio
go test ./cmd/pinocchio
go run ./cmd/pinocchio --help
```

The Glazed integration commit also ran the broader Glazed pre-commit validation: `go test ./...`, `golangci-lint`, `gosec`, and `govulncheck`.

The main implementation commits were:

| Repository | Commit | Purpose |
|---|---:|---|
| logcopter | `3a8fa1a` | Scaffold module and generator command |
| logcopter | `fb7de48` | Implement level and area primitives |
| logcopter | `7bdc491` | Implement reload-aware manager |
| logcopter | `6c5a856` | Implement generator first pass |
| logcopter | `03d3e9f` | Add output helpers and generator validation |
| logcopter | `98cd7f6` | Add examples and release notes |
| logcopter | `b2c6b62` | Add logger benchmarks |
| glazed | `0736140` | Accept equals syntax for key-value fields |
| glazed | `3a3d5ec` | Load logcopter help docs in Glaze |
| glazed | `2a86471` | Integrate logcopter with Glazed logging |
| glazed | `9adc8d6` | Document logcopter logging options |

All LOGCOPTER-001 task phases are checked complete. The only deliberate non-action was creating an actual release tag; release notes and CI preparation exist, but tagging should happen after review.

## Failure modes and design cautions

### Capturing `Raw()` loses reload behavior

`Logger.Raw()` exists for interop with APIs that require a `zerolog.Logger`. It returns the current underlying child logger. If code stores that raw logger and uses it later, it has captured the old level and context. That is sometimes acceptable in a hot path, but it is not reload-aware.

The rule is simple: package-local code should prefer `log.Debug()`, `log.Info()`, and the other wrapper methods unless it has a measured reason not to.

### A restrictive global zerolog level can break area logging

If application code calls `zerolog.SetGlobalLevel(zerolog.InfoLevel)` after Glazed initializes logging, `trace` area loggers can be suppressed. That is why the documentation now treats the global level as an emergency clamp. Normal filtering belongs to the conventional global logger's own level and logcopter's per-area child levels.

### Strict areas depend on registration timing

Strict area validation compares configured areas to known areas. Known areas are registered when packages call `logcopter.Package` or `logcopter.For`. Generated package variables do this at package initialization time, but only for packages that are imported. If a configured area belongs to a plugin or package that is not loaded yet, strict validation can reject it.

This is not a bug; it is the tradeoff of typo detection. Applications with dynamic plugins may want warning mode instead of strict mode until all areas are registered.

### Local module replace before release

During workspace development, Glazed uses a local dependency on logcopter. Before publishing Glazed, that local relationship should become a real versioned dependency. The practical release step is: tag logcopter, update Glazed's dependency to that version, remove or replace the local `replace`, and rerun validation.

## Working rules for future changes

The project should keep these rules:

- Keep logcopter small. Runtime, generator, and Markdown docs belong there; CLI/config policy belongs in Glazed.
- Keep generated package loggers as wrappers, not raw zerolog loggers.
- Preserve invalid-reload safety: parse and validate first, swap state only after success.
- Use per-area child levels for normal filtering. Do not reintroduce a restrictive process-wide global level as the main mechanism.
- Measure before caching. The manager lookup benchmark is now the baseline.
- Treat area names as user-facing API. Once generated and documented, renaming an area is a configuration migration.

## Near-term next steps

The project is ready for review rather than more implementation churn. The next useful steps are:

1. Review the Glazed dependency strategy before release.
2. Decide whether logcopter help docs should remain workspace-discovered or be copied/embedded into release builds of Glaze.
3. Try the generator in one real application package tree and inspect the resulting area names.
4. Tag a logcopter release once the API surface is accepted.
5. Update Glazed to depend on the tagged logcopter version and rerun the cross-repository validation suite.

## The larger lesson

Logcopter is a small project, but it demonstrates a useful pattern: add structure at the boundary where humans configure systems, not by replacing the low-level tool. Zerolog already knows how to emit fast structured events. Glazed already knows how to parse CLI flags and configuration. The missing concept was the area: a stable name connecting package code to user intent.

Once that name exists, the rest of the design becomes straightforward. The generator makes the name consistent. The manager resolves the name to a level. The wrapper keeps the lookup reload-aware. Glazed merges the user's configuration sources. Each part is simple because the responsibility boundaries are clean.

## Backlinks

- [research/kb/projects/infrastructure-and-release](http://parc.yolo.scapegoat.dev/note/research/kb/projects/infrastructure-and-release)
- [research/kb/tribal/dependency-ordered-release-train](http://parc.yolo.scapegoat.dev/note/research/kb/tribal/dependency-ordered-release-train)
- [research/kb/projects/glazed](http://parc.yolo.scapegoat.dev/note/research/kb/projects/glazed)

## Sitemap

- [Home](http://parc.yolo.scapegoat.dev/)
- [Home markdown mirror](http://parc.yolo.scapegoat.dev/index.md)
- [Markdown sitemap](http://parc.yolo.scapegoat.dev/sitemap.md)
