mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 16:46:57 +08:00
* feat(logs): export usage logs from the common log page The common log page has an Export button next to Search. It asks for a time range and downloads the logs of the current view, with the page's other filters, as a CSV file in the viewer's language. Users export their own logs; administrators in the all-users view export every user's logs. Each user runs one export at a time. A second export started while one is running, from another tab or another node, is refused until the first one ends. With Redis the reservation holds across nodes and lapses within a minute if its node stops. Log Maintenance settings add two options: whether users may export (on by default) and the longest log time range in days (30 by default). Regular users can search their logs within that range at once, and every export is limited to it. A user's log search had no limit before. * fix(web): split the log maintenance page into four modules The log maintenance page ran its settings, the history log cleanup and the server log files together. It now shows four modules, each under its own heading and separated by a divider, like the performance page: log recording, log search and export, history log cleanup, and server log management. The history log cleanup no longer sits in a box of its own. The history log cleanup and the server log files are now components of their own; what they do is unchanged. * feat(logs): default the log time range to 365 days and apply it to the dashboard The longest log time range now defaults to 365 days instead of 30. Regular users had no limit on log searches before this setting, and 30 days could not cover a 31-day month. A user's own usage and traffic charts on the dashboard were held to a fixed one-month range. They now follow the same setting, so the dashboard, the log search and the export share one limit, and a range that is too long gets the same "cannot exceed N days" message.
247 lines
10 KiB
Go
247 lines
10 KiB
Go
package controller
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/model"
|
|
"github.com/QuantumNous/new-api/service"
|
|
"github.com/QuantumNous/new-api/setting/operation_setting"
|
|
|
|
"github.com/alicebob/miniredis/v2"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/go-redis/redis/v8"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// callLogHandler runs a log handler as the given user and returns the JSON
|
|
// lines of the response.
|
|
func callLogHandler(t *testing.T, handler gin.HandlerFunc, userId int, role int, query string) []map[string]any {
|
|
t.Helper()
|
|
recorder := httptest.NewRecorder()
|
|
ctx, _ := gin.CreateTestContext(recorder)
|
|
ctx.Request = httptest.NewRequest(http.MethodGet, "/api/log?"+query, nil)
|
|
ctx.Set("id", userId)
|
|
ctx.Set("role", role)
|
|
handler(ctx)
|
|
var lines []map[string]any
|
|
for line := range strings.SplitSeq(strings.TrimSpace(recorder.Body.String()), "\n") {
|
|
var value map[string]any
|
|
require.NoError(t, common.UnmarshalJsonStr(line, &value))
|
|
lines = append(lines, value)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func exportedRequestIds(lines []map[string]any) []any {
|
|
ids := make([]any, 0, len(lines))
|
|
for _, line := range lines[:len(lines)-1] {
|
|
ids = append(ids, line["request_id"])
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// seedExportLogs stores logs of two users: three in the last hour and one
|
|
// from 400 days ago.
|
|
func seedExportLogs(t *testing.T, mainDB *gorm.DB, logDB *gorm.DB, now int64) {
|
|
t.Helper()
|
|
require.NoError(t, mainDB.Create(&model.Channel{Id: 9, Name: "primary"}).Error)
|
|
logs := []model.Log{
|
|
{UserId: 1, Username: "alice", CreatedAt: now - 3600, Type: model.LogTypeConsume, ModelName: "gpt-a", ChannelId: 9, Quota: 10, RequestId: "a-1", Other: `{"admin_info":{"use_channel":["9"]}}`},
|
|
{UserId: 2, Username: "bob", CreatedAt: now - 1800, Type: model.LogTypeConsume, ModelName: "gpt-b", ChannelId: 9, Quota: 20, RequestId: "b-1"},
|
|
{UserId: 1, Username: "alice", CreatedAt: now - 60, Type: model.LogTypeError, ModelName: "gpt-a", RequestId: "a-2"},
|
|
{UserId: 1, Username: "alice", CreatedAt: now - 400*24*3600, Type: model.LogTypeConsume, ModelName: "gpt-a", RequestId: "a-old"},
|
|
}
|
|
for i := range logs {
|
|
require.NoError(t, logDB.Create(&logs[i]).Error)
|
|
}
|
|
}
|
|
|
|
func useLogExportDatabases(t *testing.T, mainDB *gorm.DB, logDB *gorm.DB, mainType common.DatabaseType, logType common.DatabaseType) {
|
|
t.Helper()
|
|
previousDB, previousLogDB := model.DB, model.LOG_DB
|
|
previousMain, previousLog := common.MainDatabaseType(), common.LogDatabaseType()
|
|
previousRedis, previousMemory := common.RedisEnabled, common.MemoryCacheEnabled
|
|
model.DB, model.LOG_DB = mainDB, logDB
|
|
common.SetDatabaseTypes(mainType, logType)
|
|
common.RedisEnabled, common.MemoryCacheEnabled = false, false
|
|
t.Cleanup(func() {
|
|
model.DB, model.LOG_DB = previousDB, previousLogDB
|
|
common.SetDatabaseTypes(previousMain, previousLog)
|
|
common.RedisEnabled, common.MemoryCacheEnabled = previousRedis, previousMemory
|
|
})
|
|
}
|
|
|
|
func TestLogExportDatabaseMatrix(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
previousMaster := common.IsMasterNode
|
|
common.IsMasterNode = true
|
|
t.Cleanup(func() { common.IsMasterNode = previousMaster })
|
|
for _, tc := range []struct {
|
|
kind, env string
|
|
typ common.DatabaseType
|
|
}{
|
|
{"sqlite", "", common.DatabaseTypeSQLite},
|
|
{"mysql", "AUDIT_MYSQL_DSN", common.DatabaseTypeMySQL},
|
|
{"postgres", "AUDIT_POSTGRES_DSN", common.DatabaseTypePostgreSQL},
|
|
{"clickhouse", "AUDIT_CLICKHOUSE_DSN", common.DatabaseTypeClickHouse},
|
|
} {
|
|
t.Run(tc.kind, func(t *testing.T) {
|
|
dsn := os.Getenv(tc.env)
|
|
if tc.env != "" && dsn == "" {
|
|
t.Skip(tc.env + " is not configured")
|
|
}
|
|
db, isolatedDSN := newAuditTestDatabase(t, tc.kind, dsn)
|
|
mainDB, mainType := db, tc.typ
|
|
if tc.kind == "clickhouse" {
|
|
mainDB, _ = newAuditTestDatabase(t, "sqlite", "")
|
|
mainType = common.DatabaseTypeSQLite
|
|
}
|
|
useLogExportDatabases(t, mainDB, db, mainType, tc.typ)
|
|
require.NoError(t, mainDB.AutoMigrate(&model.Channel{}))
|
|
if tc.kind == "clickhouse" {
|
|
t.Setenv("LOG_SQL_DSN", isolatedDSN)
|
|
require.NoError(t, model.InitLogDB())
|
|
} else {
|
|
require.NoError(t, db.AutoMigrate(&model.Log{}))
|
|
}
|
|
versionSQL := "SELECT version()"
|
|
if tc.kind == "sqlite" {
|
|
versionSQL = "SELECT sqlite_version()"
|
|
}
|
|
var version string
|
|
require.NoError(t, model.LOG_DB.Raw(versionSQL).Scan(&version).Error)
|
|
t.Logf("log database: %s %s", tc.kind, version)
|
|
now := time.Now().Unix()
|
|
seedExportLogs(t, mainDB, model.LOG_DB, now)
|
|
query := fmt.Sprintf("start_timestamp=%d&end_timestamp=%d", now-2*24*3600, now+3600)
|
|
|
|
listed := callLogHandler(t, GetUserLogs, 1, common.RoleCommonUser, query)[0]
|
|
require.Equal(t, true, listed["success"])
|
|
assert.EqualValues(t, 2, listed["data"].(map[string]any)["total"])
|
|
listed = callLogHandler(t, GetAllLogs, 3, common.RoleAdminUser, query+"&username=alice")[0]
|
|
require.Equal(t, true, listed["success"])
|
|
assert.EqualValues(t, 2, listed["data"].(map[string]any)["total"])
|
|
|
|
own := callLogHandler(t, ExportUserLogs, 1, common.RoleCommonUser, query)
|
|
require.Len(t, own, 3)
|
|
assert.Equal(t, []any{"a-2", "a-1"}, exportedRequestIds(own))
|
|
assert.Equal(t, map[string]any{"success": true, "message": "", "data": map[string]any{"count": float64(2)}}, own[2])
|
|
assert.Equal(t, float64(1), own[0]["id"], "user exports carry list positions, not row ids")
|
|
assert.Empty(t, own[1]["channel_name"])
|
|
assert.NotContains(t, own[1]["other"], "admin_info")
|
|
|
|
all := callLogHandler(t, ExportAllLogs, 3, common.RoleAdminUser, query+"&model_name=gpt-%25")
|
|
require.Len(t, all, 4)
|
|
assert.Equal(t, []any{"a-2", "b-1", "a-1"}, exportedRequestIds(all))
|
|
assert.Equal(t, "primary", all[2]["channel_name"])
|
|
assert.Contains(t, all[2]["other"], "admin_info")
|
|
assert.Equal(t, true, all[3]["success"])
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLogExportRules(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db, _ := newAuditTestDatabase(t, "sqlite", "")
|
|
useLogExportDatabases(t, db, db, common.DatabaseTypeSQLite, common.DatabaseTypeSQLite)
|
|
require.NoError(t, db.AutoMigrate(&model.Channel{}, &model.Log{}, &model.QuotaData{}))
|
|
now := time.Now().Unix()
|
|
seedExportLogs(t, db, db, now)
|
|
logSetting := operation_setting.GetLogSetting()
|
|
previousSetting := *logSetting
|
|
t.Cleanup(func() { *logSetting = previousSetting })
|
|
recent := fmt.Sprintf("start_timestamp=%d&end_timestamp=%d", now-2*24*3600, now)
|
|
tooLong := fmt.Sprintf("start_timestamp=%d&end_timestamp=%d", now-366*24*3600, now)
|
|
|
|
t.Run("time range", func(t *testing.T) {
|
|
rejected := callLogHandler(t, ExportAllLogs, 3, common.RoleAdminUser, tooLong)
|
|
require.Len(t, rejected, 1)
|
|
assert.Equal(t, false, rejected[0]["success"])
|
|
assert.Equal(t, "The time range cannot exceed {{days}} days", rejected[0]["message_key"])
|
|
assert.Equal(t, map[string]any{"days": float64(365)}, rejected[0]["message_params"])
|
|
|
|
assert.Equal(t, false, callLogHandler(t, GetUserLogs, 1, common.RoleCommonUser, tooLong)[0]["success"])
|
|
assert.Equal(t, false, callLogHandler(t, GetLogsSelfStat, 1, common.RoleCommonUser, tooLong)[0]["success"])
|
|
assert.Equal(t, true, callLogHandler(t, GetUserLogs, 3, common.RoleAdminUser, tooLong)[0]["success"], "administrators search their own logs without a limit")
|
|
|
|
// The dashboard follows the same range instead of its old one-month limit.
|
|
fortyDays := fmt.Sprintf("start_timestamp=%d&end_timestamp=%d", now-40*24*3600, now)
|
|
for _, handler := range []gin.HandlerFunc{GetUserQuotaDates, GetUserFlowQuotaDates} {
|
|
assert.Equal(t, "The time range cannot exceed {{days}} days", callLogHandler(t, handler, 1, common.RoleCommonUser, tooLong)[0]["message_key"])
|
|
assert.Equal(t, true, callLogHandler(t, handler, 1, common.RoleCommonUser, fortyDays)[0]["success"])
|
|
}
|
|
|
|
// Without a start, a user's search reaches back the whole range.
|
|
listed := callLogHandler(t, GetUserLogs, 1, common.RoleCommonUser, "")[0]
|
|
assert.Equal(t, true, listed["success"])
|
|
assert.EqualValues(t, 2, listed["data"].(map[string]any)["total"])
|
|
|
|
logSetting.MaxRangeDays = 400
|
|
assert.Equal(t, true, callLogHandler(t, GetUserLogs, 1, common.RoleCommonUser, tooLong)[0]["success"])
|
|
logSetting.MaxRangeDays = previousSetting.MaxRangeDays
|
|
})
|
|
|
|
t.Run("user export switch", func(t *testing.T) {
|
|
logSetting.UserExportEnabled = false
|
|
t.Cleanup(func() { logSetting.UserExportEnabled = previousSetting.UserExportEnabled })
|
|
disabled := callLogHandler(t, ExportUserLogs, 1, common.RoleCommonUser, recent)
|
|
require.Len(t, disabled, 1)
|
|
assert.Equal(t, "Log export is disabled", disabled[0]["message_key"])
|
|
assert.Equal(t, true, callLogHandler(t, ExportUserLogs, 3, common.RoleAdminUser, recent)[0]["success"], "administrators export their own logs even when users cannot")
|
|
})
|
|
|
|
t.Run("one export per user", func(t *testing.T) {
|
|
release, ok, err := service.AcquireLogExportLock(1)
|
|
require.NoError(t, err)
|
|
require.True(t, ok)
|
|
busy := callLogHandler(t, ExportUserLogs, 1, common.RoleCommonUser, recent)
|
|
require.Len(t, busy, 1)
|
|
assert.Equal(t, "Another log export is running. Try again after it finishes.", busy[0]["message_key"])
|
|
other := callLogHandler(t, ExportUserLogs, 2, common.RoleCommonUser, recent)
|
|
assert.Equal(t, true, other[len(other)-1]["success"], "other users are not blocked")
|
|
release()
|
|
assert.Len(t, callLogHandler(t, ExportUserLogs, 1, common.RoleCommonUser, recent), 3)
|
|
})
|
|
}
|
|
|
|
func TestAcquireLogExportLock(t *testing.T) {
|
|
for _, useRedis := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("redis=%v", useRedis), func(t *testing.T) {
|
|
previousRedis, previousClient := common.RedisEnabled, common.RDB
|
|
common.RedisEnabled = useRedis
|
|
if useRedis {
|
|
server := miniredis.RunT(t)
|
|
common.RDB = redis.NewClient(&redis.Options{Addr: server.Addr()})
|
|
require.NoError(t, common.RDB.Ping(context.Background()).Err())
|
|
}
|
|
t.Cleanup(func() { common.RedisEnabled, common.RDB = previousRedis, previousClient })
|
|
|
|
release, ok, err := service.AcquireLogExportLock(1)
|
|
require.NoError(t, err)
|
|
require.True(t, ok)
|
|
_, ok, err = service.AcquireLogExportLock(1)
|
|
require.NoError(t, err)
|
|
assert.False(t, ok, "a second export of the same user must wait")
|
|
releaseOther, ok, err := service.AcquireLogExportLock(2)
|
|
require.NoError(t, err)
|
|
assert.True(t, ok)
|
|
releaseOther()
|
|
release()
|
|
releaseAgain, ok, err := service.AcquireLogExportLock(1)
|
|
require.NoError(t, err)
|
|
assert.True(t, ok, "a finished export frees the user")
|
|
releaseAgain()
|
|
})
|
|
}
|
|
}
|