Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,5 @@ go.work.sum
.env

# Editor/IDE
# .idea/
# .vscode/
.idea/
.vscode/
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,12 @@ task license-fix # Add missing license headers

| Package | Purpose |
|---------|---------|
| `cel` | Generic CEL expression compilation and evaluation (Alpha) |
| `env` | Environment variable abstraction with `Reader` interface for testable code |
| `httperr` | Wrap errors with HTTP status codes; use `WithCode()`, `Code()`, `New()` |
| `logging` | Pre-configured `*slog.Logger` factory with consistent ToolHive defaults (Alpha) |
| `oci/skills` | OCI artifact types, media types, and registry operations for ToolHive skills (Alpha) |
| `recovery` | HTTP panic recovery middleware (Beta) |
| `validation/http` | RFC 7230/8707 compliant HTTP header and URI validation |
| `validation/group` | Group name validation (lowercase alphanumeric, underscore, dash, space) |

Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,19 @@ The ToolHive ecosystem spans multiple Go repositories, and several of these proj
- **Tested and documented**: All packages meet minimum quality standards before inclusion
- **Independent versioning**: Evolves on its own release cadence, decoupled from `toolhive` releases

## Available Packages

| Package | Stability | Description |
|---------|-----------|-------------|
| `cel` | Alpha | Generic CEL expression compilation and evaluation |
| `env` | Stable | Environment variable abstraction with `Reader` interface |
| `httperr` | Stable | Wrap errors with HTTP status codes |
| `logging` | Alpha | Pre-configured `*slog.Logger` factory with consistent ToolHive defaults |
| `oci/skills` | Alpha | OCI artifact types, media types, and registry operations for skills |
| `recovery` | Beta | HTTP panic recovery middleware |
| `validation/http` | Stable | RFC 7230/8707 compliant HTTP header and URI validation |
| `validation/group` | Stable | Group name validation |

## Package Stability Levels

Each package is marked with a stability level:
Expand Down
57 changes: 57 additions & 0 deletions logging/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

/*
Package logging provides a pre-configured [log/slog.Logger] factory with
consistent defaults for the ToolHive ecosystem.

All ToolHive projects share the same timestamp format, output destination,
and handler configuration. This package encapsulates those choices so that
each project does not need to replicate them.

# Defaults

- Format: JSON ([FormatJSON]) via [log/slog.JSONHandler]
- Level: INFO ([log/slog.LevelInfo])
- Output: [os.Stderr]
- Timestamps: [time.RFC3339]

# Basic Usage

Create a logger with default settings:

logger := logging.New()
logger.Info("server started", "port", 8080)

# Configuration

Use functional options to customize the logger:

logger := logging.New(
logging.WithFormat(logging.FormatText),
logging.WithLevel(slog.LevelDebug),
)

# Dynamic Level Changes

Pass a [log/slog.LevelVar] to change the level at runtime:

var lvl slog.LevelVar
logger := logging.New(logging.WithLevel(&lvl))
lvl.Set(slog.LevelDebug) // takes effect immediately

# Testing

Inject a buffer to capture log output in tests:

var buf bytes.Buffer
logger := logging.New(logging.WithOutput(&buf))
logger.Info("test message")
// inspect buf.String()

# Stability

This package is Alpha stability. The API may change without notice.
See the toolhive-core README for stability level definitions.
*/
package logging
111 changes: 111 additions & 0 deletions logging/logging.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package logging

import (
"io"
"log/slog"
"os"
"time"
)

// Format represents the log output format.
type Format int

const (
// FormatJSON produces JSON-formatted log output using [log/slog.JSONHandler].
// This is the default format, suitable for production environments.
FormatJSON Format = iota

// FormatText produces human-readable text output using [log/slog.TextHandler].
// This is suitable for local development.
FormatText
)

// config holds the resolved configuration for creating a logger.
type config struct {
format Format
level slog.Leveler
output io.Writer
}

// Option configures the logger created by [New].
type Option func(*config)

// WithFormat sets the output format (JSON or Text).
// The default is [FormatJSON].
func WithFormat(f Format) Option {
return func(c *config) {
c.format = f
}
}

// WithLevel sets the minimum log level.
// The default is [log/slog.LevelInfo].
//
// Accepts any [log/slog.Leveler], including [*log/slog.LevelVar] for
// dynamic level changes:
//
// var lvl slog.LevelVar
// lvl.Set(slog.LevelDebug)
// logger := logging.New(logging.WithLevel(&lvl))
func WithLevel(l slog.Leveler) Option {
return func(c *config) {
c.level = l
}
}

// WithOutput sets the destination writer for log output.
// The default is [os.Stderr].
func WithOutput(w io.Writer) Option {
return func(c *config) {
c.output = w
}
}

// New creates a pre-configured [*log/slog.Logger] with consistent defaults
// used across the ToolHive ecosystem.
//
// Defaults:
// - Format: JSON ([FormatJSON])
// - Level: INFO ([log/slog.LevelInfo])
// - Output: [os.Stderr]
// - Timestamps: [time.RFC3339]
func New(opts ...Option) *slog.Logger {
cfg := &config{
format: FormatJSON,
level: slog.LevelInfo,
output: os.Stderr,
}

for _, opt := range opts {
opt(cfg)
}

handlerOpts := &slog.HandlerOptions{
Level: cfg.level,
ReplaceAttr: replaceAttr,
}

var handler slog.Handler
switch cfg.format {
case FormatText:
handler = slog.NewTextHandler(cfg.output, handlerOpts)
case FormatJSON:
handler = slog.NewJSONHandler(cfg.output, handlerOpts)
}

return slog.New(handler)
}

// replaceAttr formats the time attribute to RFC3339.
// All other attributes are passed through unchanged.
func replaceAttr(_ []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
if t, ok := a.Value.Any().(time.Time); ok {
a.Value = slog.StringValue(t.Format(time.RFC3339))
}
}
return a
}
Loading
Loading