Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
53 changes: 53 additions & 0 deletions ffi/firewood.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ type config struct {
readCacheStrategy CacheStrategy
// rootStoreDir defines a path to store all historical roots on disk.
rootStoreDir string
// logPath is the file path where logs will be written.
// If empty, logging is disabled.
logPath string
// logFilter is the RUST_LOG format filter string for logging.
// If empty and logPath is set, env_logger defaults will be used.
logFilter string
}

func defaultConfig() *config {
Expand Down Expand Up @@ -165,6 +171,35 @@ func WithRootStoreDir(dir string) Option {
}
}

// WithLogPath sets the file path where logs will be written.
// Logging is global per-process and can only be initialized once. If logging
// is already initialized (e.g., by a previous call to New with WithLogPath),
// subsequent calls will fail with an error.
//
// The logger is initialized before opening the database. If database opening fails,
// the logger remains initialized and subsequent New calls with WithLogPath will fail.
//
// Use "/dev/stdout" to write logs to standard output.
// Default: empty string (logging disabled)
func WithLogPath(path string) Option {
return func(c *config) {
c.logPath = path
}
}

// WithLogFilter sets the filter string for logging using RUST_LOG format.
// Common usage: "info" to show info-level and above logs.
// See env_logger documentation for full RUST_LOG format: https://docs.rs/env_logger
//
// This option only takes effect when WithLogPath is also set.
// If empty and WithLogPath is set, env_logger defaults will be used.
// Default: empty string
func WithLogFilter(filter string) Option {
return func(c *config) {
c.logFilter = filter
}
}

// A CacheStrategy represents the caching strategy used by a [Database].
type CacheStrategy uint8

Expand Down Expand Up @@ -210,6 +245,24 @@ func New(filePath string, opts ...Option) (*Database, error) {
return nil, fmt.Errorf("free list cache entries must be >= 1, got %d", conf.freeListCacheEntries)
}

// Initialize logging if logPath is set.
// Logging is global per-process and must be initialized before opening the database.
// If initialization fails, return error immediately without attempting to open database.
// If database opening subsequently fails, the logger remains initialized.
if conf.logPath != "" {
var pinner runtime.Pinner
defer pinner.Unpin()

logArgs := C.struct_LogArgs{
path: newBorrowedBytes([]byte(conf.logPath), &pinner),
filter_level: newBorrowedBytes([]byte(conf.logFilter), &pinner),
}

if err := getErrorFromVoidResult(C.fwd_start_logs(logArgs)); err != nil {
return nil, fmt.Errorf("failed to initialize logging: %w", err)
}
}

var pinner runtime.Pinner
defer pinner.Unpin()

Expand Down
16 changes: 11 additions & 5 deletions ffi/firewood.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

95 changes: 95 additions & 0 deletions ffi/firewood_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1797,3 +1797,98 @@
r.Contains(proposalDump, string(val), "proposal dump should contain value: %s", string(val))
}
}

// TestLogging tests the WithLogPath and WithLogFilter options.
// This test expects that no other tests in the package initialize logging.
// Tests are run in order: error cases first, then success case, then logger-already-initialized error.
func TestLogging(t *testing.T) {
ctx := t.Context()

// Test 1: Empty log path should not initialize logging (no error)
t.Run("EmptyLogPath", func(t *testing.T) {
r := require.New(t)
dbPath := filepath.Join(t.TempDir(), "test.db")
db, err := New(dbPath,
WithTruncate(true),
WithLogPath(""),
)
// Empty path means logging is disabled, should succeed
r.NoError(err)
r.NotNil(db)
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)

Check failure on line 1819 in ffi/firewood_test.go

View workflow job for this annotation

GitHub Actions / ffi (macos-latest)

context.Background() could be replaced by t.Context() in TestLogging (usetesting)
defer cancel()
r.NoError(db.Close(ctx))
}()
})

// Test 2: Invalid log path should fail
t.Run("InvalidLogPath", func(t *testing.T) {
r := require.New(t)
dbPath := filepath.Join(t.TempDir(), "test.db")
// Use a path that cannot be created (e.g., parent is a file)
invalidLogDir := filepath.Join(t.TempDir(), "file_not_dir")
r.NoError(os.WriteFile(invalidLogDir, []byte("not a directory"), 0o644))
invalidLogPath := filepath.Join(invalidLogDir, "subdir", "test.log")

Check failure on line 1833 in ffi/firewood_test.go

View workflow job for this annotation

GitHub Actions / ffi (macos-latest)

File is not properly formatted (gofumpt)

Check failure on line 1833 in ffi/firewood_test.go

View workflow job for this annotation

GitHub Actions / ffi (macos-latest)

File is not properly formatted (gofmt)

Check failure on line 1833 in ffi/firewood_test.go

View workflow job for this annotation

GitHub Actions / ffi (macos-latest)

File is not properly formatted (gci)
db, err := New(dbPath,
WithTruncate(true),
WithLogPath(invalidLogPath),
WithLogFilter("trace"),
)
r.Error(err)
r.Nil(db)
r.Contains(err.Error(), "failed to initialize logging")
})

