Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-offline-command-telemetry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"cre-cli": patch
---

Fix telemetry for commands that don't require login (`cre generate-bindings`, `cre workflow build`, `cre workflow hash`, etc.). Credentials are now attached silently when they exist on disk, so usage events from logged-in users are sent instead of being dropped. Commands remain fully usable while logged out.
72 changes: 42 additions & 30 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ func Execute() {
}

func newRootCommand() *cobra.Command {
// Without this, a subcommand-level PersistentPreRunE would silently shadow
// root's, skipping credential loading and telemetry for that whole subtree.
cobra.EnableTraverseRunHooks = true

rootLogger := createLogger()
rootViper := createViper()
runtimeContext := runtime.NewContext(rootLogger, rootViper)
Expand Down Expand Up @@ -259,6 +263,12 @@ func newRootCommand() *cobra.Command {
return err
}
}
} else {
// Commands that don't require login (e.g. generate-bindings, workflow
// build) still emit telemetry, which the server only accepts when
// authenticated. Attach credentials silently when they exist so those
// events can be sent; never validate, prompt, or fail.
runtimeContext.TryAttachCredentials()
}

// load settings from yaml files
Expand Down Expand Up @@ -586,37 +596,39 @@ func isLoadSettings(cmd *cobra.Command) bool {
return !exists
}

func isLoadCredentials(cmd *cobra.Command) bool {
// It is not expected to have the credentials loaded when running the following commands
var excludedCommands = map[string]struct{}{
"cre version": {},
"cre login": {},
"cre logout": {},
"cre completion bash": {},
"cre completion fish": {},
"cre completion powershell": {},
"cre completion zsh": {},
"cre help": {},
"cre generate-bindings": {},
"cre generate-bindings evm": {},
"cre generate-bindings solana": {},
"cre update": {},
"cre workflow": {},
"cre execution": {},
"cre workflow limits": {},
"cre workflow limits export": {},
"cre account": {},
"cre secrets": {},
"cre workflow build": {},
"cre workflow hash": {},
"cre templates": {},
"cre templates list": {},
"cre templates add": {},
"cre templates remove": {},
"cre": {},
}
// LoginExemptCommands are excluded from credential loading/validation in
// isLoadCredentials. They still get best-effort credentials via
// TryAttachCredentials so telemetry keeps working.
var LoginExemptCommands = map[string]struct{}{
"cre version": {},
"cre login": {},
"cre logout": {},
"cre completion bash": {},
"cre completion fish": {},
"cre completion powershell": {},
"cre completion zsh": {},
"cre help": {},
"cre generate-bindings": {},
"cre generate-bindings evm": {},
"cre generate-bindings solana": {},
"cre update": {},
"cre workflow": {},
"cre execution": {},
"cre workflow limits": {},
"cre workflow limits export": {},
"cre account": {},
"cre secrets": {},
"cre workflow build": {},
"cre workflow hash": {},
"cre templates": {},
"cre templates list": {},
"cre templates add": {},
"cre templates remove": {},
"cre": {},
}

_, exists := excludedCommands[cmd.CommandPath()]
func isLoadCredentials(cmd *cobra.Command) bool {
_, exists := LoginExemptCommands[cmd.CommandPath()]
return !exists
}

Expand Down
15 changes: 15 additions & 0 deletions internal/runtime/runtime_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,21 @@ func (ctx *Context) AttachCredentials(validationCtx context.Context, skipValidat
return nil
}

// TryAttachCredentials silently loads credentials from the environment or disk
// without validation, prompting, or failing. It is used for commands that do
// not require authentication so their telemetry events can still be attributed
// to the logged-in user; telemetry is skipped when no usable credentials exist.
func (ctx *Context) TryAttachCredentials() {
creds, err := credentials.New(ctx.Logger)
if err != nil || creds == nil {
return
}
if creds.APIKey == "" && (creds.Tokens == nil || creds.Tokens.AccessToken == "") {
return
}
ctx.Credentials = creds
}

// AttachTenantContext loads the user context for the current environment.
// If the manifest is missing, it is fetched from the service first.
func (ctx *Context) AttachTenantContext(validationCtx context.Context) error {
Expand Down
50 changes: 50 additions & 0 deletions internal/runtime/runtime_context_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
package runtime

import (
"io"
"os"
"path/filepath"
"testing"

"github.com/rs/zerolog"
"github.com/stretchr/testify/require"

"github.com/smartcontractkit/cre-cli/internal/creconfig"
"github.com/smartcontractkit/cre-cli/internal/credentials"
"github.com/smartcontractkit/cre-cli/internal/environments"
"github.com/smartcontractkit/cre-cli/internal/settings"
)
Expand Down Expand Up @@ -82,3 +88,47 @@ func TestValidateOnchainRegistryRPC(t *testing.T) {
require.Contains(t, err.Error(), "missing RPC URL")
})
}

