Files
修雨 57d753cd1d Merge remote-tracking branch 'origin/main' into codex/pr628-main-sync-20260716
# Conflicts:
#	CHANGELOG.md
#	internal/app/auth_command.go
#	internal/app/auth_command_test.go
#	internal/app/config_test.go
#	internal/app/root.go
#	internal/app/skill_setup_test.go
#	internal/app/timing_test.go
#	internal/auth/portable_store.go
#	internal/logging/logger.go
2026-07-16 13:42:38 +08:00

257 lines
6.1 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package logging provides file-based structured logging for diagnostics.
// Logs are written as JSON lines to ~/.dws/logs/dws.log with automatic
// size-based rotation. Sensitive values (tokens, secrets) are never logged.
package logging
import (
"io"
"log/slog"
"os"
"path/filepath"
"strconv"
"sync"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
const (
// logSubDir is the subdirectory under configDir for log files.
logSubDir = "logs"
// logFileName is the active log file name.
logFileName = "dws.log"
// maxLogSize is the maximum size of a single log file (5 MB).
maxLogSize = 5 * 1024 * 1024
// maxBackups is the number of rotated backup files to keep.
maxBackups = 2
)
var openLogFile = os.OpenFile
// FileLogger wraps a rotatingWriter and provides a structured slog.Logger
// that writes JSON lines to disk.
type FileLogger struct {
writer *rotatingWriter
Logger *slog.Logger
}
// Setup creates the log directory and returns a FileLogger. If directory
// creation fails, it returns a no-op logger that discards all output.
func Setup(configDir string) *FileLogger {
logDir := filepath.Join(configDir, logSubDir)
if err := os.MkdirAll(logDir, config.DirPerm); err != nil {
return newNopFileLogger()
}
logPath := filepath.Join(logDir, logFileName)
w := newRotatingWriter(logPath, maxLogSize, maxBackups)
if err := w.open(); err != nil {
return newNopFileLogger()
}
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
Level: slog.LevelDebug,
})
return &FileLogger{
writer: w,
Logger: slog.New(handler),
}
}
// Writer returns the underlying io.Writer for use with slog handlers.
// Returns io.Discard if the logger is not initialized.
func (fl *FileLogger) Writer() io.Writer {
if fl == nil || fl.writer == nil {
return io.Discard
}
return fl.writer
}
// Close flushes and permanently closes the underlying log file. Later writes
// fail with os.ErrClosed instead of reopening the file.
func (fl *FileLogger) Close() error {
if fl == nil || fl.writer == nil {
return nil
}
return fl.writer.close()
}
// NewRotatingFile opens path as a size-rotating log file (same rotation policy
// as the diagnostic logger: maxLogSize per file, maxBackups kept) and returns an
// io.WriteCloser. The caller owns the returned writer and must Close it. Used by
// the connect daemon to archive a long-running connector's stdout/stderr without
// reimplementing rotation. Returns an error if the file cannot be opened.
func NewRotatingFile(path string) (io.WriteCloser, error) {
w := newRotatingWriter(path, maxLogSize, maxBackups)
if err := w.open(); err != nil {
return nil, err
}
return w, nil
}
func newNopFileLogger() *FileLogger {
return &FileLogger{
Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)),
}
}
// rotatingWriter is a simple size-based log file rotator.
// When the active file exceeds maxSize, it renames:
//
// dws.log → dws.log.1
// dws.log.1 → dws.log.2
// dws.log.2 → (deleted)
//
// Then opens a fresh dws.log.
type rotatingWriter struct {
path string
maxSize int64
maxBackups int
mu sync.Mutex
file *os.File
written int64
closed bool
}
func newRotatingWriter(path string, maxSize int64, maxBackups int) *rotatingWriter {
return &rotatingWriter{
path: path,
maxSize: maxSize,
maxBackups: maxBackups,
}
}
func (w *rotatingWriter) open() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return os.ErrClosed
}
f, err := openLogFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm)
if err != nil {
return err
}
info, err := f.Stat()
if err != nil {
f.Close()
return err
}
w.file = f
w.written = info.Size()
return nil
}
func (w *rotatingWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return 0, os.ErrClosed
}
// Attempt to recover if file was lost (e.g. rotation failed due to full disk).
if w.file == nil {
if err := w.reopenLocked(); err != nil {
return 0, err
}
}
if w.written+int64(len(p)) > w.maxSize {
w.rotate()
}
// rotate may have failed, check again.
if w.file == nil {
return 0, os.ErrClosed
}
n, err := w.file.Write(p)
w.written += int64(n)
return n, err
}
// reopenLocked tries to reopen the log file. Caller must hold w.mu.
func (w *rotatingWriter) reopenLocked() error {
if w.closed {
return os.ErrClosed
}
f, err := openLogFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm)
if err != nil {
return err
}
info, err := f.Stat()
if err != nil {
f.Close()
return err
}
w.file = f
w.written = info.Size()
return nil
}
// Close implements io.Closer so rotatingWriter can be returned as an
// io.WriteCloser (see NewRotatingFile).
func (w *rotatingWriter) Close() error { return w.close() }
func (w *rotatingWriter) close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return nil
}
w.closed = true
if w.file == nil {
return nil
}
err := w.file.Close()
w.file = nil
return err
}
func (w *rotatingWriter) rotate() {
w.file.Close()
w.file = nil
// Shift existing backups: .2 → delete, .1 → .2, active → .1
for i := w.maxBackups; i >= 1; i-- {
src := w.backupName(i - 1)
dst := w.backupName(i)
if i == w.maxBackups {
os.Remove(dst)
}
os.Rename(src, dst)
}
f, err := openLogFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, config.FilePerm)
if err != nil {
return
}
w.file = f
w.written = 0
}
func (w *rotatingWriter) backupName(index int) string {
if index == 0 {
return w.path
}
return w.path + "." + itoa(index)
}
func itoa(i int) string {
return strconv.Itoa(i)
}