Skip to content

Logging

Comby uses slog as the foundation for its logging system. Users are free to utilize the provided logging implementation or integrate their own. The primary advantage of the existing implementation is that it is tailored to comby’s specific requirements.

Additionally, all log entries are accessible through the Admin Dashboard, providing an intuitive interface for monitoring and troubleshooting. This feature simplifies debugging by consolidating relevant information in one convenient location.

Logging in comby consists of two decoupled sides:

  • Write side: slog handlers (e.g. SQLiteHandler, MemoryHandler) persist log entries created via comby.Logger.
  • Read side: the LogStore interface retrieves persisted log entries (e.g. for the Admin Dashboard). It is read-only by design and has no write methods.

For SQLite, both sides share the logs.db file. For the in-memory variant, both sides share the same store instance (see In-Memory LogStore).

Default Logger

The default logger comby.Logger logs both to the console and to a local SQLite database. The SQLite database is created as a file named logs.db. Log entries can be viewed directly through the Admin interface. The following details are logged:

  • InstanceId: The ID of the application instance that created the log entry (default is 1).
  • TenantUuid: The UUID of the tenant that created the log entry.
  • Level: The log level (e.g., INFO, ERROR, DEBUG).
  • Pkg: The name of the package that created the log entry.
  • Message: The message being logged.
  • Time: The timestamp when the message was logged.
  • Fields: Additional fields included in the log entry.

comby’s Default Logger is fully compatible with slog and can be used directly within it. This allows users to seamlessly integrate the default logging implementation into their existing slog-based workflows.

go
import "log/slog"
...
slog.Info("This is an info message")

Alternatively, the Default Logger can be used directly without additional configuration:

go
import "github.com/gradientzero/comby/v3"
...
comby.Logger.Info("This is an info message")

Attribute Logging

The Default Logger follows the conventions of the slog package, including the use of log levels (INFO, ERROR, DEBUG) and structured logging. This ensures consistency with existing logging practices and simplifies the integration of comby’s logging system into various workflows.

All attributes included in a log message are always logged and stored in the database by default. These attributes can be viewed in the database and are accessible through the Admin Dashboard. The dashboard also provides filtering and sorting options based on instances, tenants, and packages.

To enable these filtering and sorting capabilities, each log entry must include the following attributes:

  • instanceId: The ID of the application instance that created the log entry (default is 1).
  • tenantUuid: The UUID of the tenant that created the log entry.
  • pkg: The name of the package that created the log entry.
go
slog.Error("my message", "instanceId", 3, "tenantUuid", "tenant123", "pkg", "app1", "anyOther", "anyValue")

or if you prefere to use a more technical form:

go
slog.Error("my message", 
    comby.LOG_INSTANCE_ID_KEY, 3, 
    comby.LOG_TENANT_UUID_KEY, "tenant123", 
    comby.LOG_PKG_KEY, "app1", 
    "anyOther", "anyValue",
)

It is generally better to create a custom logger for your application that automatically includes the required attributes in every log entry.

go
var myLogger = slog.With(
    "pkg", "app1/pkg1/sub2",
    "other", "value",
)
...
myLogger.Error("This is an slog.Error message with custom logger")
// pkg and other are automatically included in the log entry

The attributes instanceId and tenantUuid are missing now. These can either be added manually or automatically by using a context.Context that injects the attributes - if they are available in the context. This approach is used within comby itself when processing commands or events.

go
// context values set within methods...
ctx = context.WithValue(ctx, comby.LOG_INSTANCE_ID_CTX_KEY, 3)
ctx = context.WithValue(ctx, comby.LOG_TENANT_UUID_CTX_KEY, "tenant123")
ctx = context.WithValue(ctx, comby.LOG_PKG_CTX_KEY, "myPkg/123")
...
slog.ErrorContext(ctx, "Msg")

Whichever approach you take, ultimately the values from the context are also converted into slog's fields.

Environment Variables

Comby supports the use of environment variables to configure various aspects of the Logging system. The following environment variables are available:

  • COMBY_LOG_LEVEL: Sets the log level for the Default Logger. The default value is INFO. Options include DEBUG, INFO, WARN, and ERROR.
  • COMBY_LOG_SQLITE_ENABLED: Enables or disables logging to internal database (SQlite). The default value is true. If disabled, logs are only written to the console.
  • COMBY_LOG_SQLITE_PATH: Specifies the path to the log database file (SQlite). The default value is logs.db. If the file does not exist, it will be created automatically.

