392 lines
10 KiB
Go
392 lines
10 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 app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
|
)
|
|
|
|
func init() {
|
|
configmeta.Register(configmeta.ConfigItem{
|
|
Name: "DWS_PERF_DEBUG",
|
|
Category: configmeta.CategoryDebug,
|
|
Description: "启用性能计时输出到 stderr",
|
|
Example: "1",
|
|
})
|
|
configmeta.Register(configmeta.ConfigItem{
|
|
Name: "DWS_PERF_REPORT",
|
|
Category: configmeta.CategoryDebug,
|
|
Description: "JSON 性能报告输出路径 (auto=~/.dws/perf/latest.json)",
|
|
Example: "auto",
|
|
})
|
|
}
|
|
|
|
const (
|
|
// PerfDebugEnv is the environment variable to enable performance timing output.
|
|
PerfDebugEnv = "DWS_PERF_DEBUG"
|
|
|
|
// PerfReportEnv is the environment variable to enable JSON perf report output.
|
|
// Set to "auto" to write to ~/.dws/perf/latest.json, or a custom file path.
|
|
PerfReportEnv = "DWS_PERF_REPORT"
|
|
|
|
perfReportDir = "perf"
|
|
perfReportFile = "latest.json"
|
|
)
|
|
|
|
var (
|
|
timingMarshalIndent = json.MarshalIndent
|
|
timingMkdirAll = os.MkdirAll
|
|
timingWriteFile = os.WriteFile
|
|
timingRemove = os.Remove
|
|
timingRename = os.Rename
|
|
timingReadFile = os.ReadFile
|
|
timingUserHomeDir = os.UserHomeDir
|
|
)
|
|
|
|
// timingContextKey is the context key for TimingCollector.
|
|
type timingContextKey struct{}
|
|
|
|
// TimingEntry represents a single timing measurement.
|
|
type TimingEntry struct {
|
|
Name string
|
|
Duration time.Duration
|
|
Timestamp time.Time
|
|
Seq int // insertion order
|
|
}
|
|
|
|
// TimingCollector collects timing measurements for a single command execution.
|
|
// It is safe for concurrent use.
|
|
type TimingCollector struct {
|
|
mu sync.Mutex
|
|
start time.Time
|
|
entries []TimingEntry
|
|
seq int
|
|
}
|
|
|
|
// NewTimingCollector creates a new collector with the start time set to now.
|
|
func NewTimingCollector() *TimingCollector {
|
|
return &TimingCollector{
|
|
start: time.Now(),
|
|
entries: make([]TimingEntry, 0, 16),
|
|
}
|
|
}
|
|
|
|
// Record adds a timing entry with the given name and duration.
|
|
func (tc *TimingCollector) Record(name string, d time.Duration) {
|
|
if tc == nil {
|
|
return
|
|
}
|
|
tc.mu.Lock()
|
|
defer tc.mu.Unlock()
|
|
tc.entries = append(tc.entries, TimingEntry{
|
|
Name: name,
|
|
Duration: d,
|
|
Timestamp: time.Now(),
|
|
Seq: tc.seq,
|
|
})
|
|
tc.seq++
|
|
}
|
|
|
|
// StartTimer returns a function that, when called, records the elapsed time
|
|
// since StartTimer was called. This is convenient for defer usage:
|
|
//
|
|
// defer tc.StartTimer("operation")()
|
|
func (tc *TimingCollector) StartTimer(name string) func() {
|
|
if tc == nil {
|
|
return func() {}
|
|
}
|
|
start := time.Now()
|
|
return func() {
|
|
tc.Record(name, time.Since(start))
|
|
}
|
|
}
|
|
|
|
// Total returns the total elapsed time since the collector was created.
|
|
func (tc *TimingCollector) Total() time.Duration {
|
|
if tc == nil {
|
|
return 0
|
|
}
|
|
return time.Since(tc.start)
|
|
}
|
|
|
|
// Entries returns a copy of all recorded entries in insertion order.
|
|
func (tc *TimingCollector) Entries() []TimingEntry {
|
|
if tc == nil {
|
|
return nil
|
|
}
|
|
tc.mu.Lock()
|
|
defer tc.mu.Unlock()
|
|
result := make([]TimingEntry, len(tc.entries))
|
|
copy(result, tc.entries)
|
|
sort.Slice(result, func(i, j int) bool {
|
|
return result[i].Seq < result[j].Seq
|
|
})
|
|
return result
|
|
}
|
|
|
|
// formatDuration returns a human-friendly duration string.
|
|
// Sub-µs → "0µs", sub-ms → microsecond precision (e.g. "142µs"), else → ms.
|
|
func formatDuration(d time.Duration) string {
|
|
switch {
|
|
case d < time.Microsecond:
|
|
return "0µs"
|
|
case d < time.Millisecond:
|
|
return d.Truncate(time.Microsecond).String()
|
|
default:
|
|
return d.Truncate(time.Millisecond).String()
|
|
}
|
|
}
|
|
|
|
// Print writes a summary of all timing entries to the given writer.
|
|
func (tc *TimingCollector) Print(w io.Writer) {
|
|
if tc == nil || w == nil {
|
|
return
|
|
}
|
|
entries := tc.Entries()
|
|
total := tc.Total()
|
|
if len(entries) == 0 {
|
|
fmt.Fprintf(w, "\n[Perf] Total: %v (no detailed entries)\n", formatDuration(total))
|
|
return
|
|
}
|
|
|
|
fmt.Fprintln(w)
|
|
fmt.Fprintln(w, "[Perf] Execution breakdown:")
|
|
for _, e := range entries {
|
|
fmt.Fprintf(w, " %-30s %v\n", e.Name, formatDuration(e.Duration))
|
|
}
|
|
fmt.Fprintf(w, " %-30s %v\n", "──────────────────────────────", "──────────")
|
|
fmt.Fprintf(w, " %-30s %v\n", "Total", formatDuration(total))
|
|
}
|
|
|
|
// PrintIfEnabled prints timing info to stderr if DWS_PERF_DEBUG is set.
|
|
func (tc *TimingCollector) PrintIfEnabled() {
|
|
if tc == nil {
|
|
return
|
|
}
|
|
if os.Getenv(PerfDebugEnv) == "" {
|
|
return
|
|
}
|
|
tc.Print(os.Stderr)
|
|
}
|
|
|
|
// WithTimingCollector returns a new context with the TimingCollector attached.
|
|
func WithTimingCollector(ctx context.Context, tc *TimingCollector) context.Context {
|
|
return context.WithValue(ctx, timingContextKey{}, tc)
|
|
}
|
|
|
|
// TimingCollectorFromContext extracts the TimingCollector from context, or nil.
|
|
func TimingCollectorFromContext(ctx context.Context) *TimingCollector {
|
|
if ctx == nil {
|
|
return nil
|
|
}
|
|
tc, _ := ctx.Value(timingContextKey{}).(*TimingCollector)
|
|
return tc
|
|
}
|
|
|
|
// RecordTiming is a convenience function to record timing to the collector in context.
|
|
func RecordTiming(ctx context.Context, name string, d time.Duration) {
|
|
if tc := TimingCollectorFromContext(ctx); tc != nil {
|
|
tc.Record(name, d)
|
|
}
|
|
}
|
|
|
|
// StartTiming is a convenience function that returns a stop function for defer usage.
|
|
// Example:
|
|
//
|
|
// defer StartTiming(ctx, "operation")()
|
|
func StartTiming(ctx context.Context, name string) func() {
|
|
tc := TimingCollectorFromContext(ctx)
|
|
if tc == nil {
|
|
return func() {}
|
|
}
|
|
return tc.StartTimer(name)
|
|
}
|
|
|
|
// IsPerfDebugEnabled returns true if performance debug output is enabled.
|
|
func IsPerfDebugEnabled() bool {
|
|
return os.Getenv(PerfDebugEnv) != ""
|
|
}
|
|
|
|
// ── Structured Performance Report ──────────────────────────────────────
|
|
|
|
// PerfPhase is a single phase in the performance report.
|
|
type PerfPhase struct {
|
|
Name string `json:"name"`
|
|
DurationMs int64 `json:"duration_ms"`
|
|
Seq int `json:"seq"`
|
|
}
|
|
|
|
// PerfReport is the JSON-serialisable performance report.
|
|
type PerfReport struct {
|
|
Kind string `json:"kind"`
|
|
Version string `json:"version"`
|
|
CLIVersion string `json:"cli_version"`
|
|
Command string `json:"command"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
TotalMs int64 `json:"total_ms"`
|
|
Phases []PerfPhase `json:"phases"`
|
|
Slowest string `json:"slowest"`
|
|
OverheadMs int64 `json:"overhead_ms"`
|
|
}
|
|
|
|
// BuildReport constructs a PerfReport from the collected timing entries.
|
|
func (tc *TimingCollector) BuildReport(cliVersion, command string) PerfReport {
|
|
entries := tc.Entries()
|
|
total := tc.Total()
|
|
totalMs := total.Milliseconds()
|
|
|
|
phases := make([]PerfPhase, len(entries))
|
|
var sumMs int64
|
|
var slowestName string
|
|
var slowestMs int64
|
|
|
|
for i, e := range entries {
|
|
ms := e.Duration.Milliseconds()
|
|
phases[i] = PerfPhase{
|
|
Name: e.Name,
|
|
DurationMs: ms,
|
|
Seq: e.Seq,
|
|
}
|
|
sumMs += ms
|
|
if ms > slowestMs {
|
|
slowestMs = ms
|
|
slowestName = e.Name
|
|
}
|
|
}
|
|
|
|
overhead := totalMs - sumMs
|
|
if overhead < 0 {
|
|
overhead = 0
|
|
}
|
|
|
|
return PerfReport{
|
|
Kind: "perf_report",
|
|
Version: "1",
|
|
CLIVersion: cliVersion,
|
|
Command: command,
|
|
Timestamp: time.Now(),
|
|
TotalMs: totalMs,
|
|
Phases: phases,
|
|
Slowest: slowestName,
|
|
OverheadMs: overhead,
|
|
}
|
|
}
|
|
|
|
// WriteReportIfEnabled checks DWS_PERF_REPORT and writes a JSON report if set.
|
|
func (tc *TimingCollector) WriteReportIfEnabled(cliVersion, command string) {
|
|
if tc == nil {
|
|
return
|
|
}
|
|
dest := os.Getenv(PerfReportEnv)
|
|
if dest == "" {
|
|
return
|
|
}
|
|
|
|
report := tc.BuildReport(cliVersion, command)
|
|
data, err := timingMarshalIndent(report, "", " ")
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
path := resolvePerfReportPath(dest)
|
|
if path == "" {
|
|
return
|
|
}
|
|
|
|
dir := filepath.Dir(path)
|
|
if err := timingMkdirAll(dir, 0o700); err != nil {
|
|
return
|
|
}
|
|
|
|
tmp := path + ".tmp"
|
|
if err := timingWriteFile(tmp, data, 0o600); err != nil {
|
|
_ = timingRemove(tmp)
|
|
return
|
|
}
|
|
_ = timingRename(tmp, path)
|
|
}
|
|
|
|
// LoadLatestReport reads the default perf report file (~/.dws/perf/latest.json).
|
|
func LoadLatestReport() (*PerfReport, error) {
|
|
path := defaultPerfReportPath()
|
|
data, err := timingReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var report PerfReport
|
|
if err := json.Unmarshal(data, &report); err != nil {
|
|
return nil, err
|
|
}
|
|
return &report, nil
|
|
}
|
|
|
|
// resolvePerfReportPath resolves the DWS_PERF_REPORT value to an absolute path.
|
|
func resolvePerfReportPath(dest string) string {
|
|
if dest == "auto" {
|
|
return defaultPerfReportPath()
|
|
}
|
|
return dest
|
|
}
|
|
|
|
func defaultPerfReportPath() string {
|
|
home, err := timingUserHomeDir()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return filepath.Join(home, ".dws", perfReportDir, perfReportFile)
|
|
}
|
|
|
|
// sensitiveFlags are flag names whose values should be masked in commands.
|
|
var sensitiveFlags = map[string]bool{
|
|
"--token": true,
|
|
"--client-secret": true,
|
|
"--client-id": true,
|
|
}
|
|
|
|
// SanitizeCommand redacts sensitive flag values from a command arg slice.
|
|
func SanitizeCommand(args []string) string {
|
|
sanitized := make([]string, 0, len(args))
|
|
skipNext := false
|
|
for _, arg := range args {
|
|
if skipNext {
|
|
sanitized = append(sanitized, "***")
|
|
skipNext = false
|
|
continue
|
|
}
|
|
if idx := strings.IndexByte(arg, '='); idx > 0 {
|
|
key := arg[:idx]
|
|
if sensitiveFlags[key] {
|
|
sanitized = append(sanitized, key+"=***")
|
|
continue
|
|
}
|
|
}
|
|
if sensitiveFlags[arg] {
|
|
skipNext = true
|
|
}
|
|
sanitized = append(sanitized, arg)
|
|
}
|
|
return strings.Join(sanitized, " ")
|
|
}
|