Files
ttstd-infinite-canvas/backend/internal/app/diagnostics.go

572 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"archive/zip"
"bytes"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"time"
"yingce/backend/internal/model"
"gorm.io/gorm"
)
const (
diagnosticSchemaVersion = 1
diagnosticRedactionVersion = 1
diagnosticMaxWindow = 24 * time.Hour
diagnosticMaxClientEvents = 500
diagnosticMaxBundleBytes = 10 << 20
diagnosticMaxDescription = 1000
diagnosticMaxEventText = 4000
)
type DiagnosticClientEvent struct {
ID string `json:"id"`
Timestamp string `json:"timestamp"`
Level string `json:"level"`
Category string `json:"category"`
Code string `json:"code,omitempty"`
Message string `json:"message"`
Route string `json:"route,omitempty"`
DurationMs int64 `json:"durationMs,omitempty"`
HTTPStatus int `json:"httpStatus,omitempty"`
RequestID string `json:"requestId,omitempty"`
TraceID string `json:"traceId,omitempty"`
TaskID string `json:"taskId,omitempty"`
ProjectID string `json:"projectId,omitempty"`
CanvasID string `json:"canvasId,omitempty"`
Stack string `json:"stack,omitempty"`
}
type DiagnosticRuntime struct {
AppVersion string `json:"appVersion,omitempty"`
BuildCommit string `json:"buildCommit,omitempty"`
Browser string `json:"browser,omitempty"`
OS string `json:"os,omitempty"`
Timezone string `json:"timezone,omitempty"`
}
type DiagnosticExportRequest struct {
From string `json:"from"`
To string `json:"to"`
TaskID string `json:"taskId,omitempty"`
ProjectID string `json:"projectId,omitempty"`
Description string `json:"description,omitempty"`
Runtime DiagnosticRuntime `json:"runtime"`
ClientEvents []DiagnosticClientEvent `json:"clientEvents"`
}
type DiagnosticPreview struct {
ClientEventLimit int `json:"clientEventLimit"`
TaskCount int `json:"taskCount"`
TaskLogCount int `json:"taskLogCount"`
APICallCount int `json:"apiCallCount"`
EstimatedBytes int64 `json:"estimatedBytes"`
WillTruncate bool `json:"willTruncate"`
}
type DiagnosticBundle struct {
BundleID string
FileName string
Data []byte
}
type diagnosticWindow struct {
From time.Time
To time.Time
}
type diagnosticCollection struct {
Window diagnosticWindow
Description string
TaskID string
ProjectID string
Runtime diagnosticRuntimeRecord
ClientEvents []diagnosticClientEventRecord
Tasks []diagnosticTaskRecord
TaskLogs []diagnosticTaskLogRecord
APICalls []diagnosticAPICallRecord
Truncated bool
}
type diagnosticRuntimeRecord struct {
AppVersion string `json:"appVersion,omitempty"`
BuildCommit string `json:"buildCommit,omitempty"`
Browser string `json:"browser,omitempty"`
OS string `json:"os,omitempty"`
Timezone string `json:"timezone,omitempty"`
}
type diagnosticClientEventRecord struct {
ID string `json:"id,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
Level string `json:"level,omitempty"`
Category string `json:"category,omitempty"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Route string `json:"route,omitempty"`
DurationMs int64 `json:"durationMs,omitempty"`
HTTPStatus int `json:"httpStatus,omitempty"`
RequestID string `json:"requestId,omitempty"`
TraceID string `json:"traceId,omitempty"`
TaskID string `json:"taskId,omitempty"`
ProjectID string `json:"projectId,omitempty"`
CanvasID string `json:"canvasId,omitempty"`
Stack string `json:"stack,omitempty"`
}
type diagnosticTaskRecord struct {
ID string `json:"id"`
TraceID string `json:"traceId,omitempty"`
RequestID string `json:"requestId,omitempty"`
ProjectID string `json:"projectId,omitempty"`
Type string `json:"type,omitempty"`
Status string `json:"status,omitempty"`
Stage string `json:"stage,omitempty"`
Progress int `json:"progress,omitempty"`
Operation string `json:"operation,omitempty"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
LogicalModelID string `json:"logicalModelId,omitempty"`
ProviderRequestID string `json:"providerRequestId,omitempty"`
Error string `json:"error,omitempty"`
Attempts int `json:"attempts,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type diagnosticTaskLogRecord struct {
ID string `json:"id"`
TaskID string `json:"taskId,omitempty"`
TraceID string `json:"traceId,omitempty"`
RequestID string `json:"requestId,omitempty"`
Level string `json:"level,omitempty"`
Message string `json:"message,omitempty"`
Payload string `json:"payload,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type diagnosticAPICallRecord struct {
ID string `json:"id"`
TraceID string `json:"traceId,omitempty"`
RequestID string `json:"requestId,omitempty"`
ChannelID string `json:"channelId,omitempty"`
TaskID string `json:"taskId,omitempty"`
Source string `json:"source,omitempty"`
Capability string `json:"capability,omitempty"`
Operation string `json:"operation,omitempty"`
RequestKind string `json:"requestKind,omitempty"`
APIFormat string `json:"apiFormat,omitempty"`
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
Model string `json:"model,omitempty"`
Status string `json:"status,omitempty"`
StatusCode int `json:"statusCode,omitempty"`
DurationMs int64 `json:"durationMs,omitempty"`
PollCount int `json:"pollCount,omitempty"`
ProviderStatus string `json:"providerStatus,omitempty"`
ProviderRequestID string `json:"providerRequestId,omitempty"`
ErrorCode string `json:"errorCode,omitempty"`
Error string `json:"error,omitempty"`
StartedAt time.Time `json:"startedAt"`
CreatedAt time.Time `json:"createdAt"`
}
type diagnosticManifest struct {
SchemaVersion int `json:"schemaVersion"`
BundleID string `json:"bundleId"`
GeneratedAt time.Time `json:"generatedAt"`
AppVersion string `json:"appVersion,omitempty"`
BuildCommit string `json:"buildCommit,omitempty"`
TimeRange diagnosticTimeRange `json:"timeRange"`
TaskID string `json:"taskId,omitempty"`
ProjectID string `json:"projectId,omitempty"`
Description string `json:"description,omitempty"`
RedactionVersion int `json:"redactionVersion"`
Truncated bool `json:"truncated,omitempty"`
Counts diagnosticCounts `json:"counts"`
}
type diagnosticTimeRange struct {
From time.Time `json:"from"`
To time.Time `json:"to"`
}
type diagnosticCounts struct {
ClientEvents int `json:"clientEvents"`
Tasks int `json:"tasks"`
TaskLogs int `json:"taskLogs"`
APICalls int `json:"upstreamCalls"`
}
func (s *Service) PreviewDiagnosticBundle(userID string, req DiagnosticExportRequest) (*DiagnosticPreview, error) {
collection, err := s.collectDiagnosticData(userID, req)
if err != nil {
return nil, err
}
return &DiagnosticPreview{
ClientEventLimit: diagnosticMaxClientEvents,
TaskCount: len(collection.Tasks), TaskLogCount: len(collection.TaskLogs), APICallCount: len(collection.APICalls),
EstimatedBytes: int64(2048 + len(collection.ClientEvents)*420 + len(collection.Tasks)*620 + len(collection.TaskLogs)*700 + len(collection.APICalls)*520),
WillTruncate: collection.Truncated,
}, nil
}
func (s *Service) ExportDiagnosticBundle(userID string, req DiagnosticExportRequest) (*DiagnosticBundle, error) {
collection, err := s.collectDiagnosticData(userID, req)
if err != nil {
return nil, err
}
bundleID := "DIAG_" + strings.ToUpper(newID()[:8])
manifest := diagnosticManifest{
SchemaVersion: diagnosticSchemaVersion, BundleID: bundleID, GeneratedAt: time.Now().UTC(),
AppVersion: collection.Runtime.AppVersion, BuildCommit: collection.Runtime.BuildCommit,
TimeRange: diagnosticTimeRange{From: collection.Window.From, To: collection.Window.To},
TaskID: collection.TaskID, ProjectID: collection.ProjectID, Description: collection.Description,
RedactionVersion: diagnosticRedactionVersion, Truncated: collection.Truncated,
Counts: diagnosticCounts{ClientEvents: len(collection.ClientEvents), Tasks: len(collection.Tasks), TaskLogs: len(collection.TaskLogs), APICalls: len(collection.APICalls)},
}
brandName, brandSlug := s.appearanceIdentity()
data, err := buildDiagnosticZIP(brandName, bundleID, manifest, collection)
if err != nil {
return nil, err
}
if len(data) > diagnosticMaxBundleBytes {
return nil, BadAuthRequest("诊断包超过 10 MB,请缩短时间范围后重试")
}
fileName := fmt.Sprintf("%s-diagnostics-%s-%s.zip", brandSlug, time.Now().UTC().Format("20060102-150405"), bundleID)
return &DiagnosticBundle{BundleID: bundleID, FileName: fileName, Data: data}, nil
}
func (s *Service) collectDiagnosticData(userID string, req DiagnosticExportRequest) (*diagnosticCollection, error) {
window, err := normalizeDiagnosticWindow(req.From, req.To)
if err != nil {
return nil, err
}
taskID := strings.TrimSpace(req.TaskID)
projectID := strings.TrimSpace(req.ProjectID)
if len(taskID) > 96 || len(projectID) > 96 {
return nil, BadAuthRequest("诊断上下文无效")
}
if taskID != "" {
task, taskErr := s.repo.TaskForUser(userID, taskID)
if errors.Is(taskErr, gorm.ErrRecordNotFound) {
return nil, BadAuthRequest("任务不存在或无权访问")
}
if taskErr != nil {
return nil, taskErr
}
if projectID != "" && task.ProjectID != projectID {
return nil, BadAuthRequest("任务不属于当前项目")
}
if projectID == "" {
projectID = task.ProjectID
}
}
tasks, err := s.repo.DiagnosticTasks(userID, window.From, window.To, taskID, projectID)
if err != nil {
return nil, err
}
taskLogs, err := s.repo.DiagnosticTaskLogs(userID, window.From, window.To, taskID, projectID)
if err != nil {
return nil, err
}
apiCalls, err := s.repo.DiagnosticAPICallLogs(userID, window.From, window.To, taskID, projectID)
if err != nil {
return nil, err
}
clientEvents := req.ClientEvents
truncated := false
if len(clientEvents) > diagnosticMaxClientEvents {
clientEvents = clientEvents[len(clientEvents)-diagnosticMaxClientEvents:]
truncated = true
}
collection := &diagnosticCollection{
Window: window, Description: redactDiagnosticText(req.Description, diagnosticMaxDescription), TaskID: taskID, ProjectID: projectID, Truncated: truncated,
Runtime: diagnosticRuntimeRecord{
AppVersion: redactDiagnosticText(req.Runtime.AppVersion, 120), BuildCommit: redactDiagnosticText(req.Runtime.BuildCommit, 120),
Browser: redactDiagnosticText(req.Runtime.Browser, 240), OS: redactDiagnosticText(req.Runtime.OS, 120), Timezone: redactDiagnosticText(req.Runtime.Timezone, 80),
},
}
for _, event := range clientEvents {
collection.ClientEvents = append(collection.ClientEvents, sanitizeDiagnosticClientEvent(event))
}
for _, task := range tasks {
collection.Tasks = append(collection.Tasks, sanitizeDiagnosticTask(task))
}
for _, taskLog := range taskLogs {
collection.TaskLogs = append(collection.TaskLogs, sanitizeDiagnosticTaskLog(taskLog))
}
for _, apiCall := range apiCalls {
collection.APICalls = append(collection.APICalls, sanitizeDiagnosticAPICall(apiCall))
}
return collection, nil
}
func normalizeDiagnosticWindow(fromRaw string, toRaw string) (diagnosticWindow, error) {
now := time.Now().UTC()
from, to := now.Add(-30*time.Minute), now
var err error
if strings.TrimSpace(fromRaw) != "" {
from, err = time.Parse(time.RFC3339Nano, strings.TrimSpace(fromRaw))
if err != nil {
return diagnosticWindow{}, BadAuthRequest("诊断开始时间格式无效")
}
}
if strings.TrimSpace(toRaw) != "" {
to, err = time.Parse(time.RFC3339Nano, strings.TrimSpace(toRaw))
if err != nil {
return diagnosticWindow{}, BadAuthRequest("诊断结束时间格式无效")
}
}
from, to = from.UTC(), to.UTC()
if to.After(now) {
to = now
}
if !to.After(from) {
return diagnosticWindow{}, BadAuthRequest("诊断时间范围无效")
}
if to.Sub(from) > diagnosticMaxWindow {
return diagnosticWindow{}, BadAuthRequest("诊断时间范围不能超过 24 小时")
}
return diagnosticWindow{From: from, To: to}, nil
}
func sanitizeDiagnosticClientEvent(event DiagnosticClientEvent) diagnosticClientEventRecord {
return diagnosticClientEventRecord{
ID: sanitizeDiagnosticIdentifier(event.ID), Timestamp: redactDiagnosticText(event.Timestamp, 80), Level: redactDiagnosticText(event.Level, 24), Category: redactDiagnosticText(event.Category, 32),
Code: redactDiagnosticText(event.Code, 120), Message: redactDiagnosticText(event.Message, diagnosticMaxEventText), Route: sanitizeDiagnosticPath(event.Route),
DurationMs: boundedDiagnosticInt64(event.DurationMs, 0, 86_400_000), HTTPStatus: boundedDiagnosticInt(event.HTTPStatus, 0, 599),
RequestID: sanitizeDiagnosticIdentifier(event.RequestID), TraceID: sanitizeDiagnosticIdentifier(event.TraceID), TaskID: sanitizeDiagnosticIdentifier(event.TaskID), ProjectID: sanitizeDiagnosticIdentifier(event.ProjectID), CanvasID: sanitizeDiagnosticIdentifier(event.CanvasID),
Stack: redactDiagnosticText(event.Stack, diagnosticMaxEventText),
}
}
func sanitizeDiagnosticTask(task model.Task) diagnosticTaskRecord {
return diagnosticTaskRecord{
ID: task.ID, TraceID: sanitizeDiagnosticIdentifier(task.TraceID), RequestID: sanitizeDiagnosticIdentifier(task.RequestID), ProjectID: sanitizeDiagnosticIdentifier(task.ProjectID),
Type: redactDiagnosticText(task.Type, 80), Status: redactDiagnosticText(string(task.Status), 32), Stage: redactDiagnosticText(task.Stage, 160), Progress: boundedDiagnosticInt(task.Progress, 0, 100),
Operation: redactDiagnosticText(task.Operation, 120), Provider: redactDiagnosticText(task.Provider, 120), Model: redactDiagnosticText(task.Model, 160), LogicalModelID: sanitizeDiagnosticIdentifier(task.LogicalModelID),
ProviderRequestID: sanitizeDiagnosticIdentifier(task.ProviderRequestID), Error: redactDiagnosticText(task.Error, diagnosticMaxEventText), Attempts: boundedDiagnosticInt(task.Attempts, 0, 100),
StartedAt: task.StartedAt, CompletedAt: task.CompletedAt, CreatedAt: task.CreatedAt, UpdatedAt: task.UpdatedAt,
}
}
func sanitizeDiagnosticTaskLog(taskLog model.TaskLog) diagnosticTaskLogRecord {
return diagnosticTaskLogRecord{ID: taskLog.ID, TaskID: sanitizeDiagnosticIdentifier(taskLog.TaskID), TraceID: sanitizeDiagnosticIdentifier(taskLog.TraceID), RequestID: sanitizeDiagnosticIdentifier(taskLog.RequestID), Level: redactDiagnosticText(taskLog.Level, 24), Message: redactDiagnosticText(taskLog.Message, diagnosticMaxEventText), Payload: redactDiagnosticText(taskLog.Payload, diagnosticMaxEventText), CreatedAt: taskLog.CreatedAt}
}
func sanitizeDiagnosticAPICall(log model.ApiCallLog) diagnosticAPICallRecord {
return diagnosticAPICallRecord{
ID: log.ID, TraceID: sanitizeDiagnosticIdentifier(log.TraceID), RequestID: sanitizeDiagnosticIdentifier(log.RequestID), ChannelID: sanitizeDiagnosticIdentifier(log.ChannelID), TaskID: sanitizeDiagnosticIdentifier(log.TaskID),
Source: redactDiagnosticText(log.Source, 80), Capability: redactDiagnosticText(log.Capability, 48), Operation: redactDiagnosticText(log.Operation, 120), RequestKind: redactDiagnosticText(log.RequestKind, 48), APIFormat: redactDiagnosticText(log.APIFormat, 48),
Method: redactDiagnosticText(log.Method, 16), Path: sanitizeDiagnosticPath(log.Path), Model: redactDiagnosticText(log.Model, 160), Status: redactDiagnosticText(string(log.Status), 32), StatusCode: boundedDiagnosticInt(log.StatusCode, 0, 599),
DurationMs: boundedDiagnosticInt64(log.DurationMs, 0, 86_400_000), PollCount: boundedDiagnosticInt(log.PollCount, 0, 10000), ProviderStatus: redactDiagnosticText(log.ProviderStatus, 80),
ProviderRequestID: sanitizeDiagnosticIdentifier(log.ProviderRequestID), ErrorCode: redactDiagnosticText(log.ErrorCode, 120), Error: redactDiagnosticText(log.Error, diagnosticMaxEventText), StartedAt: log.StartedAt, CreatedAt: log.CreatedAt,
}
}
func buildDiagnosticZIP(brandName string, bundleID string, manifest diagnosticManifest, collection *diagnosticCollection) ([]byte, error) {
var buffer bytes.Buffer
archive := zip.NewWriter(&buffer)
manifestData, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return nil, err
}
if err := writeDiagnosticZipFile(archive, "manifest.json", append(manifestData, byte(10))); err != nil {
return nil, err
}
newline := string(rune(10))
readme := fmt.Sprintf("%s 用户诊断包%s%s诊断编号:%s%s时间范围:%s 至 %s%s%s该文件由用户主动导出,仅包含有限时间范围内的脱敏诊断摘要。%s", brandName, newline, newline, bundleID, newline, collection.Window.From.Format(time.RFC3339), collection.Window.To.Format(time.RFC3339), newline, newline, newline)
if err := writeDiagnosticZipFile(archive, "README.txt", []byte(readme)); err != nil {
return nil, err
}
if err := writeDiagnosticJSONL(archive, "client/events.jsonl", collection.ClientEvents); err != nil {
return nil, err
}
if err := writeDiagnosticJSONL(archive, "backend/tasks.jsonl", collection.Tasks); err != nil {
return nil, err
}
if err := writeDiagnosticJSONL(archive, "backend/task-logs.jsonl", collection.TaskLogs); err != nil {
return nil, err
}
if err := writeDiagnosticJSONL(archive, "backend/upstream-calls.jsonl", collection.APICalls); err != nil {
return nil, err
}
runtimeData, err := json.MarshalIndent(collection.Runtime, "", " ")
if err != nil {
return nil, err
}
if err := writeDiagnosticZipFile(archive, "context/runtime.json", append(runtimeData, byte(10))); err != nil {
return nil, err
}
if err := archive.Close(); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func writeDiagnosticJSONL[T any](archive *zip.Writer, name string, records []T) error {
var data bytes.Buffer
for _, record := range records {
encoded, err := json.Marshal(record)
if err != nil {
return err
}
data.Write(encoded)
data.WriteByte(10)
}
return writeDiagnosticZipFile(archive, name, data.Bytes())
}
func writeDiagnosticZipFile(archive *zip.Writer, name string, data []byte) error {
file, err := archive.Create(name)
if err != nil {
return err
}
_, err = file.Write(data)
return err
}
func sanitizeDiagnosticIdentifier(value string) string {
value = strings.TrimSpace(value)
if value == "" || len(value) > 96 {
return ""
}
for _, char := range value {
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || strings.ContainsRune("._:-", char) {
continue
}
return ""
}
return value
}
func sanitizeDiagnosticPath(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if index := strings.IndexAny(value, "?#"); index >= 0 {
value = value[:index]
}
return redactDiagnosticText(value, 300)
}
func redactDiagnosticText(value string, limit int) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
for _, marker := range []string{"authorization", "cookie", "set-cookie", "x-goog-api-key", "x-canvas-upstream-headers", "api_key", "api-key", "apikey", "access_key", "access-key", "secret_key", "secret-key", "password", "token="} {
value = redactDiagnosticMarker(value, marker)
}
return truncateRunes(redactDiagnosticURLs(value), limit)
}
func redactDiagnosticMarker(value string, marker string) string {
searchFrom := 0
for searchFrom < len(value) {
lower := strings.ToLower(value)
index := strings.Index(lower[searchFrom:], strings.ToLower(marker))
if index < 0 {
break
}
index += searchFrom
end := index + len(marker)
for end < len(value) && isDiagnosticSeparator(value[end]) {
end++
}
valueEnd := end
for valueEnd < len(value) && !isDiagnosticValueDelimiter(value[valueEnd]) {
valueEnd++
}
if valueEnd == end {
searchFrom = end
continue
}
value = value[:end] + "[REDACTED]" + value[valueEnd:]
searchFrom = end + len("[REDACTED]")
}
return value
}
func redactDiagnosticURLs(value string) string {
searchFrom := 0
for searchFrom < len(value) {
startHTTP := strings.Index(value[searchFrom:], "http://")
startHTTPS := strings.Index(value[searchFrom:], "https://")
start := -1
if startHTTP >= 0 {
start = startHTTP
}
if startHTTPS >= 0 && (start < 0 || startHTTPS < start) {
start = startHTTPS
}
if start < 0 {
break
}
start += searchFrom
end := start
for end < len(value) && !isDiagnosticURLDelimiter(value[end]) {
end++
}
raw := value[start:end]
parsed, err := url.Parse(raw)
if err != nil {
searchFrom = end
continue
}
parsed.RawQuery = ""
parsed.Fragment = ""
safe := parsed.String()
value = value[:start] + safe + value[end:]
searchFrom = start + len(safe)
}
return value
}
func isDiagnosticSeparator(value byte) bool {
return value == ' ' || value == 9 || value == ':' || value == '='
}
func isDiagnosticValueDelimiter(value byte) bool {
switch value {
case ' ', 9, 13, 10, ',', ';', '"', 39, '<', '>', '}', ']':
return true
default:
return false
}
}
func isDiagnosticURLDelimiter(value byte) bool {
return value == ' ' || value == 9 || value == 13 || value == 10 || value == '"' || value == 39 || value == '<' || value == '>'
}
func boundedDiagnosticInt(value int, min int, max int) int {
if value < min {
return min
}
if value > max {
return max
}
return value
}
func boundedDiagnosticInt64(value int64, min int64, max int64) int64 {
if value < min {
return min
}
if value > max {
return max
}
return value
}