feat: add privacy-safe clitrack telemetry (#1009)
Co-authored-by: zearlin <ruomiao.linrm@alibaba-inc.com> Co-authored-by: chichuan <30925823+haofeng0705@users.noreply.github.com>
This commit is contained in:
co-authored by
zearlin
chichuan
parent
fb9ff7de73
commit
8560830d3e
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Privacy-safe CLI telemetry** (#1009) — reports reviewed command outcomes and profile identity dimensions while excluding command arguments, output, paths, device fingerprints, and automatic system dimensions; `DO_NOT_TRACK=1` disables reporting.
|
||||
@@ -20,6 +20,10 @@ test/cli_compat/testdata/
|
||||
.gitignore
|
||||
.worktrees/
|
||||
.qoder/
|
||||
_logs/
|
||||
_docs/
|
||||
_output/
|
||||
vendor/
|
||||
|
||||
# Secrets & credentials
|
||||
.env
|
||||
|
||||
+66
-2
@@ -15,12 +15,76 @@ package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"gitlab.alibaba-inc.com/aes/aem-go-sdk/clitrack"
|
||||
)
|
||||
|
||||
var exit = os.Exit
|
||||
var (
|
||||
appExecute = app.ExecuteWithTelemetry
|
||||
resolveTelemetryIdentity = app.ResolveTelemetryIdentity
|
||||
trackRun = func(cfg clitrack.Config, execute func() error, exitCode func(error) int) {
|
||||
clitrack.New(cfg).Run(execute, exitCode)
|
||||
}
|
||||
)
|
||||
|
||||
// trackedExitError tells clitrack that the command failed without asking it to
|
||||
// print the error a second time. The already-rendered message is published via
|
||||
// ExtraFields c5, while app.Execute remains the sole owner of presentation.
|
||||
type trackedExitError struct{}
|
||||
|
||||
func (trackedExitError) Error() string { return "" }
|
||||
|
||||
func trackerConfig(identity app.TelemetryIdentity, commandPath, errorMessage *string) clitrack.Config {
|
||||
return clitrack.Config{
|
||||
PID: "wcCRwZ",
|
||||
App: "dws",
|
||||
Version: app.RawVersion(),
|
||||
UID: identity.UserID,
|
||||
Username: identity.UserName,
|
||||
NoCommandLine: true,
|
||||
NoCwd: true,
|
||||
NoAutomaticDimensions: true,
|
||||
ExtraFields: func() map[string]string {
|
||||
fields := map[string]string{"c9": *commandPath}
|
||||
if identity.CorpID != "" {
|
||||
fields["c10"] = identity.CorpID
|
||||
}
|
||||
if *errorMessage != "" {
|
||||
fields["c5"] = *errorMessage
|
||||
}
|
||||
return fields
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func telemetryOptedOut() bool {
|
||||
return strings.TrimSpace(os.Getenv("DO_NOT_TRACK")) != ""
|
||||
}
|
||||
|
||||
func main() {
|
||||
exit(app.Execute())
|
||||
optedOut := telemetryOptedOut()
|
||||
identity := app.TelemetryIdentity{}
|
||||
if !optedOut {
|
||||
identity = resolveTelemetryIdentity(os.Args[1:])
|
||||
}
|
||||
exitCode := 0
|
||||
commandPath := "dws"
|
||||
errorMessage := ""
|
||||
cfg := trackerConfig(identity, &commandPath, &errorMessage)
|
||||
if optedOut {
|
||||
cfg.PID = ""
|
||||
}
|
||||
trackRun(
|
||||
cfg,
|
||||
func() error {
|
||||
exitCode, commandPath, errorMessage = appExecute()
|
||||
if exitCode != 0 {
|
||||
return trackedExitError{}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
func(error) int { return exitCode },
|
||||
)
|
||||
}
|
||||
|
||||
+206
-13
@@ -1,27 +1,220 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"gitlab.alibaba-inc.com/aes/aem-go-sdk/clitrack"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageMainExitsWithSuccessfulVersionCommand(t *testing.T) {
|
||||
previousExit := exit
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
exit = previousExit
|
||||
os.Args = previousArgs
|
||||
})
|
||||
func TestCrossPlatformCoverageMainRunsThroughCLITracker(t *testing.T) {
|
||||
for _, wantCode := range []int{0, 1, 3, 5} {
|
||||
t.Run(fmt.Sprintf("exit_%d", wantCode), func(t *testing.T) {
|
||||
t.Setenv("DO_NOT_TRACK", "")
|
||||
wantError := ""
|
||||
if wantCode != 0 {
|
||||
wantError = "synthetic failure"
|
||||
}
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "sheet", "read", "--profile", "corp-a"})
|
||||
testseam.Swap(t, &resolveTelemetryIdentity, func(args []string) app.TelemetryIdentity {
|
||||
if strings.Join(args, " ") != "sheet read --profile corp-a" {
|
||||
t.Fatalf("telemetry identity args = %#v", args)
|
||||
}
|
||||
return app.TelemetryIdentity{UserID: "user-1", UserName: "Alice", CorpID: "corp-1"}
|
||||
})
|
||||
testseam.Swap(t, &appExecute, func() (int, string, string) { return wantCode, "sheet read", wantError })
|
||||
called := false
|
||||
testseam.Swap(t, &trackRun, func(cfg clitrack.Config, execute func() error, exitCode func(error) int) {
|
||||
called = true
|
||||
if cfg.PID != "wcCRwZ" || cfg.App != "dws" {
|
||||
t.Fatalf("tracker identity = PID %q App %q", cfg.PID, cfg.App)
|
||||
}
|
||||
if cfg.Version != app.RawVersion() {
|
||||
t.Fatalf("tracker Version = %q, want %q", cfg.Version, app.RawVersion())
|
||||
}
|
||||
if !cfg.NoCommandLine || !cfg.NoCwd || !cfg.NoAutomaticDimensions || cfg.CaptureOutput {
|
||||
t.Fatalf("tracker privacy config = NoCommandLine %v NoCwd %v NoAutomaticDimensions %v CaptureOutput %v", cfg.NoCommandLine, cfg.NoCwd, cfg.NoAutomaticDimensions, cfg.CaptureOutput)
|
||||
}
|
||||
if cfg.Env != "" || cfg.EventID != "" || cfg.Endpoint != "" || cfg.FlushTimeout != 0 || cfg.OutputMaxLen != 0 {
|
||||
t.Fatalf("tracker SDK defaults were overridden: %#v", cfg)
|
||||
}
|
||||
if cfg.UID != "user-1" || cfg.Username != "Alice" || cfg.UserType != "" {
|
||||
t.Fatalf("tracker user identity = UID %q Username %q UserType %q", cfg.UID, cfg.Username, cfg.UserType)
|
||||
}
|
||||
|
||||
err := execute()
|
||||
if wantCode == 0 && err != nil {
|
||||
t.Fatalf("successful tracked execute error = %v", err)
|
||||
}
|
||||
if wantCode != 0 && (err == nil || err.Error() != "") {
|
||||
t.Fatalf("failed tracked execute error = %#v, want empty sentinel", err)
|
||||
}
|
||||
if gotCode := exitCode(err); gotCode != wantCode {
|
||||
t.Fatalf("tracked exit code = %d, want %d", gotCode, wantCode)
|
||||
}
|
||||
fields := cfg.ExtraFields()
|
||||
if fields["c9"] != "sheet read" || fields["c10"] != "corp-1" || fields["c5"] != wantError {
|
||||
t.Fatalf("tracker extra fields = %#v, want command path, corp ID, and error %q", fields, wantError)
|
||||
}
|
||||
if (wantError == "" && len(fields) != 2) || (wantError != "" && len(fields) != 3) {
|
||||
t.Fatalf("tracker extra field count = %d for error %q", len(fields), wantError)
|
||||
}
|
||||
})
|
||||
|
||||
main()
|
||||
if !called {
|
||||
t.Fatalf("trackRun was not called for exit code %d", wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTrackerConfigOmitsEmptyOrganization(t *testing.T) {
|
||||
commandPath := "version"
|
||||
errorMessage := ""
|
||||
cfg := trackerConfig(app.TelemetryIdentity{}, &commandPath, &errorMessage)
|
||||
if cfg.UID != "" {
|
||||
t.Fatalf("empty identity UID = %q", cfg.UID)
|
||||
}
|
||||
if cfg.Username != "" {
|
||||
t.Fatalf("empty identity Username = %q", cfg.Username)
|
||||
}
|
||||
if fields := cfg.ExtraFields(); len(fields) != 1 || fields["c9"] != "version" {
|
||||
t.Fatalf("empty organization fields = %#v", fields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDefaultTrackRunNoopTracker(t *testing.T) {
|
||||
called := false
|
||||
code := -1
|
||||
exit = func(value int) {
|
||||
trackRun(clitrack.Config{}, func() error {
|
||||
called = true
|
||||
code = value
|
||||
return nil
|
||||
}, nil)
|
||||
if !called {
|
||||
t.Fatal("default tracker did not execute callback")
|
||||
}
|
||||
os.Args = []string{"dws", "version"}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMainRespectsDoNotTrack(t *testing.T) {
|
||||
t.Setenv("DO_NOT_TRACK", "1")
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "version"})
|
||||
testseam.Swap(t, &resolveTelemetryIdentity, func([]string) app.TelemetryIdentity {
|
||||
t.Fatal("DO_NOT_TRACK must skip telemetry identity reads")
|
||||
return app.TelemetryIdentity{}
|
||||
})
|
||||
testseam.Swap(t, &appExecute, func() (int, string, string) { return 0, "version", "" })
|
||||
testseam.Swap(t, &trackRun, func(cfg clitrack.Config, execute func() error, exitCode func(error) int) {
|
||||
if cfg.PID != "" || cfg.UID != "" || cfg.Username != "" {
|
||||
t.Fatalf("opted-out tracker config = %#v", cfg)
|
||||
}
|
||||
if err := execute(); err != nil {
|
||||
t.Fatalf("opted-out execution failed: %v", err)
|
||||
}
|
||||
if code := exitCode(nil); code != 0 {
|
||||
t.Fatalf("opted-out exit code = %d, want 0", code)
|
||||
}
|
||||
})
|
||||
|
||||
main()
|
||||
if !called || code != 0 {
|
||||
t.Fatalf("main exit = called %v, code %d", called, code)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTrackerPayloadUsesReviewedFieldWhitelist(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws", "sheet", "read", "--access-token", "must-not-leak"}
|
||||
t.Setenv("SHELL", "/bin/zsh")
|
||||
t.Setenv("TERM_SESSION_ID", "stable-session")
|
||||
t.Setenv("TMUX_PANE", "%42")
|
||||
t.Setenv("LANG", "zh_CN.UTF-8")
|
||||
t.Setenv("LC_ALL", "zh_CN.UTF-8")
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
requestBody := make(chan []byte, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
requestBody <- body
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
commandPath := "sheet read"
|
||||
errorMessage := ""
|
||||
cfg := trackerConfig(app.TelemetryIdentity{UserID: "user-1", UserName: "Alice", CorpID: "corp-1"}, &commandPath, &errorMessage)
|
||||
cfg.Endpoint = server.URL
|
||||
cfg.FlushTimeout = time.Second
|
||||
clitrack.New(cfg).Run(func() error { return nil }, nil)
|
||||
|
||||
var body []byte
|
||||
select {
|
||||
case body = <-requestBody:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for telemetry request")
|
||||
}
|
||||
var envelope map[string]string
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
t.Fatalf("decode telemetry request %q: %v", body, err)
|
||||
}
|
||||
decoded, err := url.QueryUnescape(envelope["gokey"])
|
||||
if err != nil {
|
||||
t.Fatalf("decode gokey: %v", err)
|
||||
}
|
||||
globalFields, err := url.ParseQuery(decoded)
|
||||
if err != nil {
|
||||
t.Fatalf("parse global telemetry fields: %v", err)
|
||||
}
|
||||
eventFields, err := url.ParseQuery(globalFields.Get("msg"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse event telemetry fields: %v", err)
|
||||
}
|
||||
|
||||
assertTelemetryKeys(t, globalFields, []string{"app_name", "app_version", "env", "msg", "pid", "platform", "uid", "username", "version"})
|
||||
assertTelemetryKeys(t, eventFields, []string{"c1", "c10", "c3", "c4", "c9", "p1", "p4", "ts", "type"})
|
||||
for key, want := range map[string]string{
|
||||
"app_name": "dws", "app_version": app.RawVersion(), "env": "prod", "pid": "wcCRwZ",
|
||||
"platform": "cli", "uid": "user-1", "username": "Alice", "version": app.RawVersion(),
|
||||
} {
|
||||
if got := globalFields.Get(key); got != want {
|
||||
t.Fatalf("global telemetry field %s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"type": "event", "p1": "cli.exec", "p4": "SYS", "c1": "dws", "c3": "0", "c9": "sheet read", "c10": "corp-1",
|
||||
} {
|
||||
if got := eventFields.Get(key); got != want {
|
||||
t.Fatalf("event telemetry field %s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"device_id", "ext", "os", "os_version", "pv_id", "sdk_version", "sid", "timezone_offset"} {
|
||||
if globalFields.Has(key) {
|
||||
t.Fatalf("global telemetry leaked %s: %q", key, decoded)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"c2", "c5", "c6", "c7", "c8"} {
|
||||
if eventFields.Has(key) {
|
||||
t.Fatalf("event telemetry leaked %s: %q", key, globalFields.Get("msg"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertTelemetryKeys(t *testing.T, fields url.Values, want []string) {
|
||||
t.Helper()
|
||||
got := make([]string, 0, len(fields))
|
||||
for key := range fields {
|
||||
got = append(got, key)
|
||||
}
|
||||
sort.Strings(got)
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("telemetry keys = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ module github.com/DingTalk-Real-AI/dingtalk-workspace-cli
|
||||
|
||||
go 1.25.9
|
||||
|
||||
replace gitlab.alibaba-inc.com/aes/aem-go-sdk => ./third_party/aem-go-sdk
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/RealAlexandreAI/json-repair v0.0.15
|
||||
@@ -17,6 +19,7 @@ require (
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
gitlab.alibaba-inc.com/aes/aem-go-sdk v0.3.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/text v0.35.0
|
||||
|
||||
@@ -268,7 +268,7 @@ type frameworkFailWriter struct{}
|
||||
|
||||
func (frameworkFailWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
|
||||
|
||||
func TestFrameworkExecutePanicBeforeEmissionUsesUnifiedFailure(t *testing.T) {
|
||||
func TestCrossPlatformCoverageFrameworkExecutePanicBeforeEmissionUsesUnifiedFailure(t *testing.T) {
|
||||
for _, failWriter := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "emits", true: "fallback"}[failWriter], func(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
@@ -470,6 +470,118 @@ func TestCrossPlatformCoverageFrameworkExecuteRareOutcomeBranches(t *testing.T)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExecuteDeterministicInterruptionBranches(t *testing.T) {
|
||||
install := func(t *testing.T, state *processSignalState, stdout, stderr io.Writer) {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws"}
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
testseam.Swap(t, &rootStopAllStdioClients, func() {})
|
||||
testseam.Swap(t, &rootInstallProcessSignalContext, func(ctx context.Context, _ *output.ResultStore) (context.Context, *processSignalState, func()) {
|
||||
return ctx, state, func() {}
|
||||
})
|
||||
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
cmd.SetContext(ctx)
|
||||
cmd.SetOut(stdout)
|
||||
cmd.SetErr(stderr)
|
||||
return cmd
|
||||
})
|
||||
}
|
||||
interrupted := func(primaryCompleted bool) *processSignalState {
|
||||
return &processSignalState{
|
||||
interruption: &processInterruption{signal: os.Interrupt},
|
||||
primaryCompletedAtSignal: primaryCompleted,
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("preparse interruption emits unified failure", func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
install(t, interrupted(false), &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return errors.New("preparse failed") })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(*cobra.Command) (*cobra.Command, error) {
|
||||
t.Fatal("preparse failure reached command execution")
|
||||
return nil, nil
|
||||
})
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 130 || summary == "" || !strings.Contains(stdout.String(), `"outcome": "failure"`) {
|
||||
t.Fatalf("preparse interruption = code %d summary %q stdout %q", code, summary, stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("interruption before emission becomes primary error", func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
install(t, interrupted(false), &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil })
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 130 || summary == "" || !strings.Contains(stdout.String(), `"outcome": "failure"`) {
|
||||
t.Fatalf("pre-emission interruption = code %d summary %q stdout %q", code, summary, stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("late hook error preserves emitted result", func(t *testing.T) {
|
||||
install(t, interrupted(true), io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cmd, errors.New("late hook failed")
|
||||
})
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 0 || summary != "late hook failed" {
|
||||
t.Fatalf("late hook result = code %d summary %q", code, summary)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("interruption after emission preserves emitted result", func(t *testing.T) {
|
||||
install(t, interrupted(false), io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cmd, nil
|
||||
})
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 0 || summary == "" {
|
||||
t.Fatalf("post-emission interruption = code %d summary %q", code, summary)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("publication failure replaces unobservable result", func(t *testing.T) {
|
||||
var original bytes.Buffer
|
||||
install(t, interrupted(false), io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, err := os.CreateTemp(t.TempDir(), "finished-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{file: file, original: &original, finished: true}))
|
||||
publicationErr := newOutputPublicationError("publish", errors.New("rename failed"))
|
||||
if _, handled, emitErr := emitOutputPublicationFailure(cmd, publicationErr); !handled || emitErr != nil {
|
||||
t.Fatalf("precondition publication failure = handled %v error %v unified %v state %v", handled, emitErr, output.UsesUnifiedResult(cmd), outputSinkForCommand(cmd) != nil)
|
||||
}
|
||||
return cmd, publicationErr
|
||||
})
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 5 || summary == "" {
|
||||
t.Fatalf("publication failure = code %d summary %q output %q", code, summary, original.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type frameworkPanicWriter struct{}
|
||||
|
||||
func (frameworkPanicWriter) Write([]byte) (int, error) { panic("writer panic") }
|
||||
|
||||
@@ -66,6 +66,32 @@ func TestPreparseProfileFlagUsesNormalizedProfileArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePreparseProfileFlagUsesLastOccurrence(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
valid bool
|
||||
}{
|
||||
{name: "space then equals", args: []string{"--profile", "corp-a", "version", "--profile=corp-b"}, want: "corp-b", valid: true},
|
||||
{name: "equals then space", args: []string{"--profile=corp-a", "version", "--profile", "corp-b"}, want: "corp-b", valid: true},
|
||||
{name: "last multi", args: []string{"--profile=corp-a", "--profile", "corp-b,", "corp-c", "version"}, want: "corp-b,corp-c", valid: true},
|
||||
{name: "empty equals clears earlier", args: []string{"--profile=corp-a", "version", "--profile="}},
|
||||
{name: "missing value clears earlier", args: []string{"--profile=corp-a", "version", "--profile"}},
|
||||
{name: "next flag is not profile value", args: []string{"--profile=corp-a", "--profile", "--debug", "version"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := preparseProfileFlag(tc.args); got != tc.want {
|
||||
t.Fatalf("preparseProfileFlag(%#v) = %q, want %q", tc.args, got, tc.want)
|
||||
}
|
||||
_, specified, valid := preparseProfileSelection(tc.args)
|
||||
if !specified || valid != tc.valid {
|
||||
t.Fatalf("preparseProfileSelection(%#v) = specified %v valid %v, want true/%v", tc.args, specified, valid, tc.valid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProcessProfileArgsRestoresOriginalArgv(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
|
||||
+101
-17
@@ -80,10 +80,19 @@ var (
|
||||
rootAuthLoadTokenData = authpkg.LoadTokenData
|
||||
rootNewCommandRunnerWithFlags = newCommandRunnerWithFlags
|
||||
rootEmitResult = output.EmitResult
|
||||
rootInstallProcessSignalContext = installProcessSignalContext
|
||||
)
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
func Execute() (exitCode int) {
|
||||
func Execute() int {
|
||||
exitCode, _, _ := ExecuteWithTelemetry()
|
||||
return exitCode
|
||||
}
|
||||
|
||||
// ExecuteWithTelemetry runs the root command and additionally returns a
|
||||
// privacy-safe command path and error summary for the official CLI entrypoint.
|
||||
func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage string) {
|
||||
commandPath = "dws"
|
||||
var (
|
||||
root *cobra.Command
|
||||
executed *cobra.Command
|
||||
@@ -91,12 +100,16 @@ func Execute() (exitCode int) {
|
||||
)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
errorMessage = "internal panic"
|
||||
target := executed
|
||||
if target == nil && root != nil {
|
||||
if found, _, err := root.Find(os.Args[1:]); err == nil {
|
||||
target = found
|
||||
}
|
||||
}
|
||||
if target != nil {
|
||||
commandPath = telemetryCommandPath(target)
|
||||
}
|
||||
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
|
||||
exitCode = code
|
||||
if target != nil {
|
||||
@@ -121,6 +134,7 @@ func Execute() (exitCode int) {
|
||||
CloseFileLogger()
|
||||
if executed != nil {
|
||||
if err := closeOutputSink(executed); err != nil {
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
if code, handled, emitErr := emitOutputPublicationFailure(executed, err); handled && emitErr == nil {
|
||||
exitCode = code
|
||||
} else {
|
||||
@@ -144,7 +158,9 @@ func Execute() (exitCode int) {
|
||||
agentMetadata := readAgentMetadataSnapshot()
|
||||
if err := agentMetadata.validationError(); err != nil {
|
||||
emitEarlyAgentMetadataValidationError(err, os.Args[1:])
|
||||
return apperrors.ExitCode(err)
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = apperrors.ExitCode(err)
|
||||
return
|
||||
}
|
||||
|
||||
timing := NewTimingCollector()
|
||||
@@ -162,12 +178,13 @@ func Execute() (exitCode int) {
|
||||
ctx, resultStore = output.WithResultStore(ctx)
|
||||
var signalState *processSignalState
|
||||
var stopSignals func()
|
||||
ctx, signalState, stopSignals = installProcessSignalContext(ctx, resultStore)
|
||||
ctx, signalState, stopSignals = rootInstallProcessSignalContext(ctx, resultStore)
|
||||
defer stopSignals()
|
||||
|
||||
initStart := time.Now()
|
||||
engine := newPipelineEngine()
|
||||
root = rootNewRootCommandWithEngine(ctx, engine)
|
||||
commandPath = telemetryCommandPath(root)
|
||||
timing.Record("cmd_init", time.Since(initStart))
|
||||
|
||||
// Run PreParse handlers on raw argv before Cobra parses flags.
|
||||
@@ -182,15 +199,23 @@ func Execute() (exitCode int) {
|
||||
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
|
||||
code, emitErr := output.EmitResult(target, result)
|
||||
if emitErr == nil {
|
||||
return code
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = code
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = printExecutionError(root, os.Stdout, os.Stderr, err)
|
||||
return apperrors.ExitCode(err)
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = apperrors.ExitCode(err)
|
||||
return
|
||||
}
|
||||
commandPath = telemetryCommandPathForArgs(root, os.Args[1:])
|
||||
|
||||
var err error
|
||||
executed, err = rootExecuteCommand(root)
|
||||
if executed != nil {
|
||||
commandPath = telemetryCommandPath(executed)
|
||||
}
|
||||
// PersistentPostRunE normally commits or aborts the transactional output
|
||||
// sink. Finalize once more at the process boundary so custom execution
|
||||
// seams, embedding callers, or future hook changes cannot leave publication
|
||||
@@ -222,7 +247,9 @@ func Execute() (exitCode int) {
|
||||
// successfully emitted result into a contradictory 130/143 process
|
||||
// status; likewise, a failed publication must retain its internal
|
||||
// error code instead of being relabelled as cancellation.
|
||||
return code
|
||||
errorMessage = telemetryErrorSummary(interrupted)
|
||||
exitCode = code
|
||||
return
|
||||
}
|
||||
}
|
||||
var publicationErr *outputPublicationError
|
||||
@@ -233,20 +260,26 @@ func Execute() (exitCode int) {
|
||||
if err != nil {
|
||||
if executed == nil {
|
||||
executed = root
|
||||
commandPath = telemetryCommandPath(root)
|
||||
}
|
||||
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
|
||||
var publicationErr *outputPublicationError
|
||||
if stderrors.As(err, &publicationErr) {
|
||||
errorMessage = telemetryErrorSummary(publicationErr)
|
||||
if failureCode, handled, emitErr := emitOutputPublicationFailure(executed, publicationErr); handled {
|
||||
if emitErr == nil {
|
||||
return failureCode
|
||||
exitCode = failureCode
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(executed.ErrOrStderr(), "Warning: emit output publication failure: %v\n", emitErr)
|
||||
}
|
||||
return apperrors.ExitCode(publicationErr)
|
||||
exitCode = apperrors.ExitCode(publicationErr)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(executed.ErrOrStderr(), "Warning: command hook failed after result emission: %v\n", err)
|
||||
return code
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = code
|
||||
return
|
||||
}
|
||||
err = rewordRequiredFlagError(err)
|
||||
var raw apperrors.RawStderrError
|
||||
@@ -254,7 +287,9 @@ func Execute() (exitCode int) {
|
||||
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
|
||||
code, emitErr := output.EmitResult(executed, result)
|
||||
if emitErr == nil {
|
||||
return code
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = code
|
||||
return
|
||||
}
|
||||
err = apperrors.NewInternal("emit failure result: "+emitErr.Error(), apperrors.WithCause(emitErr))
|
||||
}
|
||||
@@ -264,12 +299,42 @@ func Execute() (exitCode int) {
|
||||
_, _ = fmt.Fprintln(os.Stderr)
|
||||
}
|
||||
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
|
||||
return apperrors.ExitCode(err)
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = apperrors.ExitCode(err)
|
||||
return
|
||||
}
|
||||
if code, emitted := output.StoredExitCode(resultStore); emitted {
|
||||
return code
|
||||
exitCode = code
|
||||
return
|
||||
}
|
||||
return 0
|
||||
return
|
||||
}
|
||||
|
||||
func telemetryCommandPath(command *cobra.Command) string {
|
||||
if command == nil {
|
||||
return "dws"
|
||||
}
|
||||
path := strings.TrimSpace(command.CommandPath())
|
||||
root := command.Root()
|
||||
rootName := strings.TrimSpace(root.Name())
|
||||
if path == rootName {
|
||||
return rootName
|
||||
}
|
||||
if rootName != "" {
|
||||
path = strings.TrimSpace(strings.TrimPrefix(path, rootName+" "))
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func telemetryCommandPathForArgs(root *cobra.Command, args []string) string {
|
||||
if root == nil {
|
||||
return "dws"
|
||||
}
|
||||
command, _, err := root.Find(args)
|
||||
if err != nil || command == nil {
|
||||
return telemetryCommandPath(root)
|
||||
}
|
||||
return telemetryCommandPath(command)
|
||||
}
|
||||
|
||||
// emitEarlyAgentMetadataValidationError preserves each built-in command's
|
||||
@@ -959,17 +1024,36 @@ func installReviewedFlagProtectionHandlers(root *cobra.Command) {
|
||||
}
|
||||
|
||||
func preparseProfileFlag(args []string) string {
|
||||
profile, _, valid := preparseProfileSelection(args)
|
||||
if !valid {
|
||||
return ""
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
func preparseProfileSelection(args []string) (profile string, specified, valid bool) {
|
||||
args, _ = normalizeProfileFlagArgs(args)
|
||||
valid = true
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := strings.TrimSpace(args[i])
|
||||
switch {
|
||||
case arg == "--profile" && i+1 < len(args):
|
||||
return strings.TrimSpace(args[i+1])
|
||||
case arg == "--profile":
|
||||
specified = true
|
||||
if i+1 >= len(args) || strings.HasPrefix(strings.TrimSpace(args[i+1]), "-") {
|
||||
profile = ""
|
||||
valid = false
|
||||
continue
|
||||
}
|
||||
profile = strings.TrimSpace(args[i+1])
|
||||
valid = profile != ""
|
||||
i++
|
||||
case strings.HasPrefix(arg, "--profile="):
|
||||
return strings.TrimSpace(strings.TrimPrefix(arg, "--profile="))
|
||||
specified = true
|
||||
profile = strings.TrimSpace(strings.TrimPrefix(arg, "--profile="))
|
||||
valid = profile != ""
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return profile, specified, valid
|
||||
}
|
||||
|
||||
func normalizeProcessProfileArgs() func() {
|
||||
|
||||
@@ -37,29 +37,64 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
|
||||
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
rootStopAllStdioClients = func() {}
|
||||
var executedLeaf *cobra.Command
|
||||
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
|
||||
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
sheet := &cobra.Command{Use: "sheet"}
|
||||
executedLeaf = &cobra.Command{Use: "read", Run: func(*cobra.Command, []string) {}}
|
||||
sheet.AddCommand(executedLeaf)
|
||||
root.AddCommand(sheet)
|
||||
return root
|
||||
}
|
||||
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil }
|
||||
if code := Execute(); code != 0 {
|
||||
t.Fatalf("successful Execute code = %d", code)
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return executedLeaf, nil }
|
||||
if code, commandPath, errorMessage := ExecuteWithTelemetry(); code != 0 || commandPath != "sheet read" || errorMessage != "" {
|
||||
t.Fatalf("successful ExecuteWithTelemetry = code %d path %q error %q", code, commandPath, errorMessage)
|
||||
}
|
||||
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return errors.New("alias/canonical conflict") }
|
||||
if code := Execute(); code == 0 {
|
||||
t.Fatal("pre-parse conflict returned zero")
|
||||
if code, _, errorMessage := ExecuteWithTelemetry(); code == 0 || errorMessage != "alias/canonical conflict" {
|
||||
t.Fatalf("pre-parse conflict = code %d error %q", code, errorMessage)
|
||||
}
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
|
||||
wantErr := errors.New("unknown command missing")
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, wantErr }
|
||||
if code := Execute(); code == 0 {
|
||||
t.Fatal("failed Execute returned zero")
|
||||
if code, _, errorMessage := ExecuteWithTelemetry(); code == 0 || errorMessage != "unknown command" {
|
||||
t.Fatalf("failed ExecuteWithTelemetry = code %d error %q", code, errorMessage)
|
||||
}
|
||||
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { panic("boom") }
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("panic Execute code = %d", code)
|
||||
os.Args = []string{"dws", "sheet", "read"}
|
||||
if code, commandPath, errorMessage := ExecuteWithTelemetry(); code != 5 || commandPath != "sheet read" || errorMessage != "internal panic" {
|
||||
t.Fatalf("panic ExecuteWithTelemetry = code %d path %q error %q", code, commandPath, errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTelemetryCommandPath(t *testing.T) {
|
||||
if got := telemetryCommandPath(nil); got != "dws" {
|
||||
t.Fatalf("nil command path = %q, want dws", got)
|
||||
}
|
||||
if got := telemetryCommandPathForArgs(nil, nil); got != "dws" {
|
||||
t.Fatalf("nil root command path = %q, want dws", got)
|
||||
}
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
sheet := &cobra.Command{Use: "sheet"}
|
||||
read := &cobra.Command{Use: "read <range>"}
|
||||
sheet.AddCommand(read)
|
||||
root.AddCommand(sheet)
|
||||
if got := telemetryCommandPath(root); got != "dws" {
|
||||
t.Fatalf("root command path = %q, want dws", got)
|
||||
}
|
||||
if got := telemetryCommandPath(read); got != "sheet read" {
|
||||
t.Fatalf("leaf command path = %q, want sheet read", got)
|
||||
}
|
||||
root.PersistentFlags().String("profile", "", "")
|
||||
read.Aliases = []string{"get"}
|
||||
if got := telemetryCommandPathForArgs(root, []string{"--profile", "corp-a", "sheet", "get", "A1:B2"}); got != "sheet read" {
|
||||
t.Fatalf("pre-execution command path = %q, want sheet read", got)
|
||||
}
|
||||
if got := telemetryCommandPathForArgs(root, []string{"missing"}); got != "dws" {
|
||||
t.Fatalf("unknown pre-execution command path = %q, want dws", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ func TestPublicRootDirectExecuteClosesSinkOnHandlerError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePanicAfterEmissionPreservesSingleResultAndExitCode(t *testing.T) {
|
||||
func TestCrossPlatformCoverageExecutePanicAfterEmissionPreservesSingleResultAndExitCode(t *testing.T) {
|
||||
oldNormalize := rootNormalizeProcessProfileArgs
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
|
||||
@@ -230,13 +230,20 @@ func TestOutputSinkUnifiedPublicationFailureFailsAndLeavesNoFinalFile(t *testing
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing.T) {
|
||||
func TestCrossPlatformCoverageExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.json")
|
||||
t.Chdir(dir)
|
||||
// Keep argv portable: an absolute Windows path contains a volume colon,
|
||||
// which the CLI intentionally rejects as unsafe user-supplied output.
|
||||
target := "result.json"
|
||||
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
originalInfo, err := os.Stat(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Args = []string{"dws", "atomic-output-unified-publication", "--output", target, "--format", "json"}
|
||||
testseam.Swap(t, &rootRenameFile, func(string, string) error { return errors.New("rename failed") })
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
@@ -277,7 +284,7 @@ func TestExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing
|
||||
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 0 {
|
||||
t.Fatalf("rolled-back success leaked to stdout: %s", stdout.String())
|
||||
}
|
||||
assertOutputFile(t, target, "original", 0o640)
|
||||
assertOutputFile(t, target, "original", originalInfo.Mode().Perm())
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestExecuteEmitsStoredUnifiedResultAtSingleRootExit(t *testing.T) {
|
||||
func TestCrossPlatformCoverageExecuteEmitsStoredUnifiedResultAtSingleRootExit(t *testing.T) {
|
||||
oldNormalize := rootNormalizeProcessProfileArgs
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
|
||||
@@ -103,7 +103,7 @@ func installSignalExecuteSeams(t *testing.T, unified bool, stdout, stderr io.Wri
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecuteSignalEmitsOneTypedUnifiedFailure(t *testing.T) {
|
||||
func TestCrossPlatformCoverageExecuteSignalEmitsOneTypedUnifiedFailure(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
signal syscall.Signal
|
||||
@@ -197,7 +197,7 @@ func TestSignalAfterFailedEmissionAttemptPreservesPublicationExitCode(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
@@ -229,7 +229,7 @@ func TestSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalAfterCompletedPrimaryPreservesEstablishedOutcome(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSignalAfterCompletedPrimaryPreservesEstablishedOutcome(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// 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 (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
)
|
||||
|
||||
const maxTelemetryErrorRunes = 200
|
||||
|
||||
var (
|
||||
telemetryUnknownFlagPattern = regexp.MustCompile(`(?i)unknown flag:\s*(--[a-z0-9][a-z0-9-]*)`)
|
||||
telemetryAuthPattern = regexp.MustCompile(`(?i)\b(?:bearer|basic)\s+[a-z0-9._~+/=-]+`)
|
||||
telemetrySensitiveFlag = regexp.MustCompile(`(?i)(--(?:access-token|refresh-token|token|client-secret|client-id|password|api-key|authorization|cookie|credential|secret))(?:=|\s+)\S+`)
|
||||
telemetrySensitiveValue = regexp.MustCompile(`(?i)\b(authorization|client[-_]?secret|client[-_]?id|access[-_]?token|refresh[-_]?token|api[-_]?key|password|cookie|credential|secret|token)\b\s*[:=]\s*[^\s,;]+`)
|
||||
telemetryURLPattern = regexp.MustCompile(`(?i)\b(?:https?|wss?)://[^\s]+`)
|
||||
telemetryJSONPattern = regexp.MustCompile(`(?s)[\[{].*[\]}]`)
|
||||
telemetryUnixPathPattern = regexp.MustCompile(`(^|[\s=:])(?:~/|/)[^\s]+`)
|
||||
telemetryWindowsPathPattern = regexp.MustCompile(`(?i)(^|[\s=])[a-z]:[\\/][^\s]+`)
|
||||
telemetryRelativePathPattern = regexp.MustCompile(`(^|[\s=:])\.\.?/[^\s]+`)
|
||||
telemetryEmailPattern = regexp.MustCompile(`(?i)\b[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\b`)
|
||||
telemetryPhonePattern = regexp.MustCompile(`\b\+?\d[\d -]{7,}\d\b`)
|
||||
telemetryOpaqueTokenPattern = regexp.MustCompile(`\b[a-zA-Z0-9_-]{16,}\b`)
|
||||
)
|
||||
|
||||
func telemetryErrorSummary(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
var patError *apperrors.PATError
|
||||
if stderrors.As(err, &patError) {
|
||||
return "permission error"
|
||||
}
|
||||
var rawError apperrors.RawStderrError
|
||||
if stderrors.As(err, &rawError) {
|
||||
return "raw stderr error"
|
||||
}
|
||||
if isUnknownCommandError(err) {
|
||||
return "unknown command"
|
||||
}
|
||||
if match := telemetryUnknownFlagPattern.FindStringSubmatch(err.Error()); len(match) == 2 {
|
||||
return "unknown flag: " + match[1]
|
||||
}
|
||||
return sanitizeTelemetryErrorText(err.Error())
|
||||
}
|
||||
|
||||
func telemetryPanicMessages(value any) (display, summary string) {
|
||||
return fmt.Sprintf("internal panic: %v", value), "internal panic"
|
||||
}
|
||||
|
||||
func sanitizeTelemetryErrorText(message string) string {
|
||||
message = output.SanitizeForTerminal(message)
|
||||
message = telemetryAuthPattern.ReplaceAllString(message, "<credential>")
|
||||
message = telemetrySensitiveFlag.ReplaceAllString(message, "$1=<redacted>")
|
||||
message = telemetrySensitiveValue.ReplaceAllString(message, "$1=<redacted>")
|
||||
message = telemetryURLPattern.ReplaceAllString(message, "<url>")
|
||||
message = telemetryJSONPattern.ReplaceAllString(message, "<payload>")
|
||||
message = redactTelemetryQuotedText(message)
|
||||
message = telemetryUnixPathPattern.ReplaceAllString(message, "$1<path>")
|
||||
message = telemetryWindowsPathPattern.ReplaceAllString(message, "$1<path>")
|
||||
message = telemetryRelativePathPattern.ReplaceAllString(message, "$1<path>")
|
||||
message = telemetryEmailPattern.ReplaceAllString(message, "<email>")
|
||||
message = telemetryPhonePattern.ReplaceAllString(message, "<phone>")
|
||||
message = telemetryOpaqueTokenPattern.ReplaceAllStringFunc(message, func(value string) string {
|
||||
var hasLetter, hasDigit bool
|
||||
for _, r := range value {
|
||||
hasLetter = hasLetter || unicode.IsLetter(r)
|
||||
hasDigit = hasDigit || unicode.IsDigit(r)
|
||||
}
|
||||
if hasLetter && hasDigit {
|
||||
return "<id>"
|
||||
}
|
||||
return value
|
||||
})
|
||||
message = strings.Join(strings.Fields(message), " ")
|
||||
return truncateTelemetryText(message, maxTelemetryErrorRunes)
|
||||
}
|
||||
|
||||
func redactTelemetryQuotedText(message string) string {
|
||||
var result strings.Builder
|
||||
runes := []rune(message)
|
||||
for index := 0; index < len(runes); {
|
||||
quote := runes[index]
|
||||
if quote != '\'' && quote != '"' && quote != '`' {
|
||||
result.WriteRune(quote)
|
||||
index++
|
||||
continue
|
||||
}
|
||||
result.WriteRune(quote)
|
||||
result.WriteString("<redacted>")
|
||||
index++
|
||||
escaped := false
|
||||
for index < len(runes) {
|
||||
current := runes[index]
|
||||
index++
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if current == '\\' && quote != '`' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if current == quote {
|
||||
result.WriteRune(quote)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func truncateTelemetryText(message string, maxRunes int) string {
|
||||
if maxRunes <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(message)
|
||||
if len(runes) <= maxRunes {
|
||||
return message
|
||||
}
|
||||
if maxRunes <= 3 {
|
||||
return string(runes[:maxRunes])
|
||||
}
|
||||
return string(runes[:maxRunes-3]) + "..."
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// 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 (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
type telemetryRawError string
|
||||
|
||||
func (e telemetryRawError) Error() string { return string(e) }
|
||||
func (e telemetryRawError) RawStderr() string { return string(e) }
|
||||
|
||||
func TestCrossPlatformCoverageTelemetryErrorSummaryFixedFamilies(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{name: "nil", want: ""},
|
||||
{name: "PAT", err: &apperrors.PATError{RawJSON: `{"token":"secret"}`}, want: "permission error"},
|
||||
{name: "raw stderr", err: telemetryRawError("raw secret"), want: "raw stderr error"},
|
||||
{name: "unknown command", err: errors.New(`unknown command "secret-value" for "dws"`), want: "unknown command"},
|
||||
{name: "unknown flag", err: errors.New("unknown flag: --token=secret-value"), want: "unknown flag: --token"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := telemetryErrorSummary(tc.err); got != tc.want {
|
||||
t.Fatalf("telemetryErrorSummary() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSanitizeTelemetryErrorText(t *testing.T) {
|
||||
message := "\x1b[31mfailed\x1b[0m " +
|
||||
"--client-secret very-secret " +
|
||||
"--access-token access-secret " +
|
||||
"Authorization: Bearer abcdefghijklmnop1234 " +
|
||||
"url=https://example.test/path?token=secret " +
|
||||
`body={"access_token":"secret"} ` +
|
||||
`user="Alice" email=alice@example.test phone=13800138000 ` +
|
||||
"path=/Users/alice/private.txt relative=./private/secrets.txt id=abcDEF1234567890XYZ"
|
||||
got := sanitizeTelemetryErrorText(message)
|
||||
for _, secret := range []string{
|
||||
"very-secret", "access-secret", "abcdefghijklmnop1234", "example.test", "access_token",
|
||||
"Alice", "alice@example.test", "13800138000", "/Users/alice", "abcDEF1234567890XYZ", "\x1b",
|
||||
"./private/secrets.txt",
|
||||
} {
|
||||
if strings.Contains(got, secret) {
|
||||
t.Fatalf("sanitized telemetry error leaked %q: %q", secret, got)
|
||||
}
|
||||
}
|
||||
for _, marker := range []string{"failed", "<redacted>", "<url>", "<payload>", "<path>", "<id>"} {
|
||||
if !strings.Contains(got, marker) {
|
||||
t.Fatalf("sanitized telemetry error missing %q: %q", marker, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTelemetryErrorTruncationAndPanic(t *testing.T) {
|
||||
message := strings.Repeat("错", maxTelemetryErrorRunes+1)
|
||||
got := sanitizeTelemetryErrorText(message)
|
||||
if len([]rune(got)) != maxTelemetryErrorRunes || !strings.HasSuffix(got, "...") {
|
||||
t.Fatalf("truncated telemetry error rune length = %d suffix = %q", len([]rune(got)), got[len(got)-3:])
|
||||
}
|
||||
display, summary := telemetryPanicMessages("token-secret")
|
||||
if display != "internal panic: token-secret" || summary != "internal panic" || strings.Contains(summary, "token-secret") {
|
||||
t.Fatalf("panic messages = display %q summary %q", display, summary)
|
||||
}
|
||||
if got := truncateTelemetryText("value", 0); got != "" {
|
||||
t.Fatalf("zero-limit truncation = %q", got)
|
||||
}
|
||||
if got := truncateTelemetryText("value", 3); got != "val" {
|
||||
t.Fatalf("short-limit truncation = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTelemetryErrorEscapesAndOpaqueWords(t *testing.T) {
|
||||
const opaqueWord = "abcdefghijklmnop"
|
||||
got := sanitizeTelemetryErrorText(`failed "quoted \"value" ` + opaqueWord)
|
||||
if strings.Contains(got, "value") || !strings.Contains(got, opaqueWord) {
|
||||
t.Fatalf("escaped quote sanitization = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// 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 (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
)
|
||||
|
||||
// TelemetryIdentity is the privacy-reviewed subset of the local authentication
|
||||
// record that may be attached to a CLI execution event.
|
||||
type TelemetryIdentity struct {
|
||||
UserID string
|
||||
UserName string
|
||||
CorpID string
|
||||
}
|
||||
|
||||
var telemetryResolveProfileMetadata = authpkg.ResolveProfileMetadataReadOnly
|
||||
|
||||
// ResolveTelemetryIdentity returns a pre-execution snapshot of the identity
|
||||
// selected by args. Multi-profile executions are attributed to the current
|
||||
// default profile. Resolution is deliberately best-effort: telemetry must not
|
||||
// refresh credentials or change command behavior when local auth data is
|
||||
// missing, invalid, or unreadable.
|
||||
func ResolveTelemetryIdentity(args []string) (identity TelemetryIdentity) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
identity = TelemetryIdentity{}
|
||||
}
|
||||
}()
|
||||
|
||||
selector, specified, valid := preparseProfileSelection(args)
|
||||
if specified && !valid {
|
||||
return TelemetryIdentity{}
|
||||
}
|
||||
profile, err := resolveTelemetryProfileMetadata(defaultConfigDir(), selector)
|
||||
if err != nil || profile == nil {
|
||||
return TelemetryIdentity{}
|
||||
}
|
||||
return TelemetryIdentity{
|
||||
UserID: strings.TrimSpace(profile.UserID),
|
||||
UserName: strings.TrimSpace(profile.UserName),
|
||||
CorpID: strings.TrimSpace(profile.CorpID),
|
||||
}
|
||||
}
|
||||
|
||||
func resolveTelemetryProfileMetadata(configDir, selector string) (*authpkg.ProfileMetadata, error) {
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" || !strings.Contains(selector, ",") {
|
||||
return telemetryResolveProfileMetadata(configDir, selector)
|
||||
}
|
||||
|
||||
// A local profile name may itself contain a comma. Match the runtime
|
||||
// resolver by trying the full selector before interpreting it as CSV.
|
||||
if profile, err := telemetryResolveProfileMetadata(configDir, selector); err == nil && profile != nil {
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
for _, part := range strings.Split(selector, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
return nil, fmt.Errorf("--profile contains an empty profile selector: %q", selector)
|
||||
}
|
||||
profile, err := telemetryResolveProfileMetadata(configDir, part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if profile == nil {
|
||||
return nil, fmt.Errorf("profile %q not found", part)
|
||||
}
|
||||
}
|
||||
return telemetryResolveProfileMetadata(configDir, "")
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// 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 (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryIdentityProfileSelection(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", "/telemetry-config")
|
||||
profiles := map[string]*authpkg.ProfileMetadata{
|
||||
"": {UserID: " default-user ", UserName: " Default User ", CorpID: " default-corp "},
|
||||
"corp-a": {UserID: "user-a", UserName: "Alice", CorpID: "corp-a"},
|
||||
"corp-b": {UserID: "user-b", UserName: "Bob", CorpID: "corp-b"},
|
||||
"alpha,beta": {UserID: "comma-user", UserName: "Comma User", CorpID: "comma-corp"},
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
profiles map[string]*authpkg.ProfileMetadata
|
||||
wantCalls []string
|
||||
want TelemetryIdentity
|
||||
}{
|
||||
{name: "default profile", args: []string{"version"}, profiles: profiles, wantCalls: []string{""}, want: TelemetryIdentity{UserID: "default-user", UserName: "Default User", CorpID: "default-corp"}},
|
||||
{name: "single profile", args: []string{"--profile", "corp-a", "version"}, profiles: profiles, wantCalls: []string{"corp-a"}, want: TelemetryIdentity{UserID: "user-a", UserName: "Alice", CorpID: "corp-a"}},
|
||||
{name: "equals form after command", args: []string{"version", "--profile=corp-b"}, profiles: profiles, wantCalls: []string{"corp-b"}, want: TelemetryIdentity{UserID: "user-b", UserName: "Bob", CorpID: "corp-b"}},
|
||||
{name: "last repeated profile", args: []string{"--profile", "corp-a", "version", "--profile=corp-b"}, profiles: profiles, wantCalls: []string{"corp-b"}, want: TelemetryIdentity{UserID: "user-b", UserName: "Bob", CorpID: "corp-b"}},
|
||||
{name: "comma profile name", args: []string{"--profile", "alpha,beta", "version"}, profiles: profiles, wantCalls: []string{"alpha,beta"}, want: TelemetryIdentity{UserID: "comma-user", UserName: "Comma User", CorpID: "comma-corp"}},
|
||||
{name: "multi profile uses default", args: []string{"--profile", "corp-a,corp-b", "version"}, profiles: profiles, wantCalls: []string{"corp-a,corp-b", "corp-a", "corp-b", ""}, want: TelemetryIdentity{UserID: "default-user", UserName: "Default User", CorpID: "default-corp"}},
|
||||
{name: "unquoted multi profile", args: []string{"--profile", "corp-a,", "corp-b", "version"}, profiles: profiles, wantCalls: []string{"corp-a,corp-b", "corp-a", "corp-b", ""}, want: TelemetryIdentity{UserID: "default-user", UserName: "Default User", CorpID: "default-corp"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var calls []string
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, func(configDir, selector string) (*authpkg.ProfileMetadata, error) {
|
||||
if configDir != "/telemetry-config" {
|
||||
t.Fatalf("resolver config dir = %q", configDir)
|
||||
}
|
||||
calls = append(calls, selector)
|
||||
profile := tc.profiles[selector]
|
||||
if profile == nil {
|
||||
return nil, errors.New("profile not found")
|
||||
}
|
||||
clone := *profile
|
||||
return &clone, nil
|
||||
})
|
||||
|
||||
if got := ResolveTelemetryIdentity(tc.args); got != tc.want {
|
||||
t.Fatalf("ResolveTelemetryIdentity() = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
if !reflect.DeepEqual(calls, tc.wantCalls) {
|
||||
t.Fatalf("metadata selectors = %#v, want %#v", calls, tc.wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryIdentityRejectsMissingProfileValue(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"version", "--profile"},
|
||||
{"--profile=corp-a", "version", "--profile="},
|
||||
{"--profile", "--debug", "version"},
|
||||
} {
|
||||
t.Run(strings.Join(args, "_"), func(t *testing.T) {
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, func(string, string) (*authpkg.ProfileMetadata, error) {
|
||||
t.Fatal("invalid profile syntax attempted metadata resolution")
|
||||
return nil, nil
|
||||
})
|
||||
if got := ResolveTelemetryIdentity(args); got != (TelemetryIdentity{}) {
|
||||
t.Fatalf("invalid profile identity = %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryIdentityFailsClosed(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", "/telemetry-config")
|
||||
fail := errors.New("metadata unavailable")
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
resolve func(string, string) (*authpkg.ProfileMetadata, error)
|
||||
}{
|
||||
{name: "read error", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { return nil, fail }},
|
||||
{name: "missing profile", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { return nil, nil }},
|
||||
{name: "empty fields", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { return &authpkg.ProfileMetadata{}, nil }},
|
||||
{name: "resolver panic", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { panic("metadata failure") }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, tc.resolve)
|
||||
if got := ResolveTelemetryIdentity(nil); got != (TelemetryIdentity{}) {
|
||||
t.Fatalf("failed-closed identity = %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryIdentityRejectsInvalidMultiProfile(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", "/telemetry-config")
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, func(_ string, selector string) (*authpkg.ProfileMetadata, error) {
|
||||
if selector == "corp-a" {
|
||||
return &authpkg.ProfileMetadata{UserID: "user-a", CorpID: "corp-a"}, nil
|
||||
}
|
||||
return nil, errors.New("profile not found")
|
||||
})
|
||||
if got := ResolveTelemetryIdentity([]string{"--profile", "corp-a,missing", "version"}); got != (TelemetryIdentity{}) {
|
||||
t.Fatalf("invalid multi-profile identity = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryProfileMetadataRejectsMalformedMulti(t *testing.T) {
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, func(_ string, selector string) (*authpkg.ProfileMetadata, error) {
|
||||
switch selector {
|
||||
case "corp-a,,corp-b":
|
||||
return nil, errors.New("not a literal profile")
|
||||
case "corp-a":
|
||||
return &authpkg.ProfileMetadata{UserID: "user-a"}, nil
|
||||
case "missing":
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, errors.New("unexpected selector")
|
||||
}
|
||||
})
|
||||
if _, err := resolveTelemetryProfileMetadata("/config", "corp-a,,corp-b"); err == nil || !strings.Contains(err.Error(), "empty profile selector") {
|
||||
t.Fatalf("empty multi-profile selector error = %v", err)
|
||||
}
|
||||
if _, err := resolveTelemetryProfileMetadata("/config", "corp-a,missing"); err == nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("missing multi-profile selector error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// 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 auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProfileMetadata is the minimal, non-sensitive identity projection exposed to
|
||||
// telemetry callers. It intentionally excludes profile names, client IDs,
|
||||
// organization names, token material, and credential status.
|
||||
type ProfileMetadata struct {
|
||||
UserID string
|
||||
UserName string
|
||||
CorpID string
|
||||
}
|
||||
|
||||
// ResolveProfileMetadataReadOnly resolves one identity exclusively from the
|
||||
// non-sensitive profiles.json metadata. It deliberately avoids auth locks,
|
||||
// token stores, Keychain access, migrations, quarantine renames, and writes.
|
||||
// A missing metadata file or an empty current profile returns (nil, nil).
|
||||
func ResolveProfileMetadataReadOnly(configDir, selector string) (*ProfileMetadata, error) {
|
||||
data, err := profilesReadFile(ProfilesPath(configDir))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read profile metadata: %w", err)
|
||||
}
|
||||
|
||||
var cfg ProfilesConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse profile metadata: %w", err)
|
||||
}
|
||||
if cfg.Version > profilesMaxVersion {
|
||||
return nil, fmt.Errorf("profile metadata version %d is newer than supported version %d", cfg.Version, profilesMaxVersion)
|
||||
}
|
||||
normalizeProfilesConfig(&cfg)
|
||||
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" {
|
||||
selector = strings.TrimSpace(cfg.CurrentProfile)
|
||||
if selector == "" {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
profile, _, err := resolveProfileSelection("", &cfg, selector)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ProfileMetadata{
|
||||
UserID: profile.UserID,
|
||||
UserName: profile.UserName,
|
||||
CorpID: profile.CorpID,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// 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 auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageResolveProfileMetadataReadOnly(t *testing.T) {
|
||||
const metadata = `{
|
||||
"version": 3,
|
||||
"currentProfile": "corp-a:user-a",
|
||||
"profiles": [
|
||||
{"name":"alpha","corpId":"corp-a","userId":"user-a","userName":"Alice"},
|
||||
{"name":"beta","corpId":"corp-b","userId":"user-b","userName":"Bob"}
|
||||
]
|
||||
}`
|
||||
reads := 0
|
||||
testseam.Swap(t, &profilesReadFile, func(path string) ([]byte, error) {
|
||||
reads++
|
||||
if !strings.HasSuffix(path, profilesJSONFile) {
|
||||
t.Fatalf("metadata path = %q", path)
|
||||
}
|
||||
return []byte(metadata), nil
|
||||
})
|
||||
|
||||
current, err := ResolveProfileMetadataReadOnly("/config", "")
|
||||
if err != nil || current == nil || current.UserID != "user-a" || current.UserName != "Alice" || current.CorpID != "corp-a" {
|
||||
t.Fatalf("current metadata profile = %#v, %v", current, err)
|
||||
}
|
||||
explicit, err := ResolveProfileMetadataReadOnly("/config", "beta")
|
||||
if err != nil || explicit == nil || explicit.UserID != "user-b" || explicit.CorpID != "corp-b" {
|
||||
t.Fatalf("explicit metadata profile = %#v, %v", explicit, err)
|
||||
}
|
||||
if reads != 2 {
|
||||
t.Fatalf("profile metadata reads = %d, want 2", reads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveProfileMetadataReadOnlyFailsClosed(t *testing.T) {
|
||||
fail := errors.New("read failed")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
read func(string) ([]byte, error)
|
||||
wantErr string
|
||||
}{
|
||||
{name: "missing", read: func(string) ([]byte, error) { return nil, os.ErrNotExist }},
|
||||
{name: "read error", read: func(string) ([]byte, error) { return nil, fail }, wantErr: "read profile metadata"},
|
||||
{name: "corrupt", read: func(string) ([]byte, error) { return []byte("{"), nil }, wantErr: "parse profile metadata"},
|
||||
{name: "forward version", read: func(string) ([]byte, error) { return []byte(`{"version":999}`), nil }, wantErr: "newer than supported"},
|
||||
{name: "no current", read: func(string) ([]byte, error) { return []byte(`{"version":3,"profiles":[]}`), nil }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testseam.Swap(t, &profilesReadFile, tc.read)
|
||||
testseam.Swap(t, &profilesRename, func(string, string) error {
|
||||
t.Fatal("read-only metadata resolution attempted a quarantine rename")
|
||||
return nil
|
||||
})
|
||||
got, err := ResolveProfileMetadataReadOnly("/config", "")
|
||||
if tc.wantErr == "" {
|
||||
if err != nil || got != nil {
|
||||
t.Fatalf("read-only metadata = %#v, %v", got, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) || got != nil {
|
||||
t.Fatalf("read-only metadata = %#v, %v; want %q", got, err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveProfileMetadataReadOnlyRejectsUnknownSelector(t *testing.T) {
|
||||
testseam.Swap(t, &profilesReadFile, func(string) ([]byte, error) {
|
||||
return []byte(`{"version":3,"currentProfile":"corp-a:user-a","profiles":[{"name":"alpha","corpId":"corp-a","userId":"user-a"}]}`), nil
|
||||
})
|
||||
profile, err := ResolveProfileMetadataReadOnly("/config", "missing")
|
||||
if err == nil || profile != nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("unknown read-only profile = %#v, %v", profile, err)
|
||||
}
|
||||
}
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
# AEM Go SDK offline snapshot
|
||||
|
||||
This directory contains the source packages required by the DWS CLI from the
|
||||
private AEM Go SDK. The root module uses a local `replace` directive so builds
|
||||
do not need access to `gitlab.alibaba-inc.com`.
|
||||
|
||||
- Upstream module: `gitlab.alibaba-inc.com/aes/aem-go-sdk`
|
||||
- Upstream version: `v0.3.0`
|
||||
- Upstream commit: `2b5103b2f8899fa6e96611389c655e189a107f3b`
|
||||
- Snapshot packages: `aem`, `clitrack`, `internal/encoder`, `internal/sender`
|
||||
|
||||
DWS carries one reviewed privacy extension on top of the upstream snapshot:
|
||||
`clitrack.Config.NoAutomaticDimensions` disables the SDK's device, operating
|
||||
system, locale, session, and other automatic dimensions. The official DWS
|
||||
entrypoint enables this mode and tests the final encoded payload as an exact
|
||||
field whitelist.
|
||||
|
||||
The upstream `v0.3.0` source tree did not contain a `LICENSE`, `NOTICE`, or
|
||||
`COPYING` file. No replacement license text has been invented in this snapshot.
|
||||
Redistribution authorization is managed by the repository owners.
|
||||
Vendored
+264
@@ -0,0 +1,264 @@
|
||||
package aem
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitlab.alibaba-inc.com/aes/aem-go-sdk/internal/encoder"
|
||||
)
|
||||
|
||||
// AES 协议固定常量(不可修改,与 JS @ali/aes-tracker v3.3.18 对齐)。
|
||||
const (
|
||||
sdkVersion = "3.3.18"
|
||||
platformGo = "go"
|
||||
uuidCharset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz" // 60 字符(缺 Y 和 j)
|
||||
uuidLength = 20
|
||||
)
|
||||
|
||||
const (
|
||||
configPID = "pid"
|
||||
configAppName = "app_name"
|
||||
configEnv = "env"
|
||||
configEndpoint = "endpoint"
|
||||
configVersion = "version"
|
||||
configAsync = "async"
|
||||
configQueueSize = "queue_size"
|
||||
// configDisableAutoDimensions is a local privacy control. It is never
|
||||
// serialized; callers that enable it receive only explicitly configured
|
||||
// dimensions plus app_version derived from version.
|
||||
configDisableAutoDimensions = "disable_auto_dimensions"
|
||||
)
|
||||
|
||||
// Config 是 SDK 接入参数,key 与 AEM 协议字段保持一致。
|
||||
//
|
||||
// 仅 pid 必填,其余字段为空时由 applyDefaults 填充默认值。Go SDK 会上报
|
||||
// 服务端适用的公共维度,例如 app_name、env、version、uid、username、
|
||||
// user_type、dim1~dim10、sid、bucket_id、ext。async、queue_size、endpoint
|
||||
// 是 SDK 本地控制项,不会作为公共维度上报。
|
||||
type Config map[string]interface{}
|
||||
|
||||
var supportedSendConfigKeys = map[string]struct{}{
|
||||
configPID: {},
|
||||
configAppName: {},
|
||||
configEnv: {},
|
||||
configVersion: {},
|
||||
"user_type": {},
|
||||
"uid": {},
|
||||
"username": {},
|
||||
"sid": {},
|
||||
"bucket_id": {},
|
||||
"ext": {},
|
||||
"platform": {},
|
||||
}
|
||||
|
||||
func init() {
|
||||
for i := 1; i <= 10; i++ {
|
||||
supportedSendConfigKeys[fmt.Sprintf("dim%d", i)] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func cloneConfig(c Config) Config {
|
||||
cloned := make(Config, len(c))
|
||||
for k, v := range c {
|
||||
cloned[k] = v
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
// applyDefaults 为可选字段填充默认值,并返回新的配置副本。
|
||||
func applyDefaults(c Config) Config {
|
||||
cloned := cloneConfig(c)
|
||||
if stringValue(cloned, configAppName) == "" {
|
||||
cloned[configAppName] = "unknown"
|
||||
}
|
||||
if stringValue(cloned, configEnv) == "" {
|
||||
cloned[configEnv] = "prod"
|
||||
}
|
||||
if stringValue(cloned, configEndpoint) == "" {
|
||||
cloned[configEndpoint] = "gm.mmstat.com"
|
||||
}
|
||||
if stringValue(cloned, configVersion) == "" {
|
||||
cloned[configVersion] = "unknown"
|
||||
}
|
||||
if _, ok := cloned[configAsync]; !ok {
|
||||
cloned[configAsync] = true
|
||||
}
|
||||
if intValue(cloned, configQueueSize) <= 0 {
|
||||
cloned[configQueueSize] = 1000
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
// buildSendConfig 构建 gokey 编码所需的全局维度 map。
|
||||
//
|
||||
// 默认包含 AES 协议的 sdk_version/platform/device_id/os/os_version/app_name/
|
||||
// app_version/pv_id/timezone_offset 自动采集字段,加上用户配置的 pid/env/uid/
|
||||
// username/version/user_type/dim1~dim10/sid/bucket_id/ext 等公共维度。启用
|
||||
// disable_auto_dimensions 时仅保留显式配置字段和由 version 派生的 app_version。
|
||||
// 空值字段不会被写入;page_id、utm_* 等页面/浏览器字段不会在 Go SDK 中上报。
|
||||
func buildSendConfig(c Config) map[string]string {
|
||||
m := make(map[string]string, len(c)+8)
|
||||
for k, v := range c {
|
||||
if _, ok := supportedSendConfigKeys[k]; !ok {
|
||||
continue
|
||||
}
|
||||
if s, ok := encoder.ItemToString(v); ok {
|
||||
m[k] = s
|
||||
}
|
||||
}
|
||||
if boolValue(c, configDisableAutoDimensions) {
|
||||
if v, ok := m["version"]; ok && v != "" {
|
||||
m["app_version"] = v
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
m["sdk_version"] = sdkVersion
|
||||
if _, ok := m["platform"]; !ok {
|
||||
m["platform"] = platformGo
|
||||
}
|
||||
m["device_id"] = getDeviceID()
|
||||
m["os"] = capitalizeOS()
|
||||
m["os_version"] = getOSVersion()
|
||||
if v, ok := m["version"]; ok && v != "" {
|
||||
m["app_version"] = v
|
||||
} else {
|
||||
m["app_version"] = runtime.Version()
|
||||
}
|
||||
m["pv_id"] = generateUUID()
|
||||
m["timezone_offset"] = getTimezoneOffset()
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func stringValue(c Config, key string) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
v, ok := c[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
s, ok := encoder.ItemToString(v)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func boolValue(c Config, key string) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
v, ok := c[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case bool:
|
||||
return val
|
||||
case string:
|
||||
b, err := strconv.ParseBool(val)
|
||||
return err == nil && b
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func intValue(c Config, key string) int {
|
||||
s := stringValue(c, key)
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// getMAC 返回第一个非零的网卡 MAC 地址;获取失败时返回 "00:00:00:00:00:00"。
|
||||
func getMAC() string {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return "00:00:00:00:00:00"
|
||||
}
|
||||
for _, iface := range ifaces {
|
||||
mac := iface.HardwareAddr
|
||||
if len(mac) == 0 {
|
||||
continue
|
||||
}
|
||||
macStr := mac.String()
|
||||
if macStr != "" && macStr != "00:00:00:00:00:00" {
|
||||
return macStr
|
||||
}
|
||||
}
|
||||
return "00:00:00:00:00:00"
|
||||
}
|
||||
|
||||
// getDeviceID 用 MAC 地址的 MD5 hex 作为设备指纹。
|
||||
func getDeviceID() string {
|
||||
hash := md5.Sum([]byte(getMAC()))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// getOSVersion 调用系统命令获取内核版本字符串。
|
||||
func getOSVersion() string {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
out, err := exec.Command("cmd", "/c", "ver").Output()
|
||||
if err != nil {
|
||||
return "Windows"
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
default:
|
||||
out, err := exec.Command("uname", "-r").Output()
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
}
|
||||
|
||||
// getTimezoneOffset 对齐 JS Date.getTimezoneOffset()。东八区返回 "-480"。
|
||||
func getTimezoneOffset() string {
|
||||
_, offset := time.Now().Zone()
|
||||
return fmt.Sprintf("%d", -(offset / 60))
|
||||
}
|
||||
|
||||
// capitalizeOS 把 runtime.GOOS 首字母大写("darwin" → "Darwin")。
|
||||
func capitalizeOS() string {
|
||||
osName := runtime.GOOS
|
||||
if len(osName) == 0 {
|
||||
return osName
|
||||
}
|
||||
return strings.ToUpper(osName[:1]) + osName[1:]
|
||||
}
|
||||
|
||||
// generateUUID 生成 20 字符的随机 ID,字符集与 JS 版严格一致。
|
||||
//
|
||||
// 使用 rejection sampling 保证字符分布均匀(拒绝阈值 240 = 60*4)。
|
||||
func generateUUID() string {
|
||||
charsetLen := len(uuidCharset) // 60
|
||||
maxByte := byte(256 - 256%charsetLen) // 240
|
||||
result := make([]byte, uuidLength)
|
||||
buf := make([]byte, 1)
|
||||
for i := 0; i < uuidLength; i++ {
|
||||
for {
|
||||
_, _ = rand.Read(buf)
|
||||
if buf[0] < maxByte {
|
||||
result[i] = uuidCharset[buf[0]%byte(charsetLen)]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package aem
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildSendConfigPublishesCLIIdentityAndVersion(t *testing.T) {
|
||||
got := buildSendConfig(Config{
|
||||
"pid": "pid-1",
|
||||
"app_name": "dws",
|
||||
"version": "v1.2.3",
|
||||
"uid": "user-1",
|
||||
})
|
||||
|
||||
for key, want := range map[string]string{
|
||||
"version": "v1.2.3",
|
||||
"app_version": "v1.2.3",
|
||||
"uid": "user-1",
|
||||
} {
|
||||
if got[key] != want {
|
||||
t.Fatalf("send config %s = %q, want %q", key, got[key], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSendConfigCanDisableAutomaticDimensions(t *testing.T) {
|
||||
got := buildSendConfig(Config{
|
||||
"pid": "pid-1",
|
||||
"app_name": "dws",
|
||||
"env": "prod",
|
||||
"version": "v1.2.3",
|
||||
"platform": "cli",
|
||||
"uid": "user-1",
|
||||
"username": "Alice",
|
||||
configDisableAutoDimensions: true,
|
||||
})
|
||||
|
||||
gotKeys := make([]string, 0, len(got))
|
||||
for key := range got {
|
||||
gotKeys = append(gotKeys, key)
|
||||
}
|
||||
sort.Strings(gotKeys)
|
||||
wantKeys := []string{"app_name", "app_version", "env", "pid", "platform", "uid", "username", "version"}
|
||||
if !slices.Equal(gotKeys, wantKeys) {
|
||||
t.Fatalf("privacy send config keys = %v, want %v", gotKeys, wantKeys)
|
||||
}
|
||||
for _, key := range []string{"device_id", "ext", "os", "os_version", "pv_id", "sdk_version", "sid", "timezone_offset"} {
|
||||
if _, ok := got[key]; ok {
|
||||
t.Fatalf("privacy send config contains automatic dimension %q: %#v", key, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
package aem
|
||||
|
||||
// Event 是一次上报的载荷。Type 决定字段语义(api/event 等),
|
||||
// Fields 携带 p1~p20、c1~c10、ext 以及 url、status 等业务维度。
|
||||
//
|
||||
// 字段命名约定:
|
||||
// - 通用字段:type、ts(毫秒)
|
||||
// - 自定义事件:type=event,p1 为事件 ID,p4 为事件类型(EXP/CLK/SLD/INPUT/SYS/OTHER)
|
||||
// - 平台保留:p1~p20(不同 type 含义不同,详见 AES 协议文档)
|
||||
// - 用户自定义:c1~c10
|
||||
// - 扩展 JSON:ext
|
||||
//
|
||||
// Fields 中值为空字符串的键会在序列化时被自动丢弃。
|
||||
type Event struct {
|
||||
// Type 事件类型,如 "api"、"event"。必填。
|
||||
Type string
|
||||
|
||||
// Fields 业务字段。Track 时会补全 ts;其余键的语义由调用方决定。
|
||||
Fields map[string]string
|
||||
}
|
||||
|
||||
// toMap 把 Event 序列化为 SDK 内部使用的 map 形式,过滤空值。
|
||||
//
|
||||
// 注意:返回的 map 是新分配的,调用方修改不会影响原 Event。
|
||||
func (e Event) toMap() map[string]string {
|
||||
m := make(map[string]string, len(e.Fields)+1)
|
||||
if e.Type != "" {
|
||||
m["type"] = e.Type
|
||||
}
|
||||
for k, v := range e.Fields {
|
||||
if v != "" {
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
// Package aem 提供阿里巴巴 AEM (Application Experience Management) 平台的 Go 上报 SDK。
|
||||
//
|
||||
// 它适用于任何 Go 程序(web server、cron job、library 等),通过 Tracker.Track
|
||||
// 将埋点事件加入后台队列并异步上报到 AES 后端。
|
||||
//
|
||||
// 简单用法:
|
||||
//
|
||||
// tracker := aem.NewTracker(aem.Config{
|
||||
// "pid": "your_project_id",
|
||||
// "app_name": "my-service",
|
||||
// "env": "prod",
|
||||
// "version": "1.0.0",
|
||||
// })
|
||||
// defer tracker.Close()
|
||||
//
|
||||
// tracker.Track(aem.Event{
|
||||
// Type: "api",
|
||||
// Fields: map[string]string{
|
||||
// "url": "/api/user", "status": "200", "duration": "120",
|
||||
// },
|
||||
// })
|
||||
package aem
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitlab.alibaba-inc.com/aes/aem-go-sdk/internal/encoder"
|
||||
"gitlab.alibaba-inc.com/aes/aem-go-sdk/internal/sender"
|
||||
)
|
||||
|
||||
// ErrQueueFull 表示异步上报队列已满,本次事件没有入队。
|
||||
var ErrQueueFull = errors.New("aem: async queue is full")
|
||||
|
||||
// ErrTrackerClosed 表示 Tracker 已关闭,不能再接收新的事件。
|
||||
var ErrTrackerClosed = errors.New("aem: tracker is closed")
|
||||
|
||||
// Tracker 是 SDK 的核心入口,封装了配置、全局维度和上报通道。
|
||||
//
|
||||
// 一个进程通常只创建一个 Tracker,并发调用 Track 是安全的。生命周期内
|
||||
// sendCfg 只构建一次以降低开销。
|
||||
type Tracker struct {
|
||||
config Config
|
||||
sendCfg map[string]string
|
||||
|
||||
async bool
|
||||
queue chan string
|
||||
workerDone chan struct{}
|
||||
|
||||
mu sync.RWMutex
|
||||
closed bool
|
||||
asyncErr error
|
||||
}
|
||||
|
||||
// NewTracker 创建并初始化一个 Tracker。默认会立刻填充并采集设备维度
|
||||
// (MAC、OS、PVID 等);启用 disable_auto_dimensions 时跳过这些采集。
|
||||
func NewTracker(c Config) *Tracker {
|
||||
cfg := applyDefaults(c)
|
||||
t := &Tracker{
|
||||
config: cfg,
|
||||
sendCfg: buildSendConfig(cfg),
|
||||
async: boolValue(cfg, configAsync),
|
||||
}
|
||||
if t.async {
|
||||
t.queue = make(chan string, intValue(cfg, configQueueSize))
|
||||
t.workerDone = make(chan struct{})
|
||||
go t.runWorker()
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// Track 上报一个事件,失败时返回 error。
|
||||
//
|
||||
// 如果 Event 没有自带 ts 字段,Track 会自动补 time.Now().UnixMilli()。
|
||||
// 默认 async=true 时,Track 只负责把事件写入内存队列,不等待远端 HTTP 请求完成;
|
||||
// 返回 error 仅表示参数校验、队列已满或 Tracker 已关闭。async=false 时,Track
|
||||
// 会在当前 goroutine 内同步发送并返回远端发送结果。
|
||||
func (t *Tracker) Track(event Event) error {
|
||||
if stringValue(t.config, configPID) == "" {
|
||||
return errors.New(`aem: config field "pid" is required`)
|
||||
}
|
||||
if event.Type == "" {
|
||||
return errors.New("aem: Event.Type is required")
|
||||
}
|
||||
|
||||
fields := event.toMap()
|
||||
if _, ok := fields["ts"]; !ok {
|
||||
fields["ts"] = fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||
}
|
||||
|
||||
gokey := encoder.ProcessData([]map[string]string{fields}, t.sendCfg)
|
||||
|
||||
if !t.async {
|
||||
t.mu.RLock()
|
||||
closed := t.closed
|
||||
t.mu.RUnlock()
|
||||
if closed {
|
||||
return ErrTrackerClosed
|
||||
}
|
||||
return t.send(gokey)
|
||||
}
|
||||
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
if t.closed {
|
||||
return ErrTrackerClosed
|
||||
}
|
||||
select {
|
||||
case t.queue <- gokey:
|
||||
return nil
|
||||
default:
|
||||
return ErrQueueFull
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tracker) runWorker() {
|
||||
defer close(t.workerDone)
|
||||
for gokey := range t.queue {
|
||||
if err := t.send(gokey); err != nil {
|
||||
t.asyncErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// userAgent builds the User-Agent header from config: "app_name/version".
|
||||
func (t *Tracker) userAgent() string {
|
||||
name := stringValue(t.config, configAppName)
|
||||
ver := stringValue(t.config, configVersion)
|
||||
if name == "" || name == "unknown" {
|
||||
return ""
|
||||
}
|
||||
if ver != "" && ver != "unknown" {
|
||||
return name + "/" + ver
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func (t *Tracker) send(gokey string) error {
|
||||
return sender.Send(stringValue(t.config, configEndpoint), gokey, t.userAgent())
|
||||
}
|
||||
|
||||
// Config 返回 Tracker 当前使用的配置(副本)。
|
||||
//
|
||||
// 返回值是副本,修改它不会影响 Tracker 内部配置。
|
||||
func (t *Tracker) Config() Config {
|
||||
return cloneConfig(t.config)
|
||||
}
|
||||
|
||||
// Close 释放 Tracker 持有的资源。
|
||||
//
|
||||
// async=true 时,Close 会停止接收新事件,并等待队列中已入队的事件发送完成。
|
||||
// 如果后台发送发生错误,Close 返回最后一次发送错误。
|
||||
func (t *Tracker) Close() error {
|
||||
if !t.async {
|
||||
t.mu.Lock()
|
||||
t.closed = true
|
||||
t.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
if !t.closed {
|
||||
t.closed = true
|
||||
close(t.queue)
|
||||
}
|
||||
workerDone := t.workerDone
|
||||
t.mu.Unlock()
|
||||
|
||||
<-workerDone
|
||||
return t.asyncErr
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package clitrack
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// captureStdout 拦截 stdout,将 fn 的输出同时写到原 stdout 和 buffer。
|
||||
// 返回捕获到的输出内容和 fn 的 error。Pipe 创建失败时降级为不捕获,
|
||||
// 保证 fn 一定被执行。
|
||||
//
|
||||
// 注意:这会临时替换 os.Stdout,对依赖 TTY 检测、进度条、ANSI 颜色的 CLI
|
||||
// 可能有副作用——这正是 CaptureOutput 默认关闭的原因。
|
||||
func captureStdout(fn func() error) (string, error) {
|
||||
old := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
return "", fn()
|
||||
}
|
||||
os.Stdout = w
|
||||
|
||||
var buf bytes.Buffer
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_, _ = io.Copy(io.MultiWriter(old, &buf), r)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
execErr := fn()
|
||||
|
||||
w.Close()
|
||||
<-done
|
||||
os.Stdout = old
|
||||
|
||||
return buf.String(), execErr
|
||||
}
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
package clitrack
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitlab.alibaba-inc.com/aes/aem-go-sdk/aem"
|
||||
)
|
||||
|
||||
// 默认值。
|
||||
const (
|
||||
defaultEventID = "cli.exec" // p1:AEM 自定义事件 ID
|
||||
eventTypeSys = "SYS" // p4:AEM 事件类型,系统事件
|
||||
defaultOutputLen = 500 // c8 截断长度
|
||||
maxErrorLen = 200 // c5 截断长度
|
||||
)
|
||||
|
||||
// defaultFlushTimeout 是 CLI 退出前等待异步队列 flush 的最长时间。
|
||||
// 到点未发完就放弃上报直接退出——宁可丢这条埋点,也不让用户等。
|
||||
const defaultFlushTimeout = 300 * time.Millisecond
|
||||
|
||||
// Config 是 clitrack 接入配置。只有 PID 必填,其余都有合理默认值。
|
||||
type Config struct {
|
||||
// —— 必填 ——
|
||||
PID string // AEM 项目 ID
|
||||
|
||||
// —— 应用维度 ——
|
||||
App string // CLI 名称,默认 "unknown"
|
||||
Env string // 环境:prod/pre/daily,默认 prod
|
||||
Version string // CLI 版本号(建议用 ldflags 注入)
|
||||
|
||||
// —— 用户维度(可选,接入方自己填,本包不读取任何凭据文件)——
|
||||
UID string // 用户 ID,如工号
|
||||
Username string // 用户名
|
||||
UserType string // 账号类型,如 "14"
|
||||
|
||||
// —— 行为 ——
|
||||
EventID string // p1 事件 ID,默认 "cli.exec"
|
||||
Endpoint string // 上报域名,海外站点填 sg.mmstat.com;默认走 SDK 默认
|
||||
|
||||
// CaptureOutput 控制是否捕获 stdout 到 c8。默认 false。
|
||||
// 捕获会用 os.Pipe 劫持 os.Stdout,可能干扰进度条/TTY 检测/颜色输出,
|
||||
// 仅在确认 CLI 输出适合采集时开启。
|
||||
CaptureOutput bool
|
||||
OutputMaxLen int // c8 截断长度,默认 500;仅在 CaptureOutput 时生效
|
||||
|
||||
// FlushTimeout 是退出前等待上报完成的最长时间,默认 300ms。
|
||||
FlushTimeout time.Duration
|
||||
|
||||
// —— 字段级隐私开关(给接入开发者的编译期选项,默认采集)——
|
||||
NoCommandLine bool // 不采 c2 完整命令行(命令行常带敏感参数时设 true)
|
||||
NoCwd bool // 不采 c7 工作目录
|
||||
// NoAutomaticDimensions 只保留接入方显式配置的公共维度,并关闭
|
||||
// device_id、os、os_version、timezone_offset、pv_id、sdk_version、sid、
|
||||
// ext.language 与 c6 Shell 自动采集。
|
||||
NoAutomaticDimensions bool
|
||||
|
||||
// —— 扩展钩子 ——
|
||||
// ExtraFields 返回的字段会合并进事件,用于补充 c9/c10/ext 等自定义维度。
|
||||
// 不要覆盖 c1~c8 的约定语义,否则破坏跨 CLI 聚合。空值字段会被忽略。
|
||||
ExtraFields func() map[string]string
|
||||
}
|
||||
|
||||
// Tracker 是埋点实例,通过 New 创建,通过 Run 执行 CLI 并自动上报。
|
||||
type Tracker struct {
|
||||
inner *aem.Tracker
|
||||
|
||||
eventID string
|
||||
captureOutput bool
|
||||
outputMaxLen int
|
||||
flushTimeout time.Duration
|
||||
noCommandLine bool
|
||||
noCwd bool
|
||||
noAutomaticDimensions bool
|
||||
extraFields func() map[string]string
|
||||
}
|
||||
|
||||
// New 根据配置创建 Tracker。
|
||||
//
|
||||
// 如果 PID 为空,返回空实例:Run 仍可正常执行 CLI,只是不上报。这样接入方
|
||||
// 在缺少 PID(如本地开发)时无需加任何判断,埋点自动降级为 no-op。
|
||||
func New(cfg Config) *Tracker {
|
||||
if cfg.PID == "" {
|
||||
return &Tracker{}
|
||||
}
|
||||
|
||||
env := cfg.Env
|
||||
if env == "" {
|
||||
env = "prod"
|
||||
}
|
||||
app := cfg.App
|
||||
if app == "" {
|
||||
app = "unknown"
|
||||
}
|
||||
eventID := cfg.EventID
|
||||
if eventID == "" {
|
||||
eventID = defaultEventID
|
||||
}
|
||||
outputMaxLen := cfg.OutputMaxLen
|
||||
if outputMaxLen <= 0 {
|
||||
outputMaxLen = defaultOutputLen
|
||||
}
|
||||
flushTimeout := cfg.FlushTimeout
|
||||
if flushTimeout <= 0 {
|
||||
flushTimeout = defaultFlushTimeout
|
||||
}
|
||||
|
||||
aemCfg := aem.Config{
|
||||
"pid": cfg.PID,
|
||||
"app_name": app,
|
||||
"env": env,
|
||||
"version": cfg.Version,
|
||||
"platform": "cli",
|
||||
}
|
||||
if cfg.NoAutomaticDimensions {
|
||||
aemCfg["disable_auto_dimensions"] = true
|
||||
}
|
||||
if cfg.Endpoint != "" {
|
||||
aemCfg["endpoint"] = cfg.Endpoint
|
||||
}
|
||||
if cfg.UID != "" {
|
||||
aemCfg["uid"] = cfg.UID
|
||||
}
|
||||
if cfg.Username != "" {
|
||||
aemCfg["username"] = cfg.Username
|
||||
}
|
||||
if cfg.UserType != "" {
|
||||
aemCfg["user_type"] = cfg.UserType
|
||||
}
|
||||
|
||||
if !cfg.NoAutomaticDimensions {
|
||||
// sid:终端会话 ID,自动从环境变量采集(数据维度,非配置开关)。
|
||||
sid := os.Getenv("TERM_SESSION_ID")
|
||||
if sid == "" {
|
||||
sid = os.Getenv("TMUX_PANE")
|
||||
}
|
||||
if sid != "" {
|
||||
aemCfg["sid"] = sid
|
||||
}
|
||||
|
||||
// ext.language:终端 locale,自动采集。
|
||||
lang := os.Getenv("LANG")
|
||||
if lang == "" {
|
||||
lang = os.Getenv("LC_ALL")
|
||||
}
|
||||
if lang != "" {
|
||||
aemCfg["ext"] = fmt.Sprintf(`{"language":%q}`, lang)
|
||||
}
|
||||
}
|
||||
|
||||
return &Tracker{
|
||||
inner: aem.NewTracker(aemCfg),
|
||||
eventID: eventID,
|
||||
captureOutput: cfg.CaptureOutput,
|
||||
outputMaxLen: outputMaxLen,
|
||||
flushTimeout: flushTimeout,
|
||||
noCommandLine: cfg.NoCommandLine,
|
||||
noCwd: cfg.NoCwd,
|
||||
noAutomaticDimensions: cfg.NoAutomaticDimensions,
|
||||
extraFields: cfg.ExtraFields,
|
||||
}
|
||||
}
|
||||
|
||||
// Run 执行 CLI 主函数并自动上报埋点。
|
||||
//
|
||||
// 自动完成:计时、从 os.Args 采集入参、推导退出码、(可选)捕获 stdout、
|
||||
// 错误输出到 stderr、best-effort flush(带超时,不阻塞退出)。
|
||||
//
|
||||
// execute CLI 入口,返回 error;cobra 直接传 rootCmd.Execute。
|
||||
// exitCode 把 error 映射为退出码;传 nil 用默认映射(nil→0,其余→1)。
|
||||
//
|
||||
// 与现状一致:退出码非 0 时调用 os.Exit;为 0 时正常 return,不调 os.Exit。
|
||||
func (t *Tracker) Run(execute func() error, exitCode func(error) int) {
|
||||
if exitCode == nil {
|
||||
exitCode = defaultExitCode
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
var output string
|
||||
var err error
|
||||
if t.captureOutput {
|
||||
output, err = captureStdout(execute)
|
||||
} else {
|
||||
err = execute()
|
||||
}
|
||||
|
||||
code := 0
|
||||
var errStr string
|
||||
if err != nil {
|
||||
code = exitCode(err)
|
||||
errStr = err.Error()
|
||||
if errStr != "" {
|
||||
fmt.Fprintln(os.Stderr, errStr)
|
||||
}
|
||||
}
|
||||
|
||||
t.trackExec(code, time.Since(start), errStr, output)
|
||||
t.close()
|
||||
|
||||
if code != 0 {
|
||||
os.Exit(code)
|
||||
}
|
||||
}
|
||||
|
||||
// trackExec 上报一次命令执行事件。
|
||||
func (t *Tracker) trackExec(exitCode int, duration time.Duration, errMsg, output string) {
|
||||
if t.inner == nil {
|
||||
return
|
||||
}
|
||||
_ = t.inner.Track(aem.Event{
|
||||
Type: "event",
|
||||
Fields: t.buildFields(exitCode, duration, errMsg, output),
|
||||
})
|
||||
}
|
||||
|
||||
// buildFields 按字段约定组装一次命令执行的事件字段(纯函数,便于测试)。
|
||||
func (t *Tracker) buildFields(exitCode int, duration time.Duration, errMsg, output string) map[string]string {
|
||||
fields := map[string]string{
|
||||
"p1": t.eventID,
|
||||
"p4": eventTypeSys,
|
||||
"c1": command(),
|
||||
"c3": strconv.Itoa(exitCode),
|
||||
"c4": strconv.FormatInt(duration.Milliseconds(), 10),
|
||||
}
|
||||
if !t.noAutomaticDimensions {
|
||||
fields["c6"] = shellType()
|
||||
}
|
||||
if !t.noCommandLine {
|
||||
fields["c2"] = commandLine()
|
||||
}
|
||||
if !t.noCwd {
|
||||
fields["c7"] = cwd()
|
||||
}
|
||||
if errMsg != "" {
|
||||
fields["c5"] = truncate(errMsg, maxErrorLen)
|
||||
}
|
||||
if output != "" {
|
||||
fields["c8"] = truncate(output, t.outputMaxLen)
|
||||
}
|
||||
if t.extraFields != nil {
|
||||
for k, v := range t.extraFields() {
|
||||
if v != "" {
|
||||
fields[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// close 关闭内部 Tracker,best-effort flush:最多等 flushTimeout,超时即放弃。
|
||||
func (t *Tracker) close() {
|
||||
if t.inner == nil {
|
||||
return
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_ = t.inner.Close()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(t.flushTimeout):
|
||||
}
|
||||
}
|
||||
|
||||
// defaultExitCode 是 exitCode 参数为 nil 时的默认映射。
|
||||
func defaultExitCode(err error) int {
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package clitrack
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildFieldsKeepsOrganizationDimension(t *testing.T) {
|
||||
tracker := &Tracker{
|
||||
noCommandLine: true,
|
||||
noCwd: true,
|
||||
extraFields: func() map[string]string {
|
||||
return map[string]string{"c9": "version", "c10": "corp-1"}
|
||||
},
|
||||
}
|
||||
|
||||
fields := tracker.buildFields(0, time.Millisecond, "", "")
|
||||
if fields["c9"] != "version" || fields["c10"] != "corp-1" {
|
||||
t.Fatalf("custom telemetry fields = %#v", fields)
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
// Package clitrack 在 aem 核心 SDK 之上,为任意 Go CLI 提供零侵入的使用埋点。
|
||||
//
|
||||
// 设计理念:CLI 入口只需一行 clitrack.New(cfg).Run(...),剩下全部自动完成——
|
||||
// 从 os.Args 采集原始入参、自动计时、自动推导命令路径和退出码、异步上报。
|
||||
//
|
||||
// 埋点尽力而为,数据可丢,但绝不阻塞 CLI 退出(见 FlushTimeout)。接入方负责
|
||||
// 向最终用户披露采集范围并提供符合其产品要求的退出机制。
|
||||
//
|
||||
// 最小接入示例(以 cobra CLI 为例):
|
||||
//
|
||||
// func Execute() {
|
||||
// clitrack.New(clitrack.Config{
|
||||
// PID: "your-pid", App: "my-cli", Version: version,
|
||||
// }).Run(rootCmd.Execute, nil)
|
||||
// }
|
||||
//
|
||||
// 框架无关:Run 只要求一个 func() error 入口,cobra / urfave-cli / 标准库 flag
|
||||
// 都能套。exitCode 传 nil 时用默认映射(nil→0,其余→1)。
|
||||
//
|
||||
// AEM 字段映射约定(所有接入的 CLI 统一遵守,这是跨 CLI 聚合分析的基础):
|
||||
//
|
||||
// Config 维度(初始化时设一次):
|
||||
// pid → Config.PID AEM 项目 ID(必填)
|
||||
// app_name → Config.App CLI 名称
|
||||
// env → Config.Env 环境 (prod/pre/daily),默认 prod
|
||||
// version → Config.Version CLI 版本号
|
||||
// uid → Config.UID 用户 ID(可选,接入方自己填)
|
||||
// username → Config.Username 用户名(可选)
|
||||
// user_type → Config.UserType 账号类型(可选)
|
||||
// endpoint → Config.Endpoint 上报域名(可选,海外站点填 sg.mmstat.com)
|
||||
// sid → $TERM_SESSION_ID 终端会话 ID(自动采集,NoAutomaticDimensions 可关)
|
||||
// ext.language → $LANG 终端 locale(自动采集,NoAutomaticDimensions 可关)
|
||||
//
|
||||
// Event 维度(每次命令执行打一条,type = "event"):
|
||||
// p1 → "cli.exec" AEM 自定义事件 ID(默认值,可用 Config.EventID 覆盖)
|
||||
// p4 → "SYS" AEM 事件类型:系统事件(固定值)
|
||||
// c1 → command filepath.Base(os.Args[0])(CLI 二进制名,如 "aem")
|
||||
// c2 → command_line os.Args[1:] 拼接(完整参数);Config.NoCommandLine 可关
|
||||
// c3 → exit_code 退出码
|
||||
// c4 → duration_ms 执行耗时(毫秒)
|
||||
// c5 → error_message 错误摘要,截断 200 字符
|
||||
// c6 → shell_type Shell 类型(zsh/bash 等);NoAutomaticDimensions 可关
|
||||
// c7 → cwd 当前工作目录;Config.NoCwd 可关
|
||||
// c8 → output CLI stdout 输出摘要;默认不采,Config.CaptureOutput 显式开启
|
||||
// c9/c10/ext → 自定义 由 Config.ExtraFields 钩子返回
|
||||
//
|
||||
// Config.NoAutomaticDimensions 会关闭设备、操作系统、时区、随机会话、终端会话、
|
||||
// locale 和 Shell 等自动维度,只保留接入方显式配置的公共维度和事件字段。本包不读取
|
||||
// 产品级退出环境变量;接入应用应在创建 Tracker 前执行自己的退出策略。PID 为空时
|
||||
// Tracker 自动降级为 no-op,命令仍正常执行。
|
||||
package clitrack
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package clitrack
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// command 返回 CLI 二进制名(c1),如 "aem"。
|
||||
func command() string {
|
||||
return filepath.Base(os.Args[0])
|
||||
}
|
||||
|
||||
// commandLine 返回完整命令行参数(c2),如 "login --env prod"。
|
||||
func commandLine() string {
|
||||
return strings.Join(os.Args[1:], " ")
|
||||
}
|
||||
|
||||
// shellType 返回当前 Shell 类型(c6),取 $SHELL 的 basename(如 "zsh"、"bash")。
|
||||
func shellType() string {
|
||||
shell := os.Getenv("SHELL")
|
||||
if shell == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Base(shell)
|
||||
}
|
||||
|
||||
// cwd 返回当前工作目录(c7),失败返回空字符串。
|
||||
func cwd() string {
|
||||
dir, _ := os.Getwd()
|
||||
return dir
|
||||
}
|
||||
|
||||
// truncate 按 rune 截断字符串到 maxLen,超出部分用 "..." 替换。
|
||||
func truncate(s string, maxLen int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= maxLen {
|
||||
return s
|
||||
}
|
||||
if maxLen <= 3 {
|
||||
return string(runes[:maxLen])
|
||||
}
|
||||
return string(runes[:maxLen-3]) + "..."
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
module gitlab.alibaba-inc.com/aes/aem-go-sdk
|
||||
|
||||
go 1.25.7
|
||||
@@ -0,0 +1,97 @@
|
||||
package encoder
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func EncodeURIComponent(s string) string {
|
||||
result := url.QueryEscape(s)
|
||||
result = strings.ReplaceAll(result, "+", "%20")
|
||||
result = strings.ReplaceAll(result, "%21", "!")
|
||||
result = strings.ReplaceAll(result, "%27", "'")
|
||||
result = strings.ReplaceAll(result, "%28", "(")
|
||||
result = strings.ReplaceAll(result, "%29", ")")
|
||||
result = strings.ReplaceAll(result, "%2A", "*")
|
||||
return result
|
||||
}
|
||||
|
||||
func ItemToString(v interface{}) (string, bool) {
|
||||
if v == nil {
|
||||
return "", false
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
if val == "" {
|
||||
return "", false
|
||||
}
|
||||
return val, true
|
||||
case float64:
|
||||
if val == float64(int64(val)) {
|
||||
return fmt.Sprintf("%d", int64(val)), true
|
||||
}
|
||||
return fmt.Sprintf("%g", val), true
|
||||
case int:
|
||||
return fmt.Sprintf("%d", val), true
|
||||
case int64:
|
||||
return fmt.Sprintf("%d", val), true
|
||||
case bool:
|
||||
if val {
|
||||
return "true", true
|
||||
}
|
||||
return "false", true
|
||||
case json.Number:
|
||||
return val.String(), true
|
||||
case map[string]interface{}, []interface{}:
|
||||
b, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return string(b), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func ObjToQS(m map[string]string) string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
parts := make([]string, 0, len(m))
|
||||
for _, k := range keys {
|
||||
v := m[k]
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, k+"="+EncodeURIComponent(v))
|
||||
}
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
func ToStringMap(data map[string]interface{}) map[string]string {
|
||||
result := make(map[string]string, len(data))
|
||||
for k, v := range data {
|
||||
if s, ok := ItemToString(v); ok {
|
||||
result[k] = s
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func ProcessData(logs []map[string]string, config map[string]string) string {
|
||||
configQS := ObjToQS(config)
|
||||
|
||||
logParts := make([]string, 0, len(logs))
|
||||
for _, log := range logs {
|
||||
logParts = append(logParts, ObjToQS(log))
|
||||
}
|
||||
logsJoined := strings.Join(logParts, "|")
|
||||
|
||||
return configQS + "&msg=" + EncodeURIComponent(logsJoined)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitlab.alibaba-inc.com/aes/aem-go-sdk/internal/encoder"
|
||||
)
|
||||
|
||||
const httpTimeout = 5 * time.Second
|
||||
|
||||
var httpClient = &http.Client{Timeout: httpTimeout}
|
||||
|
||||
// Send 单次 HTTP POST 上报 gokey 到 AES 后端,不重试。
|
||||
//
|
||||
// endpoint 不带 scheme 时默认 https://;以 http:// 或 https:// 开头时按原样使用。
|
||||
// 路径固定为 /aes.1.1,请求体为 {"gokey": encodeURIComponent(gokey), "gmkey": "EXP"}。
|
||||
func Send(endpoint string, gokey string, userAgent string) error {
|
||||
u := buildURL(endpoint)
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"gokey": encoder.EncodeURIComponent(gokey),
|
||||
"gmkey": "EXP",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal body: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", u, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if userAgent != "" {
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildURL(endpoint string) string {
|
||||
endpoint = strings.TrimRight(endpoint, "/")
|
||||
var u string
|
||||
if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") {
|
||||
u = endpoint
|
||||
} else {
|
||||
u = fmt.Sprintf("https://%s", endpoint)
|
||||
}
|
||||
if strings.HasSuffix(u, "/aes.1.1") {
|
||||
return u
|
||||
}
|
||||
return u + "/aes.1.1"
|
||||
}
|
||||
Reference in New Issue
Block a user