func TestTryAttachCredentials(t *testing.T) {
discardLogger := zerolog.New(io.Discard)

t.Run("attaches API key credentials from environment", func(t *testing.T) {
t.Setenv(credentials.CreApiKeyVar, "test-api-key")

ctx := &Context{Logger: &discardLogger}
ctx.TryAttachCredentials()

require.NotNil(t, ctx.Credentials)
require.Equal(t, "test-api-key", ctx.Credentials.APIKey)
require.Equal(t, credentials.AuthTypeApiKey, ctx.Credentials.AuthType)
})

t.Run("attaches bearer credentials from config file", func(t *testing.T) {
t.Setenv(credentials.CreApiKeyVar, "")
home := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(home, creconfig.Dir), 0o700))
require.NoError(t, os.WriteFile(
filepath.Join(home, creconfig.Dir, credentials.ConfigFile),
[]byte("AccessToken: test-access-token\n"),
0o600,
))
t.Setenv("HOME", home)

ctx := &Context{Logger: &discardLogger}
ctx.TryAttachCredentials()

require.NotNil(t, ctx.Credentials)
require.NotNil(t, ctx.Credentials.Tokens)
require.Equal(t, "test-access-token", ctx.Credentials.Tokens.AccessToken)
})

t.Run("leaves credentials nil when none exist", func(t *testing.T) {
t.Setenv(credentials.CreApiKeyVar, "")
t.Setenv("HOME", t.TempDir())

ctx := &Context{Logger: &discardLogger}
ctx.TryAttachCredentials()

require.Nil(t, ctx.Credentials)
})
}
6 changes: 3 additions & 3 deletions internal/telemetry/emitter.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func EmitCommandEvent(cmd *cobra.Command, args []string, exitCode int, runtimeCt
}

// Check if this command should be excluded
if shouldExcludeCommand(cmd) {
if ShouldExcludeCommand(cmd) {
debugLog("command %s excluded from telemetry", cmd.Name())
return
}
Expand Down Expand Up @@ -81,8 +81,8 @@ func debugLog(format string, args ...interface{}) {
}
}

// shouldExcludeCommand determines if a command should not emit telemetry events
func shouldExcludeCommand(cmd *cobra.Command) bool {
// ShouldExcludeCommand determines if a command should not emit telemetry events.
func ShouldExcludeCommand(cmd *cobra.Command) bool {
if cmd == nil {
return true
}
Expand Down
2 changes: 1 addition & 1 deletion internal/telemetry/telemetry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ func TestShouldExcludeCommand(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: tt.cmdName}
assert.Equal(t, tt.shouldExclude, shouldExcludeCommand(cmd))
assert.Equal(t, tt.shouldExclude, ShouldExcludeCommand(cmd))
})
}
}
Expand Down
77 changes: 77 additions & 0 deletions test/graphql_mock.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
package test

import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"

"github.com/smartcontractkit/cre-cli/internal/environments"
"github.com/smartcontractkit/cre-cli/internal/telemetry"
"github.com/smartcontractkit/cre-cli/internal/testutil"
)

Expand All @@ -12,3 +18,74 @@ import (
func NewGraphQLMockServerGetOrganization(t *testing.T) *httptest.Server {
return testutil.NewGraphQLMockServerGetOrganization(t)
}

// RecordedTelemetry gives thread-safe access to the reportUserEvent mutations
// received by NewGraphQLMockServerRecordingTelemetry. The CLI sends telemetry
// from a goroutine killed by os.Exit, so callers must poll Events() briefly
// after the CLI process exits rather than reading it immediately.
type RecordedTelemetry struct {
mu sync.Mutex
events []telemetry.UserEventInput
}

// Events returns a snapshot of the reportUserEvent mutations received so far.
func (r *RecordedTelemetry) Events() []telemetry.UserEventInput {
r.mu.Lock()
defer r.mu.Unlock()
return append([]telemetry.UserEventInput(nil), r.events...)
}

func (r *RecordedTelemetry) record(e telemetry.UserEventInput) {
r.mu.Lock()
defer r.mu.Unlock()
r.events = append(r.events, e)
}

// NewGraphQLMockServerRecordingTelemetry starts an httptest.Server that answers
// getCreOrganizationInfo/getTenantConfig like NewGraphQLMockServerGetOrganization,
// and additionally records every reportUserEvent mutation it receives so tests
// can assert telemetry was actually sent over the wire. It sets EnvVarGraphQLURL.
// Caller must defer srv.Close().
func NewGraphQLMockServerRecordingTelemetry(t *testing.T) (*httptest.Server, *RecordedTelemetry) {
t.Helper()
testutil.IsolateCLIHome(t)

rec := &RecordedTelemetry{}

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/graphql") && r.Method == http.MethodPost {
var req struct {
Query string `json:"query"`
Variables struct {
Event telemetry.UserEventInput `json:"event"`
} `json:"variables"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
w.Header().Set("Content-Type", "application/json")

if strings.Contains(req.Query, "reportUserEvent") {
rec.record(req.Variables.Event)
_ = json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"reportUserEvent": map[string]any{"success": true, "message": ""},
},
})
return
}
if strings.Contains(req.Query, "getCreOrganizationInfo") {
_ = json.NewEncoder(w).Encode(testutil.MockGetCreOrganizationInfoGraphQLPayload())
return
}
if testutil.QueryIsGetTenantConfig(req.Query) {
_ = json.NewEncoder(w).Encode(testutil.MockGetTenantConfigGraphQLPayload())
return
}
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{
"errors": []map[string]string{{"message": "Unsupported GraphQL query"}},
})
}
}))
t.Setenv(environments.EnvVarGraphQLURL, srv.URL+"/graphql")
return srv, rec
}
Loading
Loading