diff --git a/.gitignore b/.gitignore index aaadf73..ed20272 100644 --- a/.gitignore +++ b/.gitignore @@ -28,5 +28,5 @@ go.work.sum .env # Editor/IDE -# .idea/ -# .vscode/ +.idea/ +.vscode/ diff --git a/CLAUDE.md b/CLAUDE.md index 33d1c49..ca00c37 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) | diff --git a/README.md b/README.md index 5000a13..0dd94d8 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/logging/doc.go b/logging/doc.go new file mode 100644 index 0000000..65aee1b --- /dev/null +++ b/logging/doc.go @@ -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 diff --git a/logging/logging.go b/logging/logging.go new file mode 100644 index 0000000..ec0907c --- /dev/null +++ b/logging/logging.go @@ -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 +} diff --git a/logging/logging_test.go b/logging/logging_test.go new file mode 100644 index 0000000..d35e2a3 --- /dev/null +++ b/logging/logging_test.go @@ -0,0 +1,248 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package logging + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNew(t *testing.T) { + t.Parallel() + + t.Run("returns a non-nil logger with no options", func(t *testing.T) { + t.Parallel() + logger := New() + assert.NotNil(t, logger) + }) + + t.Run("default format is JSON with RFC3339 timestamps", func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + logger := New(WithOutput(&buf)) + + logger.Info("test message", "key", "value") + + var entry map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &entry)) + + assert.Equal(t, "INFO", entry["level"]) + assert.Equal(t, "test message", entry["msg"]) + assert.Equal(t, "value", entry["key"]) + + ts, ok := entry["time"].(string) + require.True(t, ok, "time field should be a string") + _, err := time.Parse(time.RFC3339, ts) + assert.NoError(t, err, "timestamp should be valid RFC3339") + }) + + t.Run("default level is INFO", func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + logger := New(WithOutput(&buf)) + + logger.Debug("should not appear") + assert.Empty(t, buf.String(), "DEBUG should be filtered at INFO level") + + logger.Info("should appear") + assert.NotEmpty(t, buf.String(), "INFO should be written at INFO level") + }) +} + +func TestNew_WithFormat(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + format Format + check func(t *testing.T, output string) + }{ + { + name: "JSON format produces valid JSON", + format: FormatJSON, + check: func(t *testing.T, output string) { + t.Helper() + var entry map[string]any + require.NoError(t, json.Unmarshal([]byte(output), &entry)) + assert.Equal(t, "INFO", entry["level"]) + assert.Equal(t, "hello", entry["msg"]) + }, + }, + { + name: "text format produces key=value output", + format: FormatText, + check: func(t *testing.T, output string) { + t.Helper() + assert.Contains(t, output, "level=INFO") + assert.Contains(t, output, "msg=hello") + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + logger := New(WithFormat(tc.format), WithOutput(&buf)) + + logger.Info("hello") + + tc.check(t, buf.String()) + }) + } +} + +func TestNew_WithLevel(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + level slog.Level + logLevel slog.Level + shouldWrite bool + }{ + {"debug logger writes debug", slog.LevelDebug, slog.LevelDebug, true}, + {"info logger filters debug", slog.LevelInfo, slog.LevelDebug, false}, + {"info logger writes info", slog.LevelInfo, slog.LevelInfo, true}, + {"warn logger filters info", slog.LevelWarn, slog.LevelInfo, false}, + {"warn logger writes warn", slog.LevelWarn, slog.LevelWarn, true}, + {"error logger filters warn", slog.LevelError, slog.LevelWarn, false}, + {"error logger writes error", slog.LevelError, slog.LevelError, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + logger := New(WithLevel(tc.level), WithOutput(&buf)) + + logger.Log(context.TODO(), tc.logLevel, "test") + + if tc.shouldWrite { + assert.NotEmpty(t, buf.String()) + } else { + assert.Empty(t, buf.String()) + } + }) + } +} + +func TestNew_DynamicLevel(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + var lvl slog.LevelVar + lvl.Set(slog.LevelWarn) + + logger := New(WithLevel(&lvl), WithOutput(&buf)) + + logger.Info("should not appear") + assert.Empty(t, buf.String(), "INFO should be filtered at WARN level") + + lvl.Set(slog.LevelInfo) + logger.Info("should appear") + assert.NotEmpty(t, buf.String(), "INFO should be written after level change") +} + +func TestNew_TimestampFormat(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + format Format + parse func(t *testing.T, output string) string + }{ + { + name: "JSON timestamp is RFC3339", + format: FormatJSON, + parse: func(t *testing.T, output string) string { + t.Helper() + var entry map[string]any + require.NoError(t, json.Unmarshal([]byte(output), &entry)) + ts, ok := entry["time"].(string) + require.True(t, ok) + return ts + }, + }, + { + name: "text timestamp is RFC3339", + format: FormatText, + parse: func(t *testing.T, output string) string { + t.Helper() + // slog text format: time= level=... + // Extract the time value between "time=" and the next space + const prefix = "time=" + start := len(prefix) + require.Greater(t, len(output), start) + end := start + for end < len(output) && output[end] != ' ' { + end++ + } + return output[start:end] + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + logger := New(WithFormat(tc.format), WithOutput(&buf)) + + logger.Info("test") + + ts := tc.parse(t, buf.String()) + _, err := time.Parse(time.RFC3339, ts) + assert.NoError(t, err, "timestamp %q should be valid RFC3339", ts) + }) + } +} + +func TestNew_MultipleOptions(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + logger := New( + WithFormat(FormatText), + WithLevel(slog.LevelDebug), + WithOutput(&buf), + ) + + logger.Debug("debug message") + + output := buf.String() + assert.Contains(t, output, "level=DEBUG") + assert.Contains(t, output, "msg=\"debug message\"") +} + +func TestReplaceAttr(t *testing.T) { + t.Parallel() + + t.Run("formats time attribute to RFC3339", func(t *testing.T) { + t.Parallel() + now := time.Date(2026, 2, 17, 10, 30, 0, 0, time.UTC) + attr := slog.Time(slog.TimeKey, now) + + result := replaceAttr(nil, attr) + + assert.Equal(t, slog.TimeKey, result.Key) + assert.Equal(t, "2026-02-17T10:30:00Z", result.Value.String()) + }) + + t.Run("passes non-time attributes unchanged", func(t *testing.T) { + t.Parallel() + attr := slog.String("key", "value") + + result := replaceAttr(nil, attr) + + assert.Equal(t, attr, result) + }) +}