# 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
257 lines
6.1 KiB
Go
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)
|
|
}
|