Note: If COMBY_LOG_SQLITE_ENABLED is set to false, the COMBY_LOG_SQLITE_PATH variable is ignored. Otherwise, the specified path is used to create the log database file. The internal SQLiteLogger writes logs non-blockingly, which means not all logs are immediately visible in the database. This is a trade-off between performance and consistency. Incoming log entries are first stored in an internal channel and then processed sequentially and written to the database. This ensures that logging does not block the application's flow.

In-Memory LogStore

Comby provides an in-memory implementation of the LogStore interface. Since version 3.1.2 it is the default LogStore used by NewFacade - no logs.db file is required to read logs. The in-memory store keeps log entries in a ring buffer with a configurable limit (default: 10,000 entries). When the limit is exceeded, the oldest entries are evicted.

go
import "github.com/gradientzero/comby/v3"
// ...
logStore := comby.NewLogStoreMemory(
    // optional: configure the ring buffer limit
    comby.LogStoreOptionWithAttribute(comby.LOG_STORE_MEMORY_MAX_ENTRIES_KEY, 50000),
)

Because there is no shared database file, the write side must be connected explicitly: the in-memory store implements the additional LogStoreWriter interface (an Add method), which the MemoryHandler uses to write log entries directly into the store. To make logs written via comby.Logger (slog) visible in the in-memory LogStore, register the MemoryHandler:

go
import (
    "log/slog"
    "os"

    "github.com/gradientzero/comby/v3"
)
// ...
// create the in-memory log store (read side)
logStore := comby.NewLogStoreMemory()

// create the memory logger and handler (write side)
memoryLogger, _ := comby.NewMemoryLogger(logStore)
memoryHandler := comby.NewMemoryHandler(memoryLogger)

// combine with console logging and replace the default logger
stdoutHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: comby.LogLevel})
comby.Logger = slog.New(comby.NewSlogMultiHandler(stdoutHandler, memoryHandler))

// pass the same store instance to the facade
fc, _ := comby.NewFacade(
    comby.FacadeWithLogStore(logStore),
)

INFO

Without a registered MemoryHandler, the in-memory LogStore works but stays empty - the LogStore interface itself is read-only. This is intentional for the facade default: it guarantees that Get, List and Info work without requiring a SQLite file. The SQLite write side (COMBY_LOG_SQLITE_ENABLED) remains the default for comby.Logger and is unaffected by the facade's LogStore choice.

Unlike the SQLiteLogger, the MemoryLogger writes synchronously - there is no internal queue, since no I/O is involved.

Custom LogStore

Comby also provides a SQLite implementation of the LogStore interface (NewLogStoreSQLite) that retrieves logs from the local SQLite database written by the SQLite write side - if COMBY_LOG_SQLITE_ENABLED was not disabled. Additionally, users can create their own custom LogStore implementation to retrieve logs from a different location. This allows for greater flexibility and customization based on specific requirements.

go
type LogStore interface {
	// Init initializes the log store with the provided options.
	Init(ctx context.Context, opts ...LogStoreOption) error

	// Get retrieves an log by its unique identifier.
	Get(ctx context.Context, opts ...LogStoreGetOption) (*LogStoreModel, error)

	// List retrieves a list of logs based on the provided options.
	List(ctx context.Context, opts ...LogStoreListOption) ([]*LogStoreModel, int64, error)

	// UniqueList retrieves a list of unique values based on a specific field.
	UniqueList(ctx context.Context, opts ...LogStoreUniqueListOption) ([]string, int64, error)
	
	// Delete removes an log by its unique identifier.
	Delete(ctx context.Context, opts ...LogStoreDeleteOption) error

	// Total returns the total number of logs in the store.
	Total(ctx context.Context) int64

	// Close closes the log store connection.
	Close(ctx context.Context) error

	// Options returns the configuration options of the log store.
	Options() LogStoreOptions

	// String returns a string representation of the log store.
	String() string

	// Info provides detailed information about the log store.
	Info(ctx context.Context) (*LogStoreInfoModel, error)

	// Reset clears all logs from the store.
	Reset(ctx context.Context) error
}

The user could simply redirect console output to an external service and implement a custom LogStore that retrieves logs from the service and makes them available for viewing in the Comby Admin Dashboard. Then, the user could disable the internal SQLite logging by setting COMBY_LOG_SQLITE_ENABLED to false and pass the custom LogStore to the facade as an option.

go
import "github.com/gradientzero/comby/v3"
// ...
customLogStore := MyCustomLogStore()
fc, _ := comby.NewFacade(
    comby.FacadeWithLogStore(customLogStore),
)