From 6d3ecf4cd5cb52fbfdf7305c23201ca4dfdd6a71 Mon Sep 17 00:00:00 2001 From: jy Date: Wed, 29 Apr 2026 02:33:34 +0000 Subject: [PATCH 1/4] feat: use adaptive sampling rate by default in setup agent and wizard Update the setup agent prompts, onboard wizard, and cloud onboard wizard to use adaptive sampling mode by default instead of fixed mode when generating new configurations. Changes: - Setup agent config templates now use recording.sampling.mode: adaptive with recording.sampling.base_rate instead of the deprecated recording.sampling_rate - Onboard wizard (tusk init) generates configs with nested sampling config using adaptive mode - Cloud onboard wizard (tusk init-cloud) saves sampling.mode: adaptive and sampling.base_rate alongside the legacy sampling_rate field - Updated descriptions to mention adaptive sampling behavior - Updated integration test to verify new fields are saved Closes #223 --- .../phase_cloud_configure_recording.md | 24 ++++++++++++------- internal/agent/prompts/phase_create_config.md | 8 +++++-- internal/tui/onboard-cloud/save.go | 5 ++++ internal/tui/onboard-cloud/save_test.go | 2 ++ internal/tui/onboard-cloud/steps.go | 6 ++--- internal/tui/onboard/config.go | 16 +++++++++---- 6 files changed, 44 insertions(+), 17 deletions(-) diff --git a/internal/agent/prompts/phase_cloud_configure_recording.md b/internal/agent/prompts/phase_cloud_configure_recording.md index 7e70bfeb..da30a3f3 100644 --- a/internal/agent/prompts/phase_cloud_configure_recording.md +++ b/internal/agent/prompts/phase_cloud_configure_recording.md @@ -4,17 +4,22 @@ Configure the recording parameters for Tusk Drift Cloud. ### Configuration Options -1. **Sampling Rate** (0.0 to 1.0): - - Percentage of requests to record +1. **Sampling Mode**: + - `adaptive` (default): Automatically adjusts sampling rate under load to reduce overhead + - `fixed`: Uses a constant sampling rate + +2. **Base Sampling Rate** (0.0 to 1.0): + - Base percentage of requests to record + - In adaptive mode, the SDK may temporarily reduce below this rate under pressure - Recommended: 0.1 (10%) for dev/staging, 0.01 (1%) for production - Default: 0.1 -2. **Export Spans** (boolean): +3. **Export Spans** (boolean): - Whether to upload trace data to Tusk Cloud - Required for cloud features - Default: true -3. **Record Environment Variables** (boolean): +4. **Record Environment Variables** (boolean): - Whether to record and replay environment variables - Recommended if app behavior depends on env vars - Default: false @@ -22,20 +27,22 @@ Configure the recording parameters for Tusk Drift Cloud. ### Steps 1. **Present defaults**: Tell the user the default configuration: - - Sampling rate: 0.1 (10%) + - Sampling mode: adaptive + - Base sampling rate: 0.1 (10%) - Export spans: true - Record env vars: false 2. **Ask for customization**: Use `ask_user` to ask if they want to customize: "The default recording configuration is: - - Sampling rate: 10% (0.1) + - Sampling mode: adaptive (automatically adjusts under load) + - Base sampling rate: 10% (0.1) - Export spans: enabled - Record environment variables: disabled Press Enter to accept defaults, or type 'custom' to customize:" 3. **If customizing**: Ask for each value: - - Sampling rate (number between 0.0 and 1.0) + - Base sampling rate (number between 0.0 and 1.0) - Export spans (yes/no) - Record env vars (yes/no) @@ -61,6 +68,7 @@ Since cloud users fetch traces from Tusk Cloud rather than storing them locally, ### Important Notes -- Lower sampling rates reduce performance overhead +- Adaptive mode is recommended for most deployments as it automatically reduces sampling under load to minimize performance overhead +- Lower base sampling rates reduce performance overhead - Export spans must be true for cloud features to work - Environment variable recording is useful for apps that depend on env vars for business logic diff --git a/internal/agent/prompts/phase_create_config.md b/internal/agent/prompts/phase_create_config.md index fbd897a7..dcd9e76e 100644 --- a/internal/agent/prompts/phase_create_config.md +++ b/internal/agent/prompts/phase_create_config.md @@ -22,7 +22,9 @@ test_execution: timeout: 30s recording: - sampling_rate: 1.0 + sampling: + mode: adaptive + base_rate: 1.0 export_spans: false enable_env_var_recording: true ``` @@ -52,7 +54,9 @@ test_execution: timeout: 30s recording: - sampling_rate: 1.0 + sampling: + mode: adaptive + base_rate: 1.0 export_spans: false enable_env_var_recording: true ``` diff --git a/internal/tui/onboard-cloud/save.go b/internal/tui/onboard-cloud/save.go index d2ffe894..18a53703 100644 --- a/internal/tui/onboard-cloud/save.go +++ b/internal/tui/onboard-cloud/save.go @@ -215,10 +215,15 @@ func SaveServiceIDToConfig(serviceID string) error { func SaveRecordingConfig(samplingRate float64, exportSpans, enableEnvVarRecording bool) error { return saveToConfig(func(cfg *config.Config, u *ConfigUpdater) error { cfg.Recording.SamplingRate = samplingRate + cfg.Recording.Sampling.Mode = "adaptive" + baseRate := samplingRate + cfg.Recording.Sampling.BaseRate = &baseRate cfg.Recording.ExportSpans = &exportSpans cfg.Recording.EnableEnvVarRecording = &enableEnvVarRecording u.Set([]string{"recording", "sampling_rate"}, samplingRate) + u.Set([]string{"recording", "sampling", "mode"}, "adaptive") + u.Set([]string{"recording", "sampling", "base_rate"}, samplingRate) u.Set([]string{"recording", "export_spans"}, exportSpans) u.Set([]string{"recording", "enable_env_var_recording"}, enableEnvVarRecording) return nil diff --git a/internal/tui/onboard-cloud/save_test.go b/internal/tui/onboard-cloud/save_test.go index c5a8ecb4..3c536090 100644 --- a/internal/tui/onboard-cloud/save_test.go +++ b/internal/tui/onboard-cloud/save_test.go @@ -172,6 +172,8 @@ recording: result := string(data) assert.Contains(t, result, "sampling_rate: 1") + assert.Contains(t, result, "mode: adaptive") + assert.Contains(t, result, "base_rate: 1") assert.Contains(t, result, "export_spans: true") assert.Contains(t, result, "enable_env_var_recording: true") assert.NotContains(t, result, "!!float") diff --git a/internal/tui/onboard-cloud/steps.go b/internal/tui/onboard-cloud/steps.go index d4f025af..174d1812 100644 --- a/internal/tui/onboard-cloud/steps.go +++ b/internal/tui/onboard-cloud/steps.go @@ -508,9 +508,9 @@ func (RecordingConfigStep) Heading(*Model) string { return "Configure recording func (RecordingConfigStep) Description(m *Model) string { return `Configure how Tusk records execution traces from your application: -• Sampling Rate: Percentage of requests to record (0.01 = 1%, 0.1 = 10%) - Lower rates reduce performance overhead. We recommend starting 10% for - dev/staging, and 1% for production environments. +• Sampling Rate: Base percentage of requests to record (0.01 = 1%, 0.1 = 10%) + Adaptive sampling mode is used by default, which automatically adjusts the rate + under load. We recommend starting at 10% for dev/staging, and 1% for production. • Export Spans: Upload trace data to Tusk Drift Cloud (required for cloud features) Disable only if using Tusk Drift locally without cloud integration. diff --git a/internal/tui/onboard/config.go b/internal/tui/onboard/config.go index 6697fb33..39646ab7 100644 --- a/internal/tui/onboard/config.go +++ b/internal/tui/onboard/config.go @@ -57,10 +57,15 @@ type TestExecution struct { Timeout string `yaml:"timeout"` } +type RecordingSampling struct { + Mode string `yaml:"mode"` + BaseRate float64 `yaml:"base_rate"` +} + type Recording struct { - SamplingRate float64 `yaml:"sampling_rate"` - ExportSpans bool `yaml:"export_spans"` - EnableEnvVarRecording bool `yaml:"enable_env_var_recording"` + Sampling RecordingSampling `yaml:"sampling"` + ExportSpans bool `yaml:"export_spans"` + EnableEnvVarRecording bool `yaml:"enable_env_var_recording"` } type Traces struct { @@ -106,7 +111,10 @@ func (m *Model) getCurrentConfig() Config { Timeout: "30s", }, Recording: Recording{ - SamplingRate: samplingRate, + Sampling: RecordingSampling{ + Mode: "adaptive", + BaseRate: samplingRate, + }, ExportSpans: false, EnableEnvVarRecording: true, }, From a6437efef1b19f5684d2fdbd0e7087f8bd8a1882 Mon Sep 17 00:00:00 2001 From: jy Date: Wed, 29 Apr 2026 04:49:37 +0000 Subject: [PATCH 2/4] Add unit tests for adaptive sampling config generation --- internal/tui/onboard/config_test.go | 65 +++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 internal/tui/onboard/config_test.go diff --git a/internal/tui/onboard/config_test.go b/internal/tui/onboard/config_test.go new file mode 100644 index 00000000..1d40e93c --- /dev/null +++ b/internal/tui/onboard/config_test.go @@ -0,0 +1,65 @@ +package onboard + +import ( + "strings" + "testing" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestGetCurrentConfig_UsesAdaptiveSamplingMode(t *testing.T) { + inputs := make([]textinput.Model, 1) + inputs[0] = textinput.New() + + m := &Model{ + ServiceName: "test-service", + ServicePort: "3000", + StartCmd: "npm start", + ReadinessCmd: "curl http://localhost:3000/health", + ReadinessTimeout: "30s", + ReadinessInterval: "1s", + SamplingRate: "1.0", + inputs: inputs, + } + + cfg := m.getCurrentConfig() + + assert.Equal(t, "adaptive", cfg.Recording.Sampling.Mode) + assert.Equal(t, 1.0, cfg.Recording.Sampling.BaseRate) + + // Verify YAML output uses nested sampling config + var buf strings.Builder + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + require.NoError(t, enc.Encode(cfg)) + _ = enc.Close() + yamlStr := buf.String() + + assert.Contains(t, yamlStr, "mode: adaptive") + assert.Contains(t, yamlStr, "base_rate: 1") + assert.NotContains(t, yamlStr, "sampling_rate:") +} + +func TestGetCurrentConfig_CustomSamplingRate(t *testing.T) { + inputs := make([]textinput.Model, 1) + inputs[0] = textinput.New() + + m := &Model{ + ServiceName: "test-service", + ServicePort: "8080", + StartCmd: "python app.py", + ReadinessCmd: "curl http://localhost:8080/health", + ReadinessTimeout: "30s", + ReadinessInterval: "1s", + SamplingRate: "0.1", + inputs: inputs, + } + + cfg := m.getCurrentConfig() + + assert.Equal(t, "adaptive", cfg.Recording.Sampling.Mode) + assert.Equal(t, 0.1, cfg.Recording.Sampling.BaseRate) +} From ebe039f9ee504b5a8992e2c8bca580f5fa987818 Mon Sep 17 00:00:00 2001 From: jy Date: Wed, 29 Apr 2026 05:29:05 +0000 Subject: [PATCH 3/4] feat: allow choosing sampling mode (adaptive/fixed) in cloud wizard and agent --- internal/agent/executor.go | 7 +- .../phase_cloud_configure_recording.md | 3 + internal/agent/tools/cloud.go | 3 +- internal/tui/onboard-cloud/helpers.go | 4 + internal/tui/onboard-cloud/model.go | 1 + .../onboard-cloud/recording_config_table.go | 34 +++++--- internal/tui/onboard-cloud/save.go | 9 +- internal/tui/onboard-cloud/save_test.go | 83 ++++++++++++++++++- internal/tui/onboard-cloud/steps.go | 9 +- internal/tui/onboard-cloud/update.go | 7 +- 10 files changed, 139 insertions(+), 21 deletions(-) diff --git a/internal/agent/executor.go b/internal/agent/executor.go index bd85f0c8..500589e9 100644 --- a/internal/agent/executor.go +++ b/internal/agent/executor.go @@ -657,7 +657,12 @@ func toolDefinitions() map[ToolName]*ToolDefinition { }, "sampling_rate": { "type": "number", - "description": "Sampling rate (0.0 to 1.0, e.g., 0.1 for 10%)" + "description": "Base sampling rate (0.0 to 1.0, e.g., 0.1 for 10%)" + }, + "sampling_mode": { + "type": "string", + "description": "Sampling mode: 'adaptive' (adjusts under load) or 'fixed' (constant rate). Defaults to 'adaptive'.", + "enum": ["adaptive", "fixed"] }, "export_spans": { "type": "boolean", diff --git a/internal/agent/prompts/phase_cloud_configure_recording.md b/internal/agent/prompts/phase_cloud_configure_recording.md index da30a3f3..7072b310 100644 --- a/internal/agent/prompts/phase_cloud_configure_recording.md +++ b/internal/agent/prompts/phase_cloud_configure_recording.md @@ -42,6 +42,7 @@ Configure the recording parameters for Tusk Drift Cloud. Press Enter to accept defaults, or type 'custom' to customize:" 3. **If customizing**: Ask for each value: + - Sampling mode (adaptive or fixed) - Base sampling rate (number between 0.0 and 1.0) - Export spans (yes/no) - Record env vars (yes/no) @@ -49,11 +50,13 @@ Configure the recording parameters for Tusk Drift Cloud. 4. **Save configuration**: Use `cloud_save_config` with: - `service_id`: from state.cloud_service_id - `sampling_rate`: the chosen rate + - `sampling_mode`: the chosen mode (adaptive or fixed) - `export_spans`: the chosen value - `enable_env_var_recording`: the chosen value 5. **Transition**: Move to the next phase with: - `sampling_rate`: the chosen rate + - `sampling_mode`: the chosen mode - `export_spans`: the chosen value - `enable_env_var_recording`: the chosen value diff --git a/internal/agent/tools/cloud.go b/internal/agent/tools/cloud.go index 3889cca8..d3966d56 100644 --- a/internal/agent/tools/cloud.go +++ b/internal/agent/tools/cloud.go @@ -781,6 +781,7 @@ func (ct *CloudTools) SaveCloudConfig(input json.RawMessage) (string, error) { var params struct { ServiceID string `json:"service_id"` SamplingRate float64 `json:"sampling_rate"` + SamplingMode string `json:"sampling_mode"` ExportSpans bool `json:"export_spans"` EnableEnvVarRecording bool `json:"enable_env_var_recording"` } @@ -796,7 +797,7 @@ func (ct *CloudTools) SaveCloudConfig(input json.RawMessage) (string, error) { } // Save recording config (preserves other fields like exclude_paths, transforms) - if err := onboardcloud.SaveRecordingConfig(params.SamplingRate, params.ExportSpans, params.EnableEnvVarRecording); err != nil { + if err := onboardcloud.SaveRecordingConfig(params.SamplingRate, params.SamplingMode, params.ExportSpans, params.EnableEnvVarRecording); err != nil { return "", fmt.Errorf("failed to save recording config: %w", err) } diff --git a/internal/tui/onboard-cloud/helpers.go b/internal/tui/onboard-cloud/helpers.go index ffcdab18..e5352135 100644 --- a/internal/tui/onboard-cloud/helpers.go +++ b/internal/tui/onboard-cloud/helpers.go @@ -195,6 +195,10 @@ func loadExistingConfig(m *Model) error { } m.ServiceID = cfg.Service.ID + m.SamplingMode = cfg.Recording.Sampling.Mode + if m.SamplingMode == "" { + m.SamplingMode = "adaptive" + } m.SamplingRate = fmt.Sprintf("%.2f", cfg.Recording.SamplingRate) if cfg.Recording.ExportSpans != nil { diff --git a/internal/tui/onboard-cloud/model.go b/internal/tui/onboard-cloud/model.go index 99d20505..652c5971 100644 --- a/internal/tui/onboard-cloud/model.go +++ b/internal/tui/onboard-cloud/model.go @@ -65,6 +65,7 @@ type Model struct { CreateApiKeyChoice bool // State - Recording Config + SamplingMode string SamplingRate string ExportSpans bool EnableEnvVarRecording bool diff --git a/internal/tui/onboard-cloud/recording_config_table.go b/internal/tui/onboard-cloud/recording_config_table.go index b03ecc64..c93451a2 100644 --- a/internal/tui/onboard-cloud/recording_config_table.go +++ b/internal/tui/onboard-cloud/recording_config_table.go @@ -12,6 +12,7 @@ import ( type RecordingConfigTable struct { table table.Model + samplingMode string samplingRate string exportSpans bool enableEnvVarRecording bool @@ -20,7 +21,7 @@ type RecordingConfigTable struct { cursor int } -func NewRecordingConfigTable(samplingRate string, exportSpans, enableEnvVarRecording bool) *RecordingConfigTable { +func NewRecordingConfigTable(samplingMode, samplingRate string, exportSpans, enableEnvVarRecording bool) *RecordingConfigTable { columns := []table.Column{ {Title: "Setting", Width: 35}, {Title: "Value", Width: 25}, @@ -48,8 +49,12 @@ func NewRecordingConfigTable(samplingRate string, exportSpans, enableEnvVarRecor t.SetStyles(s) + if samplingMode == "" { + samplingMode = "adaptive" + } rct := &RecordingConfigTable{ table: t, + samplingMode: samplingMode, samplingRate: samplingRate, exportSpans: true, // Required for cloud onboarding enableEnvVarRecording: enableEnvVarRecording, @@ -64,7 +69,7 @@ func NewRecordingConfigTable(samplingRate string, exportSpans, enableEnvVarRecor func (rct *RecordingConfigTable) updateRows() { rate, _ := strconv.ParseFloat(rct.samplingRate, 64) rateDisplay := fmt.Sprintf("%.2f (%.0f%%)", rate, rate*100) - if rct.EditMode && rct.cursor == 0 { + if rct.EditMode && rct.cursor == 1 { rateDisplay = "→ " + rct.samplingRate + "_" } @@ -76,7 +81,8 @@ func (rct *RecordingConfigTable) updateRows() { } rows := []table.Row{ - {"Sampling Rate", rateDisplay}, + {"Sampling Mode", rct.samplingMode}, + {"Base Sampling Rate", rateDisplay}, {"Export Spans", formatBool(rct.exportSpans)}, {"Record Environment Variables", formatBool(rct.enableEnvVarRecording)}, } @@ -92,7 +98,7 @@ func (rct *RecordingConfigTable) Update(msg tea.Msg) (*RecordingConfigTable, tea switch msg := msg.(type) { case tea.KeyMsg: // If in edit mode (typing sampling rate) - if rct.EditMode && rct.cursor == 0 { + if rct.EditMode && rct.cursor == 1 { switch msg.String() { case "tab", "esc": rct.EditMode = false @@ -127,7 +133,7 @@ func (rct *RecordingConfigTable) Update(msg tea.Msg) (*RecordingConfigTable, tea return rct, nil case "down", "j": - if rct.cursor < 2 { + if rct.cursor < 3 { rct.cursor++ rct.table.MoveDown(1) } @@ -136,18 +142,24 @@ func (rct *RecordingConfigTable) Update(msg tea.Msg) (*RecordingConfigTable, tea case "tab", " ": switch rct.cursor { + case 0: + if rct.samplingMode == "adaptive" { + rct.samplingMode = "fixed" + } else { + rct.samplingMode = "adaptive" + } case 1: - rct.exportSpans = !rct.exportSpans + rct.EditMode = true case 2: + rct.exportSpans = !rct.exportSpans + case 3: rct.enableEnvVarRecording = !rct.enableEnvVarRecording - case 0: - rct.EditMode = true } rct.updateRows() return rct, nil case "e": - if rct.cursor == 0 { + if rct.cursor == 1 { rct.EditMode = true rct.updateRows() return rct, nil @@ -169,9 +181,9 @@ func (rct *RecordingConfigTable) View() string { ) } -func (rct *RecordingConfigTable) GetValues() (samplingRate float64, exportSpans, enableEnvVarRecording bool) { +func (rct *RecordingConfigTable) GetValues() (samplingMode string, samplingRate float64, exportSpans, enableEnvVarRecording bool) { rate, _ := strconv.ParseFloat(rct.samplingRate, 64) - return rate, rct.exportSpans, rct.enableEnvVarRecording + return rct.samplingMode, rate, rct.exportSpans, rct.enableEnvVarRecording } func (rct *RecordingConfigTable) SetFocused(focused bool) { diff --git a/internal/tui/onboard-cloud/save.go b/internal/tui/onboard-cloud/save.go index 18a53703..75966734 100644 --- a/internal/tui/onboard-cloud/save.go +++ b/internal/tui/onboard-cloud/save.go @@ -212,17 +212,20 @@ func SaveServiceIDToConfig(serviceID string) error { // SaveRecordingConfig saves recording settings to .tusk/config.yaml. // Uses yaml.Node parsing to preserve file structure, comments, and unknown fields // (e.g., exclude_paths, transforms that the user may have configured). -func SaveRecordingConfig(samplingRate float64, exportSpans, enableEnvVarRecording bool) error { +func SaveRecordingConfig(samplingRate float64, samplingMode string, exportSpans, enableEnvVarRecording bool) error { + if samplingMode == "" { + samplingMode = "adaptive" + } return saveToConfig(func(cfg *config.Config, u *ConfigUpdater) error { cfg.Recording.SamplingRate = samplingRate - cfg.Recording.Sampling.Mode = "adaptive" + cfg.Recording.Sampling.Mode = samplingMode baseRate := samplingRate cfg.Recording.Sampling.BaseRate = &baseRate cfg.Recording.ExportSpans = &exportSpans cfg.Recording.EnableEnvVarRecording = &enableEnvVarRecording u.Set([]string{"recording", "sampling_rate"}, samplingRate) - u.Set([]string{"recording", "sampling", "mode"}, "adaptive") + u.Set([]string{"recording", "sampling", "mode"}, samplingMode) u.Set([]string{"recording", "sampling", "base_rate"}, samplingRate) u.Set([]string{"recording", "export_spans"}, exportSpans) u.Set([]string{"recording", "enable_env_var_recording"}, enableEnvVarRecording) diff --git a/internal/tui/onboard-cloud/save_test.go b/internal/tui/onboard-cloud/save_test.go index 3c536090..5b51f8de 100644 --- a/internal/tui/onboard-cloud/save_test.go +++ b/internal/tui/onboard-cloud/save_test.go @@ -163,7 +163,7 @@ recording: require.NoError(t, err) // Save new recording config - err = SaveRecordingConfig(1.0, true, true) + err = SaveRecordingConfig(1.0, "adaptive", true, true) require.NoError(t, err) // Read the file back @@ -180,6 +180,87 @@ recording: assert.NotContains(t, result, "!!bool") } +func TestSaveRecordingConfig_FixedMode(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "tusk-test-*") + require.NoError(t, err) + t.Cleanup(func() { + _ = os.RemoveAll(tmpDir) + config.Invalidate() + }) + + originalWd, err := os.Getwd() + require.NoError(t, err) + t.Cleanup(func() { _ = os.Chdir(originalWd) }) + + err = os.Chdir(tmpDir) + require.NoError(t, err) + + tuskDir := filepath.Join(tmpDir, ".tusk") + err = os.MkdirAll(tuskDir, 0o750) + require.NoError(t, err) + + initialConfig := `service: + name: test-service + +recording: + sampling_rate: 0.5 + export_spans: false + enable_env_var_recording: false +` + err = os.WriteFile(filepath.Join(tuskDir, "config.yaml"), []byte(initialConfig), 0o600) + require.NoError(t, err) + + err = SaveRecordingConfig(0.5, "fixed", false, false) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(tuskDir, "config.yaml")) // #nosec G304 + require.NoError(t, err) + + result := string(data) + assert.Contains(t, result, "mode: fixed") + assert.Contains(t, result, "base_rate: 0.5") + assert.Contains(t, result, "sampling_rate: 0.5") +} + +func TestSaveRecordingConfig_DefaultsToAdaptive(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "tusk-test-*") + require.NoError(t, err) + t.Cleanup(func() { + _ = os.RemoveAll(tmpDir) + config.Invalidate() + }) + + originalWd, err := os.Getwd() + require.NoError(t, err) + t.Cleanup(func() { _ = os.Chdir(originalWd) }) + + err = os.Chdir(tmpDir) + require.NoError(t, err) + + tuskDir := filepath.Join(tmpDir, ".tusk") + err = os.MkdirAll(tuskDir, 0o750) + require.NoError(t, err) + + initialConfig := `service: + name: test-service + +recording: + sampling_rate: 1 +` + err = os.WriteFile(filepath.Join(tuskDir, "config.yaml"), []byte(initialConfig), 0o600) + require.NoError(t, err) + + // Empty string should default to adaptive + err = SaveRecordingConfig(1.0, "", true, false) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(tuskDir, "config.yaml")) // #nosec G304 + require.NoError(t, err) + + result := string(data) + assert.Contains(t, result, "mode: adaptive") +} + // Helper function to test updateField without file I/O func updateYAMLString(t *testing.T, input string, path []string, value any) string { t.Helper() diff --git a/internal/tui/onboard-cloud/steps.go b/internal/tui/onboard-cloud/steps.go index 174d1812..f73ec5f7 100644 --- a/internal/tui/onboard-cloud/steps.go +++ b/internal/tui/onboard-cloud/steps.go @@ -526,7 +526,7 @@ func (RecordingConfigStep) Help(m *Model) string { if m.RecordingConfigTable != nil && m.RecordingConfigTable.EditMode { return "Type sampling rate (0.0-1.0) • tab/esc: done editing" } - return "↑↓: navigate • tab/space: toggle/edit • enter: save" + return "↑↓: navigate • tab/space: toggle/edit value • enter: save" } func (RecordingConfigStep) Clear(m *Model) { @@ -560,8 +560,13 @@ func (ReviewStep) Description(m *Model) string { } summary.WriteString("⚙️ Recording Configuration\n") + samplingMode := m.SamplingMode + if samplingMode == "" { + samplingMode = "adaptive" + } + summary.WriteString(fmt.Sprintf(" • Sampling mode: %s\n", samplingMode)) samplingRate, _ := strconv.ParseFloat(m.SamplingRate, 64) - summary.WriteString(fmt.Sprintf(" • Sampling rate: %.2f (%.0f%% of requests)\n", samplingRate, samplingRate*100)) + summary.WriteString(fmt.Sprintf(" • Base sampling rate: %.2f (%.0f%% of requests)\n", samplingRate, samplingRate*100)) summary.WriteString(fmt.Sprintf(" • Export spans: %t\n", m.ExportSpans)) summary.WriteString(fmt.Sprintf(" • Record environment variables: %t\n\n", m.EnableEnvVarRecording)) diff --git a/internal/tui/onboard-cloud/update.go b/internal/tui/onboard-cloud/update.go index 630d7564..58323c42 100644 --- a/internal/tui/onboard-cloud/update.go +++ b/internal/tui/onboard-cloud/update.go @@ -111,6 +111,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if step.ID() == stepRecordingConfig { // Initialize RecordingConfigTable when going BACK to that step m.RecordingConfigTable = NewRecordingConfigTable( + m.SamplingMode, m.SamplingRate, m.ExportSpans, m.EnableEnvVarRecording, @@ -132,12 +133,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Only handle Enter for saving if not in edit mode if msg.String() == "enter" && !m.RecordingConfigTable.EditMode { - samplingRate, exportSpans, enableEnvVarRecording := m.RecordingConfigTable.GetValues() + samplingMode, samplingRate, exportSpans, enableEnvVarRecording := m.RecordingConfigTable.GetValues() + m.SamplingMode = samplingMode m.SamplingRate = fmt.Sprintf("%.2f", samplingRate) m.ExportSpans = exportSpans m.EnableEnvVarRecording = enableEnvVarRecording - if err := SaveRecordingConfig(samplingRate, exportSpans, enableEnvVarRecording); err != nil { + if err := SaveRecordingConfig(samplingRate, samplingMode, exportSpans, enableEnvVarRecording); err != nil { m.Err = fmt.Errorf("failed to save: %w", err) } else { return m, func() tea.Msg { return stepCompleteMsg{} } @@ -190,6 +192,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // always recreate the table if step.ID() == stepRecordingConfig { m.RecordingConfigTable = NewRecordingConfigTable( + m.SamplingMode, m.SamplingRate, m.ExportSpans, m.EnableEnvVarRecording, From af217b9b9707e7dd62e1f2decc3894146b957f27 Mon Sep 17 00:00:00 2001 From: jy Date: Wed, 29 Apr 2026 05:43:16 +0000 Subject: [PATCH 4/4] fix: default to adaptive for configs without explicit mode, add mode validation --- internal/config/config.go | 6 ++++++ internal/tui/onboard-cloud/helpers.go | 31 +++++++++++++++++++++++++-- internal/tui/onboard-cloud/save.go | 2 ++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index aea7cb83..70d48805 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -543,6 +543,12 @@ func getMinimalSchemaHint() string { interval: 1s` } +// FindConfigFile returns the path to the config file found by traversing +// upward from the current directory. Returns an empty string if not found. +func FindConfigFile() string { + return findConfigFile() +} + func findConfigFile() string { wd, err := os.Getwd() if err != nil { diff --git a/internal/tui/onboard-cloud/helpers.go b/internal/tui/onboard-cloud/helpers.go index e5352135..981f7740 100644 --- a/internal/tui/onboard-cloud/helpers.go +++ b/internal/tui/onboard-cloud/helpers.go @@ -13,6 +13,7 @@ import ( "github.com/Use-Tusk/tusk-cli/internal/cliconfig" "github.com/Use-Tusk/tusk-cli/internal/config" "github.com/Use-Tusk/tusk-cli/internal/utils" + "gopkg.in/yaml.v3" ) func listGitRemotes() (map[string]string, error) { @@ -195,8 +196,13 @@ func loadExistingConfig(m *Model) error { } m.ServiceID = cfg.Service.ID - m.SamplingMode = cfg.Recording.Sampling.Mode - if m.SamplingMode == "" { + // Use the config's sampling mode, but default to "adaptive" when the + // config file doesn't contain an explicit sampling.mode key (the config + // parser normalizes absent mode to "fixed" for backward compatibility, + // but the wizard should default new setups to "adaptive"). + if configHasExplicitSamplingMode() { + m.SamplingMode = cfg.Recording.Sampling.Mode + } else { m.SamplingMode = "adaptive" } m.SamplingRate = fmt.Sprintf("%.2f", cfg.Recording.SamplingRate) @@ -492,3 +498,24 @@ func detectShellConfig() string { // Fallback to .profile return filepath.Join(homeDir, ".profile") } + +// configHasExplicitSamplingMode checks the raw config file for an explicit +// recording.sampling.mode key. Returns false if the key is absent or the +// file can't be read, so the caller can fall back to the wizard default. +func configHasExplicitSamplingMode() bool { + data, err := os.ReadFile(config.FindConfigFile()) + if err != nil { + return false + } + var raw struct { + Recording struct { + Sampling struct { + Mode string `yaml:"mode"` + } `yaml:"sampling"` + } `yaml:"recording"` + } + if err := yaml.Unmarshal(data, &raw); err != nil { + return false + } + return raw.Recording.Sampling.Mode != "" +} diff --git a/internal/tui/onboard-cloud/save.go b/internal/tui/onboard-cloud/save.go index 75966734..e201d073 100644 --- a/internal/tui/onboard-cloud/save.go +++ b/internal/tui/onboard-cloud/save.go @@ -215,6 +215,8 @@ func SaveServiceIDToConfig(serviceID string) error { func SaveRecordingConfig(samplingRate float64, samplingMode string, exportSpans, enableEnvVarRecording bool) error { if samplingMode == "" { samplingMode = "adaptive" + } else if samplingMode != "adaptive" && samplingMode != "fixed" { + return fmt.Errorf("invalid sampling mode %q: must be 'adaptive' or 'fixed'", samplingMode) } return saveToConfig(func(cfg *config.Config, u *ConfigUpdater) error { cfg.Recording.SamplingRate = samplingRate