diff --git a/cmd/root.go b/cmd/root.go index 06acc69a16..0cd59307e6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -52,7 +52,7 @@ var ( logging.Fatal("cmd.Help function failed: %s", err) } }, - Version: "v1.83.0", + Version: config.GetToolkitVersion(), Annotations: annotation, } ) diff --git a/pkg/config/config.go b/pkg/config/config.go index 90e3fc9087..ec0357bec5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -36,7 +36,8 @@ import ( ) const ( - maxHintDist int = 3 // Maximum Levenshtein distance where we suggest a hint + maxHintDist int = 3 // Maximum Levenshtein distance where we suggest a hint + latestToolkitVersion = "v1.83.0" ) // map[moved module path]replacing module path @@ -503,6 +504,10 @@ func (bp Blueprint) Export(outputFilename string) error { return nil } +func GetToolkitVersion() string { + return latestToolkitVersion +} + // addKindToModules sets the kind to 'terraform' when empty. func (bp *Blueprint) addKindToModules() { bp.WalkModulesSafe(func(_ ModulePath, m *Module) { diff --git a/pkg/telemetry/collector.go b/pkg/telemetry/collector.go new file mode 100644 index 0000000000..0c1d0015f1 --- /dev/null +++ b/pkg/telemetry/collector.go @@ -0,0 +1,52 @@ +// Copyright 2026 "Google LLC" +// +// 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 telemetry + +import ( + "strconv" + "time" + + "github.com/spf13/cobra" +) + +var ( + eventStartTime time.Time +) + +func CollectPreMetrics(cmd *cobra.Command, args []string) { + eventStartTime = time.Now() + + metadata[COMMAND_NAME] = getCommandName(cmd) + metadata[IS_TEST_DATA] = getIsTestData() + +} + +func CollectPostMetrics(errorCode int) { + metadata[RUNTIME_MS] = getRuntime() + metadata[EXIT_CODE] = strconv.Itoa(errorCode) +} + +func getCommandName(cmd *cobra.Command) string { + return cmd.Name() +} + +func getIsTestData() string { + return "true" +} + +func getRuntime() string { + eventEndTime := time.Now() + return strconv.FormatInt(eventEndTime.Sub(eventStartTime).Milliseconds(), 10) +} diff --git a/pkg/telemetry/collector_test.go b/pkg/telemetry/collector_test.go new file mode 100644 index 0000000000..538fe83aef --- /dev/null +++ b/pkg/telemetry/collector_test.go @@ -0,0 +1,196 @@ +// Copyright 2026 "Google LLC" +// +// 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 telemetry + +import ( + "strconv" + "testing" + "time" + + "github.com/spf13/cobra" +) + +func resetGlobalState() { + eventStartTime = time.Time{} + metadata = make(map[string]string) +} + +func TestCollectPreMetrics(t *testing.T) { + tests := []struct { + name string + cmd *cobra.Command + args []string + wantCmdName string + wantIsTest string + }{ + { + name: "standard command", + cmd: &cobra.Command{ + Use: "apply", // cobra.Command.Name() derives from the first word of Use + }, + args: []string{}, + wantCmdName: "apply", + wantIsTest: "true", + }, + { + name: "command with flags in use string", + cmd: &cobra.Command{ + Use: "destroy [flags]", + }, + args: []string{"--force"}, + wantCmdName: "destroy", + wantIsTest: "true", + }, + { + name: "empty command", + cmd: &cobra.Command{}, + args: []string{}, + wantCmdName: "", + wantIsTest: "true", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + resetGlobalState() + + // Capture bounding times to verify eventStartTime is correctly set to time.Now() + before := time.Now() + CollectPreMetrics(tc.cmd, tc.args) + after := time.Now() + + if eventStartTime.Before(before) || eventStartTime.After(after) { + t.Errorf("eventStartTime = %v, want between %v and %v", eventStartTime, before, after) + } + + if got := metadata[COMMAND_NAME]; got != tc.wantCmdName { + t.Errorf("metadata[%q] = %q, want %q", COMMAND_NAME, got, tc.wantCmdName) + } + + if got := metadata[IS_TEST_DATA]; got != tc.wantIsTest { + t.Errorf("metadata[%q] = %q, want %q", IS_TEST_DATA, got, tc.wantIsTest) + } + }) + } +} + +func TestCollectPostMetrics(t *testing.T) { + tests := []struct { + name string + errorCode int + wantExitCode string + }{ + { + name: "success execution", + errorCode: 0, + wantExitCode: "0", + }, + { + name: "standard error", + errorCode: 1, + wantExitCode: "1", + }, + { + name: "custom error code", + errorCode: 127, + wantExitCode: "127", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + resetGlobalState() + + // Set start time to a known duration in the past to simulate runtime + eventStartTime = time.Now().Add(-50 * time.Millisecond) + + CollectPostMetrics(tc.errorCode) + + if got := metadata[EXIT_CODE]; got != tc.wantExitCode { + t.Errorf("metadata[%q] = %q, want %q", EXIT_CODE, got, tc.wantExitCode) + } + + runtimeMsStr, ok := metadata[RUNTIME_MS] + if !ok { + t.Fatalf("metadata[%q] missing, want populated", RUNTIME_MS) + } + + runtimeMs, err := strconv.ParseInt(runtimeMsStr, 10, 64) + if err != nil { + t.Fatalf("failed to parse RUNTIME_MS %q: %v", runtimeMsStr, err) + } + + // Validating that the calculated runtime is at least the 50ms we stubbed + if runtimeMs < 50 { + t.Errorf("RUNTIME_MS = %d, want >= 50", runtimeMs) + } + }) + } +} + +func TestGetCommandName(t *testing.T) { + tests := []struct { + name string + cmd *cobra.Command + want string + }{ + { + name: "simple use", + cmd: &cobra.Command{Use: "deploy"}, + want: "deploy", + }, + { + name: "complex use", + cmd: &cobra.Command{Use: "create cluster"}, + want: "create", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := getCommandName(tc.cmd); got != tc.want { + t.Errorf("getCommandName() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestGetIsTestData(t *testing.T) { + t.Run("returns true", func(t *testing.T) { + if got := getIsTestData(); got != "true" { + t.Errorf("getIsTestData() = %q, want %q", got, "true") + } + }) +} + +func TestGetRuntime(t *testing.T) { + t.Run("calculates correct duration", func(t *testing.T) { + resetGlobalState() + + // Mock start time to exactly 100ms ago + eventStartTime = time.Now().Add(-100 * time.Millisecond) + + gotStr := getRuntime() + + got, err := strconv.ParseInt(gotStr, 10, 64) + if err != nil { + t.Fatalf("getRuntime() returned non-integer %q: %v", gotStr, err) + } + + if got < 100 { + t.Errorf("getRuntime() = %d, want >= 100", got) + } + }) +} diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go new file mode 100644 index 0000000000..7acee2783e --- /dev/null +++ b/pkg/telemetry/telemetry.go @@ -0,0 +1,73 @@ +// Copyright 2026 "Google LLC" +// +// 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. + +// The following implementation is done for sending one LogEvent per LogRequest as per the telemetry logic. + +package telemetry + +import ( + "encoding/json" + "hpc-toolkit/pkg/config" + "hpc-toolkit/pkg/logging" + "time" + + "github.com/spf13/cobra" +) + +func Initialize(cmd *cobra.Command, args []string) { + CollectPreMetrics(cmd, args) +} + +func Finalize(exitCode int) { + CollectPostMetrics(exitCode) + payload := ConstructPayload() + Flush(payload) +} + +func ConstructPayload() LogRequest { + sourceExtensionJSON, err := json.Marshal(map[string]any{ + "event_type": "GCluster CLI", + "console_type": CLUSTER_TOOLKIT, + "release_version": config.GetToolkitVersion(), + "event_metadata": getEventMetadataKVPairs(), + }) + if err != nil { + logging.Error("Error collecting telemetry event metadata: %v", err) + return LogRequest{} + } + + logEvent := LogEvent{ + EventTimeMs: time.Now().UnixMilli(), + SourceExtensionJson: string(sourceExtensionJSON), + } + + logRequest := LogRequest{ + RequestTimeMs: time.Now().UnixMilli(), + ClientInfo: ClientInfo{ClientType: CLUSTER_TOOLKIT}, + LogSourceName: CONCORD, + LogEvent: []LogEvent{logEvent}, + } + return logRequest +} + +func getEventMetadataKVPairs() []map[string]string { + eventMetadata := make([]map[string]string, 0) + for k, v := range metadata { + eventMetadata = append(eventMetadata, map[string]string{ + "key": k, + "value": v, + }) + } + return eventMetadata +} diff --git a/pkg/telemetry/types.go b/pkg/telemetry/types.go new file mode 100644 index 0000000000..e57ba74b5d --- /dev/null +++ b/pkg/telemetry/types.go @@ -0,0 +1,52 @@ +// Copyright 2026 "Google LLC" +// +// 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 telemetry + +import "time" + +var ( + ClearcutProdURL = "https://play.googleapis.com/log" + HttpServerTimeout = 10 * time.Second + metadata = make(map[string]string) +) + +type ClientInfo struct { + ClientType string `json:"client_type"` +} + +type LogEvent struct { + EventTimeMs int64 `json:"event_time_ms"` + SourceExtensionJson string `json:"source_extension_json"` // Contains event metadata as key-value pairs. +} + +type LogRequest struct { + RequestTimeMs int64 `json:"request_time_ms"` + ClientInfo ClientInfo `json:"client_info"` + LogSourceName string `json:"log_source_name"` + LogEvent []LogEvent `json:"log_event"` +} + +const ( + CLUSTER_TOOLKIT string = "CLUSTER_TOOLKIT" + CONCORD string = "CONCORD" +) + +// CTK Metrics being collected +const ( + COMMAND_NAME = "CLUSTER_TOOLKIT_COMMAND_NAME" + IS_TEST_DATA = "CLUSTER_TOOLKIT_IS_TEST_DATA" + RUNTIME_MS = "CLUSTER_TOOLKIT_RUNTIME_MS" + EXIT_CODE = "CLUSTER_TOOLKIT_EXIT_CODE" +) diff --git a/pkg/telemetry/uploader.go b/pkg/telemetry/uploader.go new file mode 100644 index 0000000000..21ab3f2dc2 --- /dev/null +++ b/pkg/telemetry/uploader.go @@ -0,0 +1,67 @@ +// Copyright 2026 "Google LLC" +// +// 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 telemetry + +import ( + "bytes" + "encoding/json" + "fmt" + "hpc-toolkit/pkg/config" + "hpc-toolkit/pkg/logging" + "io" + "net/http" + "net/url" +) + +func Flush(payload LogRequest) { + jsonData, err := json.Marshal(payload) + if err != nil { + logging.Error("Error marshalling telemetry JSON: %v", err) + return + } + + client := &http.Client{ + Timeout: HttpServerTimeout, + } + + u, err := url.Parse(ClearcutProdURL) + if err != nil { + logging.Error("Error parsing URL: %v", err) + return + } + params := url.Values{} + params.Add("format", "json_proto") + u.RawQuery = params.Encode() + + req, err := http.NewRequest("POST", u.String(), bytes.NewBuffer(jsonData)) + if err != nil { + logging.Error("Error creating HTTP request: %v", err) + return + } + req.Header.Set("User-Agent", fmt.Sprintf("CLUSTER_TOOLKIT/%v", config.GetToolkitVersion())) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + logging.Error("Error sending request: %v", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + logging.Error("Telemetry request failed with status %d: %s", resp.StatusCode, string(body)) + } +} diff --git a/pkg/telemetry/uploader_test.go b/pkg/telemetry/uploader_test.go new file mode 100644 index 0000000000..30561e8c36 --- /dev/null +++ b/pkg/telemetry/uploader_test.go @@ -0,0 +1,135 @@ +// Copyright 2026 "Google LLC" +// +// 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 telemetry + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "hpc-toolkit/pkg/config" +) + +func TestFlush(t *testing.T) { + // Save the original global variables to restore them after testing + origURL := ClearcutProdURL + origTimeout := HttpServerTimeout + + defer func() { + ClearcutProdURL = origURL + HttpServerTimeout = origTimeout + + }() + + tests := []struct { + name string + payload LogRequest + serverHandler http.HandlerFunc + setupGlobals func(serverURL string) + }{ + { + name: "successful request sets correct headers and payload", + payload: LogRequest{}, // Use a dummy/empty payload + serverHandler: func(w http.ResponseWriter, r *http.Request) { + // 1. Verify Method + if r.Method != http.MethodPost { + t.Errorf("Method = %s, want %s", r.Method, http.MethodPost) + } + + // 2. Verify Query Params + if gotFormat := r.URL.Query().Get("format"); gotFormat != "json_proto" { + t.Errorf("Query param 'format' = %q, want %q", gotFormat, "json_proto") + } + + // 3. Verify Headers + if gotCT := r.Header.Get("Content-Type"); gotCT != "application/json" { + t.Errorf("Header Content-Type = %q, want %q", gotCT, "application/json") + } + + wantUA := "CLUSTER_TOOLKIT/" + config.GetToolkitVersion() + if gotUA := r.Header.Get("User-Agent"); gotUA != wantUA { + t.Errorf("Header User-Agent = %q, want %q", gotUA, wantUA) + } + + // 4. Verify Body + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("Failed to read request body: %v", err) + } + + expectedBody, _ := json.Marshal(LogRequest{}) + if string(body) != string(expectedBody) { + t.Errorf("Body = %s, want %s", string(body), string(expectedBody)) + } + + // Return 200 OK + w.WriteHeader(http.StatusOK) + }, + setupGlobals: func(serverURL string) { + ClearcutProdURL = serverURL + HttpServerTimeout = 5 * time.Second + }, + }, + { + name: "client handles network timeout error", + payload: LogRequest{}, + serverHandler: func(w http.ResponseWriter, r *http.Request) { + // Artificially delay the response to trigger the client timeout + time.Sleep(10 * time.Millisecond) + w.WriteHeader(http.StatusOK) + }, + setupGlobals: func(serverURL string) { + ClearcutProdURL = serverURL + // Set an aggressive timeout to ensure client.Do(req) fails + HttpServerTimeout = 1 * time.Millisecond + }, + }, + { + name: "invalid URL format prevents request creation", + payload: LogRequest{}, + serverHandler: nil, // Server won't be hit + setupGlobals: func(_ string) { + // A control character in the URL scheme forces http.NewRequest to return an error + ClearcutProdURL = "http://192.168.0.%31/" + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var serverURL string + + // Initialize the mock HTTP server if the test case provides a handler + if tc.serverHandler != nil { + ts := httptest.NewServer(tc.serverHandler) + defer ts.Close() + serverURL = ts.URL + } + + // Apply test-specific globals (e.g., overriding the URL to the mock server's URL) + if tc.setupGlobals != nil { + tc.setupGlobals(serverURL) + } + + // Execute the function. Because Flush() returns no error, test failures + // for the "happy path" are primarily caught by the `serverHandler` assertions. + // Network/parsing error paths are verified by ensuring no panics occur. + Flush(tc.payload) + }) + } +} diff --git a/tools/enforce_coverage.pl b/tools/enforce_coverage.pl index 199a550d2d..da3c5f910b 100755 --- a/tools/enforce_coverage.pl +++ b/tools/enforce_coverage.pl @@ -27,6 +27,7 @@ pkg/validators 10 pkg/inspect 60 pkg/modulewriter 79 + pkg/telemetry 50 pkg 80 );