diff options
Diffstat (limited to 'internal/logging')
| -rw-r--r-- | internal/logging/logging.go | 259 | ||||
| -rw-r--r-- | internal/logging/manager.go | 48 | ||||
| -rw-r--r-- | internal/logging/service.go | 167 | ||||
| -rw-r--r-- | internal/logging/writer.go | 53 |
4 files changed, 245 insertions, 282 deletions
diff --git a/internal/logging/logging.go b/internal/logging/logging.go index af14a0ded..d669d485d 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -1,51 +1,282 @@ package logging import ( + "bytes" + "context" + "database/sql" + "encoding/json" "fmt" + "io" "log/slog" "os" "runtime/debug" + "strings" + "sync" "time" - "github.com/opencode-ai/opencode/internal/status" + "github.com/go-logfmt/logfmt" + "github.com/google/uuid" + "github.com/opencode-ai/opencode/internal/db" + "github.com/opencode-ai/opencode/internal/pubsub" + // "github.com/opencode-ai/opencode/internal/status" ) +type Log struct { + ID string + SessionID string + Timestamp int64 + Level string + Message string + Attributes map[string]string + CreatedAt int64 +} + +const ( + EventLogCreated pubsub.EventType = "log_created" +) + +type Service interface { + pubsub.Subscriber[Log] + + Create(ctx context.Context, log Log) error + ListBySession(ctx context.Context, sessionID string) ([]Log, error) + ListAll(ctx context.Context, limit int) ([]Log, error) +} + +type service struct { + db *db.Queries + broker *pubsub.Broker[Log] + mu sync.RWMutex +} + +var globalLoggingService *service + +func InitService(dbConn *sql.DB) error { + if globalLoggingService != nil { + return fmt.Errorf("logging service already initialized") + } + queries := db.New(dbConn) + broker := pubsub.NewBroker[Log]() + + globalLoggingService = &service{ + db: queries, + broker: broker, + } + return nil +} + +func GetService() Service { + if globalLoggingService == nil { + panic("logging service not initialized. Call logging.InitService() first.") + } + return globalLoggingService +} + +func (s *service) Create(ctx context.Context, log Log) error { + s.mu.Lock() + defer s.mu.Unlock() + + if log.ID == "" { + log.ID = uuid.New().String() + } + if log.Timestamp == 0 { + log.Timestamp = time.Now().UnixMilli() + } + if log.CreatedAt == 0 { + log.CreatedAt = time.Now().UnixMilli() + } + if log.Level == "" { + log.Level = "info" + } + + var attributesJSON sql.NullString + if len(log.Attributes) > 0 { + attributesBytes, err := json.Marshal(log.Attributes) + if err != nil { + return fmt.Errorf("failed to marshal log attributes: %w", err) + } + attributesJSON = sql.NullString{String: string(attributesBytes), Valid: true} + } + + err := s.db.CreateLog(ctx, db.CreateLogParams{ + ID: log.ID, + SessionID: sql.NullString{String: log.SessionID, Valid: log.SessionID != ""}, + Timestamp: log.Timestamp / 1000, + Level: log.Level, + Message: log.Message, + Attributes: attributesJSON, + CreatedAt: log.CreatedAt / 1000, + }) + if err != nil { + return fmt.Errorf("db.CreateLog: %w", err) + } + + s.broker.Publish(EventLogCreated, log) + return nil +} + +func (s *service) ListBySession(ctx context.Context, sessionID string) ([]Log, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + dbLogs, err := s.db.ListLogsBySession(ctx, sql.NullString{String: sessionID, Valid: true}) + if err != nil { + return nil, fmt.Errorf("db.ListLogsBySession: %w", err) + } + return s.fromDBItems(dbLogs) +} + +func (s *service) ListAll(ctx context.Context, limit int) ([]Log, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + dbLogs, err := s.db.ListAllLogs(ctx, int64(limit)) + if err != nil { + return nil, fmt.Errorf("db.ListAllLogs: %w", err) + } + return s.fromDBItems(dbLogs) +} + +func (s *service) Subscribe(ctx context.Context) <-chan pubsub.Event[Log] { + return s.broker.Subscribe(ctx) +} + +func (s *service) fromDBItems(items []db.Log) ([]Log, error) { + logs := make([]Log, len(items)) + for i, item := range items { + log := Log{ + ID: item.ID, + SessionID: item.SessionID.String, + Timestamp: item.Timestamp * 1000, + Level: item.Level, + Message: item.Message, + CreatedAt: item.CreatedAt * 1000, + } + if item.Attributes.Valid && item.Attributes.String != "" { + if err := json.Unmarshal([]byte(item.Attributes.String), &log.Attributes); err != nil { + slog.Error("Failed to unmarshal log attributes", "log_id", item.ID, "error", err) + log.Attributes = make(map[string]string) + } + } else { + log.Attributes = make(map[string]string) + } + logs[i] = log + } + return logs, nil +} + +func Create(ctx context.Context, log Log) error { + return GetService().Create(ctx, log) +} + +func ListBySession(ctx context.Context, sessionID string) ([]Log, error) { + return GetService().ListBySession(ctx, sessionID) +} + +func ListAll(ctx context.Context, limit int) ([]Log, error) { + return GetService().ListAll(ctx, limit) +} + +func SubscribeToEvents(ctx context.Context) <-chan pubsub.Event[Log] { + return GetService().Subscribe(ctx) +} + +type slogWriter struct{} + +func (sw *slogWriter) Write(p []byte) (n int, err error) { + // Example: time=2024-05-09T12:34:56.789-05:00 level=INFO msg="User request" session=xyz foo=bar + d := logfmt.NewDecoder(bytes.NewReader(p)) + for d.ScanRecord() { + logEntry := Log{ + Attributes: make(map[string]string), + } + hasTimestamp := false + + for d.ScanKeyval() { + key := string(d.Key()) + value := string(d.Value()) + + switch key { + case "time": + parsedTime, timeErr := time.Parse(time.RFC3339Nano, value) + if timeErr != nil { + parsedTime, timeErr = time.Parse(time.RFC3339, value) + if timeErr != nil { + slog.Error("Failed to parse time in slog writer", "value", value, "error", timeErr) + logEntry.Timestamp = time.Now().UnixMilli() + hasTimestamp = true + continue + } + } + logEntry.Timestamp = parsedTime.UnixMilli() + hasTimestamp = true + case "level": + logEntry.Level = strings.ToLower(value) + case "msg", "message": + logEntry.Message = value + case "session_id", "session", "sid": + logEntry.SessionID = value + default: + logEntry.Attributes[key] = value + } + } + if d.Err() != nil { + return len(p), fmt.Errorf("logfmt.ScanRecord: %w", d.Err()) + } + + if !hasTimestamp { + logEntry.Timestamp = time.Now().UnixMilli() + } + + // Create log entry via the service (non-blocking or handle error appropriately) + // Using context.Background() as this is a low-level logging write. + go func(le Log) { // Run in a goroutine to avoid blocking slog + if err := Create(context.Background(), le); err != nil { + // Log internal error using a more primitive logger to avoid loops + fmt.Fprintf(os.Stderr, "ERROR [logging.slogWriter]: failed to persist log: %v\n", err) + } + }(logEntry) + } + if d.Err() != nil { + return len(p), fmt.Errorf("logfmt.ScanRecord final: %w", d.Err()) + } + return len(p), nil +} + +func NewSlogWriter() io.Writer { + return &slogWriter{} +} + // RecoverPanic is a common function to handle panics gracefully. // It logs the error, creates a panic log file with stack trace, -// and executes an optional cleanup function before returning. +// and executes an optional cleanup function. func RecoverPanic(name string, cleanup func()) { if r := recover(); r != nil { - // Log the panic errorMsg := fmt.Sprintf("Panic in %s: %v", name, r) + // Use slog directly here, as our service might be the one panicking. slog.Error(errorMsg) - status.Error(errorMsg) + // status.Error(errorMsg) - // Create a timestamped panic log file timestamp := time.Now().Format("20060102-150405") filename := fmt.Sprintf("opencode-panic-%s-%s.log", name, timestamp) file, err := os.Create(filename) if err != nil { - errMsg := fmt.Sprintf("Failed to create panic log: %v", err) + errMsg := fmt.Sprintf("Failed to create panic log file '%s': %v", filename, err) slog.Error(errMsg) - status.Error(errMsg) + // status.Error(errMsg) } else { defer file.Close() - - // Write panic information and stack trace fmt.Fprintf(file, "Panic in %s: %v\n\n", name, r) fmt.Fprintf(file, "Time: %s\n\n", time.Now().Format(time.RFC3339)) - fmt.Fprintf(file, "Stack Trace:\n%s\n", debug.Stack()) - + fmt.Fprintf(file, "Stack Trace:\n%s\n", string(debug.Stack())) // Capture stack trace infoMsg := fmt.Sprintf("Panic details written to %s", filename) slog.Info(infoMsg) - status.Info(infoMsg) + // status.Info(infoMsg) } - // Execute cleanup function if provided if cleanup != nil { cleanup() } } } - diff --git a/internal/logging/manager.go b/internal/logging/manager.go deleted file mode 100644 index e8e96520b..000000000 --- a/internal/logging/manager.go +++ /dev/null @@ -1,48 +0,0 @@ -package logging - -import ( - "context" - "sync" -) - -// Manager handles logging management -type Manager struct { - service Service - mu sync.RWMutex -} - -// Global instance of the logging manager -var globalManager *Manager - -// InitManager initializes the global logging manager with the provided service -func InitManager(service Service) { - globalManager = &Manager{ - service: service, - } - - // Subscribe to log events if needed - go func() { - ctx := context.Background() - _ = service.Subscribe(ctx) // Just subscribing to keep the channel open - }() -} - -// GetService returns the logging service -func GetService() Service { - if globalManager == nil { - return nil - } - - globalManager.mu.RLock() - defer globalManager.mu.RUnlock() - - return globalManager.service -} - -func Create(ctx context.Context, log Log) error { - if globalManager == nil { - return nil - } - return globalManager.service.Create(ctx, log) -} - diff --git a/internal/logging/service.go b/internal/logging/service.go deleted file mode 100644 index 8cf8039de..000000000 --- a/internal/logging/service.go +++ /dev/null @@ -1,167 +0,0 @@ -package logging - -import ( - "context" - "database/sql" - "encoding/json" - "time" - - "github.com/google/uuid" - "github.com/opencode-ai/opencode/internal/db" - "github.com/opencode-ai/opencode/internal/pubsub" -) - -// Log represents a log entry in the system -type Log struct { - ID string - SessionID string - Timestamp int64 - Level string - Message string - Attributes map[string]string - CreatedAt int64 -} - -// Service defines the interface for log operations -type Service interface { - pubsub.Suscriber[Log] - Create(ctx context.Context, log Log) error - ListBySession(ctx context.Context, sessionID string) ([]Log, error) - ListAll(ctx context.Context, limit int) ([]Log, error) -} - -// service implements the Service interface -type service struct { - *pubsub.Broker[Log] - q db.Querier -} - -// NewService creates a new logging service -func NewService(q db.Querier) Service { - broker := pubsub.NewBroker[Log]() - return &service{ - Broker: broker, - q: q, - } -} - -// Create adds a new log entry to the database -func (s *service) Create(ctx context.Context, log Log) error { - // Generate ID if not provided - if log.ID == "" { - log.ID = uuid.New().String() - } - - // Set timestamp if not provided - if log.Timestamp == 0 { - log.Timestamp = time.Now().Unix() - } - - // Set created_at if not provided - if log.CreatedAt == 0 { - log.CreatedAt = time.Now().Unix() - } - - // Convert attributes to JSON string - var attributesJSON sql.NullString - if len(log.Attributes) > 0 { - attributesBytes, err := json.Marshal(log.Attributes) - if err != nil { - return err - } - attributesJSON = sql.NullString{ - String: string(attributesBytes), - Valid: true, - } - } - - // Convert session ID to SQL nullable string - var sessionID sql.NullString - if log.SessionID != "" { - sessionID = sql.NullString{ - String: log.SessionID, - Valid: true, - } - } - - // Insert log into database - err := s.q.CreateLog(ctx, db.CreateLogParams{ - ID: log.ID, - SessionID: sessionID, - Timestamp: log.Timestamp, - Level: log.Level, - Message: log.Message, - Attributes: attributesJSON, - CreatedAt: log.CreatedAt, - }) - - if err != nil { - return err - } - - // Publish event - s.Publish(pubsub.CreatedEvent, log) - return nil -} - -// ListBySession retrieves logs for a specific session -func (s *service) ListBySession(ctx context.Context, sessionID string) ([]Log, error) { - dbLogs, err := s.q.ListLogsBySession(ctx, sql.NullString{ - String: sessionID, - Valid: true, - }) - if err != nil { - return nil, err - } - - logs := make([]Log, len(dbLogs)) - for i, dbLog := range dbLogs { - logs[i] = s.fromDBItem(dbLog) - } - return logs, nil -} - -// ListAll retrieves all logs with a limit -func (s *service) ListAll(ctx context.Context, limit int) ([]Log, error) { - dbLogs, err := s.q.ListAllLogs(ctx, int64(limit)) - if err != nil { - return nil, err - } - - logs := make([]Log, len(dbLogs)) - for i, dbLog := range dbLogs { - logs[i] = s.fromDBItem(dbLog) - } - return logs, nil -} - -// fromDBItem converts a database log item to a Log struct -func (s *service) fromDBItem(item db.Log) Log { - log := Log{ - ID: item.ID, - Timestamp: item.Timestamp, - Level: item.Level, - Message: item.Message, - CreatedAt: item.CreatedAt, - } - - // Convert session ID if valid - if item.SessionID.Valid { - log.SessionID = item.SessionID.String - } - - // Parse attributes JSON if present - if item.Attributes.Valid { - attributes := make(map[string]string) - if err := json.Unmarshal([]byte(item.Attributes.String), &attributes); err == nil { - log.Attributes = attributes - } else { - // Initialize empty map if parsing fails - log.Attributes = make(map[string]string) - } - } else { - log.Attributes = make(map[string]string) - } - - return log -} diff --git a/internal/logging/writer.go b/internal/logging/writer.go deleted file mode 100644 index 4b5bcc4fe..000000000 --- a/internal/logging/writer.go +++ /dev/null @@ -1,53 +0,0 @@ -package logging - -import ( - "bytes" - "context" - "fmt" - "strings" - "time" - - "github.com/go-logfmt/logfmt" - "github.com/opencode-ai/opencode/internal/session" -) - -type writer struct{} - -func (w *writer) Write(p []byte) (int, error) { - d := logfmt.NewDecoder(bytes.NewReader(p)) - for d.ScanRecord() { - msg := Log{} - - for d.ScanKeyval() { - switch string(d.Key()) { - case "time": - parsed, err := time.Parse(time.RFC3339, string(d.Value())) - if err != nil { - return 0, fmt.Errorf("parsing time: %w", err) - } - msg.Timestamp = parsed.UnixMilli() - case "level": - msg.Level = strings.ToLower(string(d.Value())) - case "msg": - msg.Message = string(d.Value()) - default: - if msg.Attributes == nil { - msg.Attributes = make(map[string]string) - } - msg.Attributes[string(d.Key())] = string(d.Value()) - } - } - - msg.SessionID = session.CurrentSessionID() - Create(context.Background(), msg) - } - if d.Err() != nil { - return 0, d.Err() - } - return len(p), nil -} - -func NewWriter() *writer { - w := &writer{} - return w -} |