// Test 3: Success case - valid log path with trace filter
t.Run("ValidLogging", func(t *testing.T) {
r := require.New(t)
dbPath := filepath.Join(t.TempDir(), "test.db")
logPath := filepath.Join(t.TempDir(), "firewood.log")

db, err := New(dbPath,
WithTruncate(true),
WithLogPath(logPath),
WithLogFilter("trace"),
)
r.NoError(err)
r.NotNil(db)
defer func() {
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
r.NoError(db.Close(ctx))
}()

// Perform some operations to generate logs
keys := [][]byte{[]byte("key1")}
vals := [][]byte{[]byte("value1")}
_, err = db.Update(keys, vals)
r.NoError(err)

// Verify log file was created and contains trace logs
logContents, err := os.ReadFile(logPath)
r.NoError(err)
r.NotEmpty(logContents, "log file should contain trace logs from database opening")

// Verify the log contains our trace message from opening
r.Contains(string(logContents), "Opening Firewood database")
})

// Test 4: Logger already initialized error
t.Run("LoggerAlreadyInitialized", func(t *testing.T) {
r := require.New(t)
dbPath := filepath.Join(t.TempDir(), "test2.db")
logPath := filepath.Join(t.TempDir(), "firewood2.log")

db, err := New(dbPath,
WithTruncate(true),
WithLogPath(logPath),
WithLogFilter("info"),
)
r.Error(err)
r.Nil(db)
r.Contains(err.Error(), "failed to initialize logging")
r.Contains(err.Error(), "attempted to set a logger after the logging system was already initialized")
})
}
22 changes: 0 additions & 22 deletions ffi/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ package ffi
import "C"

import (
"runtime"
"strings"

"github.com/prometheus/client_golang/prometheus"
Expand Down Expand Up @@ -73,24 +72,3 @@ func GatherMetrics() (string, error) {

return string(bytes), nil
}

// LogConfig configures logs for this process.
type LogConfig struct {
Path string
FilterLevel string
}

// Starts global logs.
// This function only needs to be called once.
// An error is returned if this method is called a second time.
func StartLogs(config *LogConfig) error {
var pinner runtime.Pinner
defer pinner.Unpin()

args := C.struct_LogArgs{
path: newBorrowedBytes([]byte(config.Path), &pinner),
filter_level: newBorrowedBytes([]byte(config.FilterLevel), &pinner),
}

return getErrorFromVoidResult(C.fwd_start_logs(args))
}
21 changes: 0 additions & 21 deletions ffi/metrics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"testing"
"time"

Expand All @@ -24,25 +22,13 @@
ctx := t.Context()

// test params
var (

Check failure on line 25 in ffi/metrics_test.go

View workflow job for this annotation

GitHub Actions / ffi (macos-latest)

File is not properly formatted (gofumpt)
logPath = filepath.Join(t.TempDir(), "firewood.log")
metricsPort = uint16(3000)
)

db := newTestDatabase(t)
r.NoError(StartMetricsWithExporter(metricsPort))

logConfig := &LogConfig{
Path: logPath,
FilterLevel: "trace",
}

var logsDisabled bool
if err := StartLogs(logConfig); err != nil {
r.Contains(err.Error(), "Logging is not available")
logsDisabled = true
}

// Populate DB
keys, vals := kvForTest(10)
_, err := db.Update(keys, vals)
Expand Down Expand Up @@ -93,11 +79,4 @@
r.NotNil(d)
r.Equal(v, *d.Type)
}

if !logsDisabled {
// logs should be non-empty if logging with trace filter level
f, err := os.ReadFile(logPath)
r.NoError(err)
r.NotEmpty(f)
}
}
8 changes: 6 additions & 2 deletions ffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -659,14 +659,18 @@ pub unsafe extern "C" fn fwd_open_db(args: DatabaseHandleArgs) -> HandleResult {

/// Start logs for this process.
///
/// Logging is global per-process and can only be initialized once. Subsequent calls
/// will return an error.
///
/// # Arguments
///
/// See [`LogArgs`].
///
/// # Returns
///
/// - [`VoidResult::Ok`] if the recorder was initialized.
/// - [`VoidResult::Err`] if an error occurs during initialization.
/// - [`VoidResult::Ok`] if the logger was initialized.
/// - [`VoidResult::Err`] if an error occurs during initialization (e.g., invalid path,
/// invalid filter, or logger already initialized).
///
/// # Safety
///
Expand Down
Loading
Loading