diff --git a/acceptance/bundle/config-remote-sync/job_fields/output.txt b/acceptance/bundle/config-remote-sync/job_fields/output.txt index 56c26de52a1..7e0b7fe07d4 100644 --- a/acceptance/bundle/config-remote-sync/job_fields/output.txt +++ b/acceptance/bundle/config-remote-sync/job_fields/output.txt @@ -86,6 +86,28 @@ Resource: resources.jobs.my_job targets: +=== Telemetry + +>>> cat out.requests.txt +{ + "save": true, + "changes_total": 12, + "add_count": 6, + "replace_count": 4, + "remove_count": 2, + "resource_changes": [ + { + "resource_type": "jobs", + "changes_count": 12, + "add_count": 6, + "replace_count": 4, + "remove_count": 2 + } + ], + "files_changed_count": 1, + "files_written_count": 1 +} + >>> [CLI] bundle destroy --auto-approve The following resources will be deleted: delete resources.jobs.my_job diff --git a/acceptance/bundle/config-remote-sync/job_fields/script b/acceptance/bundle/config-remote-sync/job_fields/script index 264304d7345..ed2d6dd93ea 100755 --- a/acceptance/bundle/config-remote-sync/job_fields/script +++ b/acceptance/bundle/config-remote-sync/job_fields/script @@ -6,6 +6,8 @@ add_repl.py "$NODE_TYPE_ID" "[NODE_TYPE_ID]" cleanup() { trace $CLI bundle destroy --auto-approve + # destroy records requests too; drop the recorded file so it is not compared. + rm -f out.requests.txt } trap cleanup EXIT @@ -63,3 +65,10 @@ title "Configuration changes" echo trace diff.py databricks.yml.backup databricks.yml rm databricks.yml.backup + +title "Telemetry" +echo +# The engine name is dropped because both EnvMatrix variants share this output; +# every other counter is engine-agnostic. This run exercises add_count and +# remove_count (the remote edits add and remove keyed fields). +trace cat out.requests.txt | jq 'select(has("path") and .path == "/telemetry-ext") | .body.protoLogs[] | fromjson | .entry.databricks_cli_log.bundle_config_remote_sync_event | select(. != null) | del(.engine)' diff --git a/acceptance/bundle/config-remote-sync/job_fields/test.toml b/acceptance/bundle/config-remote-sync/job_fields/test.toml index 625c660c617..2e303f0382a 100644 --- a/acceptance/bundle/config-remote-sync/job_fields/test.toml +++ b/acceptance/bundle/config-remote-sync/job_fields/test.toml @@ -1,7 +1,10 @@ Cloud = true RequiresUnityCatalog = true -RecordRequests = false +# RecordRequests captures the config-remote-sync telemetry event so the script +# can assert the per-operation change counts (add_count/remove_count/...). +# out.requests.txt is removed in the script's cleanup so it is not compared. +RecordRequests = true Ignore = [".databricks", "dummy.whl", "databricks.yml", "databricks.yml.backup"] [Env] diff --git a/acceptance/bundle/config-remote-sync/resolve_variables/output.txt b/acceptance/bundle/config-remote-sync/resolve_variables/output.txt index 520745de4ed..388f7ab3bbf 100644 --- a/acceptance/bundle/config-remote-sync/resolve_variables/output.txt +++ b/acceptance/bundle/config-remote-sync/resolve_variables/output.txt @@ -108,6 +108,35 @@ Resource: resources.pipelines.my_pipeline targets: +=== Telemetry + +>>> cat out.requests.txt +{ + "save": true, + "changes_total": 17, + "add_count": 10, + "replace_count": 6, + "remove_count": 1, + "resource_changes": [ + { + "resource_type": "jobs", + "changes_count": 16, + "add_count": 10, + "replace_count": 5, + "remove_count": 1 + }, + { + "resource_type": "pipelines", + "changes_count": 1, + "replace_count": 1 + } + ], + "files_changed_count": 1, + "files_written_count": 1, + "refs_retargeted": 1, + "refs_from_siblings": 7 +} + >>> [CLI] bundle destroy --auto-approve The following resources will be deleted: delete resources.jobs.my_job diff --git a/acceptance/bundle/config-remote-sync/resolve_variables/script b/acceptance/bundle/config-remote-sync/resolve_variables/script index 449fa9407f2..9a18bfe2d50 100755 --- a/acceptance/bundle/config-remote-sync/resolve_variables/script +++ b/acceptance/bundle/config-remote-sync/resolve_variables/script @@ -4,6 +4,8 @@ envsubst < databricks.yml.tmpl > databricks.yml cleanup() { trace $CLI bundle destroy --auto-approve + # destroy records requests too; drop the recorded file so it is not compared. + rm -f out.requests.txt } trap cleanup EXIT @@ -127,3 +129,11 @@ title "Configuration changes" echo trace diff.py databricks.yml.backup databricks.yml rm databricks.yml.backup + +title "Telemetry" +echo +# The engine name is dropped because both EnvMatrix variants share this output; +# every other counter is engine-agnostic. This run exercises refs_retargeted +# (the env param re-targeted to a different variable) and refs_from_siblings +# (added params/tasks restored from sibling references). +trace cat out.requests.txt | jq 'select(has("path") and .path == "/telemetry-ext") | .body.protoLogs[] | fromjson | .entry.databricks_cli_log.bundle_config_remote_sync_event | select(. != null) | del(.engine)' diff --git a/acceptance/bundle/config-remote-sync/resolve_variables/test.toml b/acceptance/bundle/config-remote-sync/resolve_variables/test.toml index 3174477f7d2..fff2325bf22 100644 --- a/acceptance/bundle/config-remote-sync/resolve_variables/test.toml +++ b/acceptance/bundle/config-remote-sync/resolve_variables/test.toml @@ -1,7 +1,11 @@ Cloud = true RequiresUnityCatalog = true -RecordRequests = false +# RecordRequests captures the config-remote-sync telemetry event so the script +# can assert the variable-reference restoration counts (refs_retargeted / +# refs_from_siblings). out.requests.txt is removed in the script's cleanup so it +# is not compared. +RecordRequests = true Ignore = [".databricks", "databricks.yml", "databricks.yml.backup"] [Env] diff --git a/acceptance/bundle/telemetry/config-remote-sync-error/databricks.yml b/acceptance/bundle/telemetry/config-remote-sync-error/databricks.yml new file mode 100644 index 00000000000..c0c9f880163 --- /dev/null +++ b/acceptance/bundle/telemetry/config-remote-sync-error/databricks.yml @@ -0,0 +1,11 @@ +bundle: + name: config-remote-sync-telemetry-error + +resources: + jobs: + foo: + name: test job + tasks: + - task_key: main + notebook_task: + notebook_path: /Workspace/Users/tester@databricks.com/notebook diff --git a/acceptance/bundle/telemetry/config-remote-sync-error/out.test.toml b/acceptance/bundle/telemetry/config-remote-sync-error/out.test.toml new file mode 100644 index 00000000000..4e136c6838f --- /dev/null +++ b/acceptance/bundle/telemetry/config-remote-sync-error/out.test.toml @@ -0,0 +1,4 @@ +Local = true +Cloud = false +GOOS.windows = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform"] diff --git a/acceptance/bundle/telemetry/config-remote-sync-error/output.txt b/acceptance/bundle/telemetry/config-remote-sync-error/output.txt new file mode 100644 index 00000000000..42525087c62 --- /dev/null +++ b/acceptance/bundle/telemetry/config-remote-sync-error/output.txt @@ -0,0 +1,12 @@ + +>>> errcode [CLI] bundle config-remote-sync +Error: failed to detect changes: state snapshot not available: resources state snapshot not found remotely at resources-config-sync-snapshot.json: state snapshot not found + +Exit code: 1 + +>>> cat out.requests.txt +{ + "engine": "terraform", + "error_message": "failed to detect changes: state snapshot not available: resources state snapshot not found remotely at resources-config-sync-snapshot.json: state snapshot not found", + "error_category": "STATE_NOT_FOUND" +} diff --git a/acceptance/bundle/telemetry/config-remote-sync-error/script b/acceptance/bundle/telemetry/config-remote-sync-error/script new file mode 100644 index 00000000000..e48fb6ff123 --- /dev/null +++ b/acceptance/bundle/telemetry/config-remote-sync-error/script @@ -0,0 +1,7 @@ +# Running config-remote-sync without a prior deploy: the state snapshot does +# not exist, so the command fails and telemetry reports STATE_NOT_FOUND. +trace errcode $CLI bundle config-remote-sync + +trace cat out.requests.txt | jq 'select(has("path") and .path == "/telemetry-ext") | .body.protoLogs[] | fromjson | .entry.databricks_cli_log.bundle_config_remote_sync_event | select(. != null)' + +rm out.requests.txt diff --git a/acceptance/bundle/telemetry/config-remote-sync-error/test.toml b/acceptance/bundle/telemetry/config-remote-sync-error/test.toml new file mode 100644 index 00000000000..ecfc085a640 --- /dev/null +++ b/acceptance/bundle/telemetry/config-remote-sync-error/test.toml @@ -0,0 +1,9 @@ +# config-remote-sync is not supported on Windows. +[GOOS] +windows = false + +# STATE_NOT_FOUND is a terraform-only path: the terraform engine pulls a config +# snapshot that doesn't exist without a prior deploy, while the direct engine +# does not. Pin the engine so the test exercises that specific failure. +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform"] diff --git a/acceptance/bundle/telemetry/config-remote-sync-recreate/databricks.yml b/acceptance/bundle/telemetry/config-remote-sync-recreate/databricks.yml new file mode 100644 index 00000000000..ba8a696ceb8 --- /dev/null +++ b/acceptance/bundle/telemetry/config-remote-sync-recreate/databricks.yml @@ -0,0 +1,16 @@ +bundle: + name: config-remote-sync-recreate + +resources: + pipelines: + foo: + name: test pipeline + # ingestion_definition.connection_name is immutable (recreate_on_changes): + # a remote change to it means the next deploy would delete + recreate the + # pipeline. (storage, the other immutable pipeline field, is skipped by + # configsync because the backend auto-generates it, so it can't be used + # here.) + ingestion_definition: + connection_name: my_connection + objects: + - {} diff --git a/acceptance/bundle/telemetry/config-remote-sync-recreate/out.test.toml b/acceptance/bundle/telemetry/config-remote-sync-recreate/out.test.toml new file mode 100644 index 00000000000..f7c4cf648a9 --- /dev/null +++ b/acceptance/bundle/telemetry/config-remote-sync-recreate/out.test.toml @@ -0,0 +1,4 @@ +Local = true +Cloud = false +GOOS.windows = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/telemetry/config-remote-sync-recreate/output.txt b/acceptance/bundle/telemetry/config-remote-sync-recreate/output.txt new file mode 100644 index 00000000000..c002f652ed3 --- /dev/null +++ b/acceptance/bundle/telemetry/config-remote-sync-recreate/output.txt @@ -0,0 +1,30 @@ + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/config-remote-sync-recreate/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> [CLI] bundle config-remote-sync +Detected changes in 1 resource(s): + +Resource: resources.pipelines.foo + ingestion_definition.connection_name: replace + + + +>>> cat out.requests.txt +{ + "engine": "direct", + "changes_total": 1, + "replace_count": 1, + "recreate_forcing_changes": 1, + "resource_changes": [ + { + "resource_type": "pipelines", + "changes_count": 1, + "replace_count": 1 + } + ], + "files_changed_count": 1 +} diff --git a/acceptance/bundle/telemetry/config-remote-sync-recreate/script b/acceptance/bundle/telemetry/config-remote-sync-recreate/script new file mode 100644 index 00000000000..eb6c1773888 --- /dev/null +++ b/acceptance/bundle/telemetry/config-remote-sync-recreate/script @@ -0,0 +1,15 @@ +trace $CLI bundle deploy + +pipeline_id="$(read_id.py foo)" + +# Change the immutable ingestion_definition.connection_name remotely. +# config-remote-sync would sync this into config, so recreate_forcing_changes is 1. +edit_resource.py pipelines $pipeline_id <>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/config-remote-sync-save/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> [CLI] bundle config-remote-sync --save +Detected changes in 1 resource(s): + +Resource: resources.jobs.foo + name: replace + + + +>>> cat out.requests.txt +{ + "save": true, + "engine": "direct", + "changes_total": 1, + "replace_count": 1, + "overwritten_local_edits": 1, + "resource_changes": [ + { + "resource_type": "jobs", + "changes_count": 1, + "replace_count": 1 + } + ], + "files_changed_count": 1, + "files_written_count": 1 +} diff --git a/acceptance/bundle/telemetry/config-remote-sync-save/script b/acceptance/bundle/telemetry/config-remote-sync-save/script new file mode 100644 index 00000000000..0434f3dc882 --- /dev/null +++ b/acceptance/bundle/telemetry/config-remote-sync-save/script @@ -0,0 +1,12 @@ +trace $CLI bundle deploy + +# Edit the job name locally (not deployed) so the local config diverges from the +# deployed state. config-remote-sync --save then overwrites that pending edit, +# exercising save, files_written_count, and overwritten_local_edits. +update_file.py databricks.yml "test job" "locally edited job name" + +trace $CLI bundle config-remote-sync --save + +trace cat out.requests.txt | jq 'select(has("path") and .path == "/telemetry-ext") | .body.protoLogs[] | fromjson | .entry.databricks_cli_log.bundle_config_remote_sync_event | select(. != null)' + +rm out.requests.txt diff --git a/acceptance/bundle/telemetry/config-remote-sync-save/test.toml b/acceptance/bundle/telemetry/config-remote-sync-save/test.toml new file mode 100644 index 00000000000..5f5f32dc258 --- /dev/null +++ b/acceptance/bundle/telemetry/config-remote-sync-save/test.toml @@ -0,0 +1,11 @@ +# --save rewrites databricks.yml; it's a mutated input, not asserted output. +Ignore = ["databricks.yml", "databricks.yml.backup"] + +# config-remote-sync is not supported on Windows. +[GOOS] +windows = false + +# overwritten_local_edits is computed from the per-field deployed base, which +# only the direct engine materializes (the terraform sync snapshot is empty). +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/telemetry/config-remote-sync/databricks.yml b/acceptance/bundle/telemetry/config-remote-sync/databricks.yml new file mode 100644 index 00000000000..543dc4129bd --- /dev/null +++ b/acceptance/bundle/telemetry/config-remote-sync/databricks.yml @@ -0,0 +1,11 @@ +bundle: + name: config-remote-sync-telemetry + +resources: + jobs: + foo: + name: test job + tasks: + - task_key: main + notebook_task: + notebook_path: /Workspace/Users/tester@databricks.com/notebook diff --git a/acceptance/bundle/telemetry/config-remote-sync/out.test.toml b/acceptance/bundle/telemetry/config-remote-sync/out.test.toml new file mode 100644 index 00000000000..f7c4cf648a9 --- /dev/null +++ b/acceptance/bundle/telemetry/config-remote-sync/out.test.toml @@ -0,0 +1,4 @@ +Local = true +Cloud = false +GOOS.windows = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/telemetry/config-remote-sync/output.txt b/acceptance/bundle/telemetry/config-remote-sync/output.txt new file mode 100644 index 00000000000..8886bae291a --- /dev/null +++ b/acceptance/bundle/telemetry/config-remote-sync/output.txt @@ -0,0 +1,29 @@ + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/config-remote-sync-telemetry/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> [CLI] bundle config-remote-sync +Detected changes in 1 resource(s): + +Resource: resources.jobs.foo + name: replace + + + +>>> cat out.requests.txt +{ + "engine": "direct", + "changes_total": 1, + "replace_count": 1, + "resource_changes": [ + { + "resource_type": "jobs", + "changes_count": 1, + "replace_count": 1 + } + ], + "files_changed_count": 1 +} diff --git a/acceptance/bundle/telemetry/config-remote-sync/script b/acceptance/bundle/telemetry/config-remote-sync/script new file mode 100644 index 00000000000..18247c96ce6 --- /dev/null +++ b/acceptance/bundle/telemetry/config-remote-sync/script @@ -0,0 +1,12 @@ +trace $CLI bundle deploy + +job_id="$(read_id.py foo)" +edit_resource.py jobs $job_id < maxErrorMessageLength { - errMsg = errMsg[:maxErrorMessageLength] - } + errMsg = telemetry.ScrubErrorMessage(errMsg) resourcesCount := int64(0) _, err := dyn.MapByPattern(b.Config.Value(), dyn.NewPattern(dyn.Key("resources"), dyn.AnyKey(), dyn.AnyKey()), func(p dyn.Path, v dyn.Value) (dyn.Value, error) { diff --git a/cmd/bundle/config_remote_sync.go b/cmd/bundle/config_remote_sync.go index c50e613d71a..2192852497c 100644 --- a/cmd/bundle/config_remote_sync.go +++ b/cmd/bundle/config_remote_sync.go @@ -12,8 +12,11 @@ import ( "github.com/databricks/cli/bundle/statemgmt" "github.com/databricks/cli/cmd/bundle/utils" "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/flags" "github.com/databricks/cli/libs/log" + "github.com/databricks/cli/libs/telemetry" + "github.com/databricks/cli/libs/telemetry/protos" "github.com/spf13/cobra" ) @@ -46,6 +49,18 @@ Examples: return errors.New("config-remote-sync command is not supported on Windows") } + stats := configsync.Stats{Save: save} + + // Emit telemetry on every exit path, including failures inside + // ProcessBundleRet before PostStateFunc runs. Skip when no auth config + // was resolved: without it the upload at the end of the command + // lifecycle has no workspace to send to. + defer func() { + if cmdctx.HasConfigUsed(cmd.Context()) { + stats.LogTelemetry(cmd.Context()) + } + }() + _, _, err := utils.ProcessBundleRet(cmd, utils.ProcessOptions{ ReadState: true, Build: true, @@ -54,29 +69,41 @@ Examples: b.SkipLocalFileValidation = true }, PostStateFunc: func(ctx context.Context, b *bundle.Bundle, stateDesc *statemgmt.StateDesc) error { + stats.Engine = stateDesc.Engine + changes, err := configsync.DetectChanges(ctx, b, stateDesc.Engine) if err != nil { + stats.ErrorCategory = protos.BundleConfigRemoteSyncErrorCategoryDetectChangesFailed + if errors.Is(err, configsync.ErrStateSnapshotNotFound) { + stats.ErrorCategory = protos.BundleConfigRemoteSyncErrorCategoryStateNotFound + } return fmt.Errorf("failed to detect changes: %w", err) } + stats.CollectChangeStats(ctx, changes) fieldChanges, err := configsync.ResolveChanges(ctx, b, changes) if err != nil { + stats.ErrorCategory = protos.BundleConfigRemoteSyncErrorCategoryResolveFailed return fmt.Errorf("failed to resolve field changes: %w", err) } - if err := configsync.RestoreVariableReferences(ctx, b, fieldChanges); err != nil { + if err := configsync.RestoreVariableReferences(ctx, b, fieldChanges, &stats.Restore); err != nil { log.Warnf(ctx, "variable restoration skipped: %v", err) } files, err := configsync.ApplyChangesToYAML(ctx, b, fieldChanges) if err != nil { + stats.ErrorCategory = protos.BundleConfigRemoteSyncErrorCategoryYamlApplyFailed return fmt.Errorf("failed to generate YAML files: %w", err) } + stats.FilesChangedCount = int64(len(files)) if save { if err := configsync.SaveFiles(ctx, b, files); err != nil { + stats.ErrorCategory = protos.BundleConfigRemoteSyncErrorCategorySaveFailed return fmt.Errorf("failed to save files: %w", err) } + stats.FilesWrittenCount = int64(len(files)) } var result []byte @@ -87,6 +114,7 @@ Examples: } result, err = json.MarshalIndent(diffOutput, "", " ") if err != nil { + stats.ErrorCategory = protos.BundleConfigRemoteSyncErrorCategoryOutputFailed return fmt.Errorf("failed to marshal output: %w", err) } } else if root.OutputType(cmd) == flags.OutputText { @@ -99,6 +127,12 @@ Examples: return nil }, }) + if err != nil { + if stats.ErrorCategory == "" { + stats.ErrorCategory = protos.BundleConfigRemoteSyncErrorCategoryBundleLoadFailed + } + stats.ErrorMessage = telemetry.ScrubErrorMessage(err.Error()) + } return err } diff --git a/libs/telemetry/protos/bundle_config_remote_sync.go b/libs/telemetry/protos/bundle_config_remote_sync.go new file mode 100644 index 00000000000..466ef3af0f9 --- /dev/null +++ b/libs/telemetry/protos/bundle_config_remote_sync.go @@ -0,0 +1,83 @@ +package protos + +type BundleConfigRemoteSyncErrorCategory string + +const ( + BundleConfigRemoteSyncErrorCategoryUnspecified BundleConfigRemoteSyncErrorCategory = "TYPE_UNSPECIFIED" + BundleConfigRemoteSyncErrorCategoryBundleLoadFailed BundleConfigRemoteSyncErrorCategory = "BUNDLE_LOAD_FAILED" + BundleConfigRemoteSyncErrorCategoryStateNotFound BundleConfigRemoteSyncErrorCategory = "STATE_NOT_FOUND" + BundleConfigRemoteSyncErrorCategoryDetectChangesFailed BundleConfigRemoteSyncErrorCategory = "DETECT_CHANGES_FAILED" + BundleConfigRemoteSyncErrorCategoryResolveFailed BundleConfigRemoteSyncErrorCategory = "RESOLVE_FAILED" + BundleConfigRemoteSyncErrorCategoryYamlApplyFailed BundleConfigRemoteSyncErrorCategory = "YAML_APPLY_FAILED" + BundleConfigRemoteSyncErrorCategorySaveFailed BundleConfigRemoteSyncErrorCategory = "SAVE_FAILED" + BundleConfigRemoteSyncErrorCategoryOutputFailed BundleConfigRemoteSyncErrorCategory = "OUTPUT_FAILED" +) + +// BundleConfigRemoteSyncEvent is emitted on every execution of the +// `databricks bundle config-remote-sync` command. +// +// All fields are aggregate counts, booleans, or system-defined categories. +// No resource names, keys, field paths, file paths, or configuration values +// are logged. +type BundleConfigRemoteSyncEvent struct { + // Whether the command was invoked with --save (config files written to + // disk) as opposed to diff-only mode. + Save bool `json:"save,omitempty"` + + // Deployment engine the state was read from: "direct" or "terraform". + Engine string `json:"engine,omitempty"` + + // Total number of field-level changes detected between the deployed state + // and the current remote state, across all resources. + ChangesTotal int64 `json:"changes_total,omitempty"` + + // Number of detected changes by operation type. + AddCount int64 `json:"add_count,omitempty"` + ReplaceCount int64 `json:"replace_count,omitempty"` + RemoveCount int64 `json:"remove_count,omitempty"` + + // Number of detected changes on fields the resource lifecycle metadata + // marks recreate_on_changes (immutable): syncing these into config means the + // next deploy will delete+recreate the resource (a data-loss risk). + RecreateForcingChanges int64 `json:"recreate_forcing_changes,omitempty"` + + // Number of detected changes that overwrite a not-yet-deployed local config + // edit (the local value differs from the last-deployed state). Direct engine + // only; the terraform sync snapshot has no per-field base to compare against. + OverwrittenLocalEdits int64 `json:"overwritten_local_edits,omitempty"` + + // One entry per resource type that has at least one detected change. + ResourceChanges []BundleConfigRemoteSyncResourceChanges `json:"resource_changes,omitempty"` + + // Number of configuration files that would be modified by the detected + // changes, and the number actually written to disk (--save only). + FilesChangedCount int64 `json:"files_changed_count,omitempty"` + FilesWrittenCount int64 `json:"files_written_count,omitempty"` + + // Variable-reference restoration counts for the two mechanisms that can + // write a current-target-scoped reference into a shared file (the source of + // the cross-target "reference does not exist" failures). + RefsRetargeted int64 `json:"refs_retargeted,omitempty"` + RefsFromSiblings int64 `json:"refs_from_siblings,omitempty"` + + // Scrubbed, truncated summary of the failure when the command exits with an + // error. Privileged free-text (DATA_LABEL_USER_COMMANDS_RESPONSE, LPP-5543); + // stays in-region and is stripped from centralized logfood. Unset on success. + ErrorMessage string `json:"error_message,omitempty"` + + // Category of the failure when the command exits with an error. + // Unset on success. + ErrorCategory BundleConfigRemoteSyncErrorCategory `json:"error_category,omitempty"` +} + +// BundleConfigRemoteSyncResourceChanges holds field-level change counts for a +// single resource type within one config-remote-sync run. +type BundleConfigRemoteSyncResourceChanges struct { + // Resource type name, e.g. "jobs", "pipelines", "dashboards". + ResourceType string `json:"resource_type,omitempty"` + + ChangesCount int64 `json:"changes_count,omitempty"` + AddCount int64 `json:"add_count,omitempty"` + ReplaceCount int64 `json:"replace_count,omitempty"` + RemoveCount int64 `json:"remove_count,omitempty"` +} diff --git a/libs/telemetry/protos/frontend_log.go b/libs/telemetry/protos/frontend_log.go index 816297a8eef..676f72055da 100644 --- a/libs/telemetry/protos/frontend_log.go +++ b/libs/telemetry/protos/frontend_log.go @@ -16,8 +16,9 @@ type FrontendLogEntry struct { type DatabricksCliLog struct { ExecutionContext *ExecutionContext `json:"execution_context,omitempty"` - CliTestEvent *CliTestEvent `json:"cli_test_event,omitempty"` - BundleInitEvent *BundleInitEvent `json:"bundle_init_event,omitempty"` - BundleDeployEvent *BundleDeployEvent `json:"bundle_deploy_event,omitempty"` - SshTunnelEvent *SshTunnelEvent `json:"ssh_tunnel_event,omitempty"` + CliTestEvent *CliTestEvent `json:"cli_test_event,omitempty"` + BundleInitEvent *BundleInitEvent `json:"bundle_init_event,omitempty"` + BundleDeployEvent *BundleDeployEvent `json:"bundle_deploy_event,omitempty"` + SshTunnelEvent *SshTunnelEvent `json:"ssh_tunnel_event,omitempty"` + BundleConfigRemoteSyncEvent *BundleConfigRemoteSyncEvent `json:"bundle_config_remote_sync_event,omitempty"` } diff --git a/bundle/phases/telemetry_scrub.go b/libs/telemetry/scrub.go similarity index 91% rename from bundle/phases/telemetry_scrub.go rename to libs/telemetry/scrub.go index 051c81bd75b..582526b93a7 100644 --- a/bundle/phases/telemetry_scrub.go +++ b/libs/telemetry/scrub.go @@ -1,4 +1,4 @@ -package phases +package telemetry import ( "path" @@ -6,6 +6,20 @@ import ( "strings" ) +// Maximum length of an error message included in telemetry. +const maxErrorMessageLength = 500 + +// ScrubErrorMessage scrubs sensitive paths and PII from an error message and +// truncates it to maxErrorMessageLength, producing a value safe to attach to a +// telemetry event. The result is still treated as privileged data in-region. +func ScrubErrorMessage(msg string) string { + msg = scrubForTelemetry(msg) + if len(msg) > maxErrorMessageLength { + msg = msg[:maxErrorMessageLength] + } + return msg +} + // Scrub sensitive information from error messages before sending to telemetry. // Inspired by VS Code's telemetry path scrubbing and Sentry's @userpath pattern. // diff --git a/bundle/phases/telemetry_scrub_test.go b/libs/telemetry/scrub_test.go similarity index 99% rename from bundle/phases/telemetry_scrub_test.go rename to libs/telemetry/scrub_test.go index 556b3783ed3..9ae21cad3ed 100644 --- a/bundle/phases/telemetry_scrub_test.go +++ b/libs/telemetry/scrub_test.go @@ -1,4 +1,4 @@ -package phases +package telemetry import ( "testing"