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:
zearlin
2026-08-15 15:58:31 +08:00
committed by GitHub
co-authored by zearlin chichuan
parent fb9ff7de73
commit 8560830d3e
32 changed files with 2369 additions and 51 deletions
+5
View File
@@ -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.
+4
View File
@@ -20,6 +20,10 @@ test/cli_compat/testdata/
.gitignore
.worktrees/
.qoder/
_logs/
_docs/
_output/
vendor/
# Secrets & credentials
.env
+66 -2
View File
@@ -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
View File
@@ -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)
}
}
+3
View File
@@ -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
+113 -1
View File
@@ -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") }
+26
View File
@@ -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
View File
@@ -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() {
+45 -10
View File
@@ -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
+10 -3
View File
@@ -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)
}
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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) {
+143
View File
@@ -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]) + "..."
}
+99
View File
@@ -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)
}
}
+86
View File
@@ -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, "")
}
+147
View File
@@ -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)
}
}
+20
View File
@@ -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.
+264
View File
@@ -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
View File
@@ -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)
}
}
}
+36
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+21
View File
@@ -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
View File
@@ -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
View File
@@ -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]) + "..."
}
+3
View File
@@ -0,0 +1,3 @@
module gitlab.alibaba-inc.com/aes/aem-go-sdk
go 1.25.7
+97
View File
@@ -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)
}
+68
View File
@@ -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"
}