Split the SSH extension telemetry category by failure outcome - #6497
Split the SSH extension telemetry category by failure outcome#6497anton-107 wants to merge 4 commits into
Conversation
CheckIDESSHExtension reports four distinct outcomes as the single IDE_SSH_EXTENSION_MISSING category: the --list-extensions call failing, a declined prompt, no way to ask for consent, and the install itself being rejected. That category is now the largest source of failed IDE-mode tunnels, and the four call for different fixes -- a list failure means the check never ran, while a rejected install points at the marketplace or a policy blocking it -- so one value cannot tell us which to fix. Give each outcome its own category, attributed through errors.Is sentinels rather than message matching. Only LIST_FAILED and INSTALL_FAILED are reachable under --auto-approve, which the VS Code extension's tunnel button always passes, so the split also separates button traffic from direct CLI use. IDE_SSH_EXTENSION_MISSING is retired rather than reused: rows written before this change still carry it, so a query spanning the release has to accept both. Co-authored-by: Isaac <no-reply@databricks.com>
anton-107
left a comment
There was a problem hiding this comment.
Review
The refactor is sound and well-executed — sentinel-based attribution instead of string matching, retiring rather than reusing the enum value, a documented migration note, and clean removal with no dangling references to the old constant. CI is fully green, and I independently confirmed lint/vet clean and all four suites passing. The acceptance-golden claim holds (grepped; nothing depends on the old text).
Three things worth fixing before merge, in severity order.
1. Ctrl-C during install is silently counted as INSTALL_FAILED — and the new comment asserts the opposite
Probed directly (fake IDE that hangs on --install-extension, cancelled mid-flight):
err: could not install the Remote SSH extension in VS Code: signal: killed
errors.Is(err, context.Canceled) = false <- category() checks this for USER_ABORTED
errors.Is(err, ErrSSHExtensionInstallFailed) = true
exec.CommandContext kills the child and returns *exec.ExitError; it does not wrap context.Canceled. So category()'s USER_ABORTED branch never fires, and the attempt lands in IDE_SSH_EXTENSION_INSTALL_FAILED. Same applies to LIST_FAILED.
The misattribution itself predates this PR — it previously fell into the undifferentiated MISSING bucket, where it was harmless. What's new is the claim this PR bakes into ssh_tunnel.go:
Points at the marketplace or a policy that forbids the install rather than at anything the user chose.
For a user who Ctrl-C's a slow marketplace download, that is precisely backwards. This matters more than it looks: under --auto-approve — which the IDE button always passes — INSTALL_FAILED is one of only two reachable categories, so this pollution lands directly in the bucket the PR says will be used to diagnose marketplace/policy blocks.
One viable fix: check ctx.Err() in Run's telemetry defer and let it win over the site category. The defer ordering already works in your favour — defer cancel() is registered first, so the logging defer runs before it (LIFO), meaning ctx.Err() there reflects genuine cancellation rather than the function's own cleanup. Alternatively, propagate ctx.Err() at the two exec sites in run.go. Either way, if you'd rather not fix it now, the comment should say so rather than claim the opposite.
2. The DECLINED mapping has no real coverage — proven by mutation
Changing the declined branch to return the wrong sentinel (Unavailable instead of Declined) and running the full vscode + client suites:
ok github.com/databricks/cli/experimental/ssh/internal/vscode 2.887s
ok github.com/databricks/cli/experimental/ssh/internal/client (cached)
Both green. Nothing tests that CheckIDESSHExtension actually returns ErrSSHExtensionInstallDeclined on the declined path. TestSshExtensionErrorCategory hand-constructs the sentinel (fmt.Errorf("%w: install it with ...", vscode.ErrSSHExtensionInstallDeclined)), so it verifies the switch statement against itself — tautological for the wiring. The other three new sentinels are covered end to end; DECLINED is the gap, and it is the one that distinguishes "user said no" from "couldn't ask," which is the distinction the analysis rests on.
This is easily closed. The following passes and does catch the mutation:
ctx, tst := cmdio.SetupTest(t.Context(), cmdio.TestOptions{PromptSupported: true})
defer tst.Done()
go func() { _, _ = io.Copy(io.Discard, tst.Stderr) }() // prompt goes to stderr; must be drained or it deadlocks
go func() { tst.Stdin.WriteString("n\n"); tst.Stdin.Flush() }()
err := CheckIDESSHExtension(ctx, VSCodeOption, false)
assert.ErrorIs(t, err, ErrSSHExtensionInstallDeclined)Same pattern as settings_test.go:531. (The AskYesOrNo-error → Unavailable path is also untested, but that one is harder to reach and lower value.)
3. The merge-order safety claim does not hold as written
The description says: "unknown enum values are ignored, so this PR is safe to merge in either order — it just is not queryable until both are in." Testing protojson against a plain (non-well-known) enum:
unknown enum VALUE -> err = proto: invalid value for enum field type: "TYPE_TOTALLY_UNKNOWN"
unknown enum VALUE + DiscardUnknown -> err = <nil>, field = <zero value>
By default an unknown enum value is a hard unmarshal error. It is only tolerated with DiscardUnknown: true — and DiscardUnknown is documented for unknown fields, so relying on it for unknown values is a second assumption. Which branch applies depends on the ingestion pipeline's options, which isn't visible from this repo. Both outcomes are worse than "not queryable":
- No
DiscardUnknown→ the wholeSshTunnelEventrow fails to parse. That loses the entire event for every IDE extension failure, not just the category — strictly worse than today. - With
DiscardUnknown→ the field collapses to its zero value,TYPE_UNSPECIFIED, which is exactly what the CLI sends on success. Failures would read as successes on that field. (is_success = falsestill saves you for post-v1.14.0 rows, buterror_categorybecomes actively misleading for the window.)
Worth confirming with whoever owns ingestion before merging ahead of the proto PR. Also worth linking the companion universe PR in the body — it is referenced but not linked.
Minor
The sentinel does double duty as machine tag and user-facing prose, and on the most common path it reads backwards:
Error: cannot prompt to install the Remote SSH extension: Required extension
"Remote - SSH" is not installed in VS Code. Install it with: code --install-extension ...
It leads with a CLI-internal mechanism and then redundantly restates the install. The prior wording opened with the user's actual problem. Since the description says the messages were hand-checked and "read correctly," this one is worth a second look — consider a terser sentinel, or keep the sentinels purely for errors.Is and lead the message with the problem.
Nothing else: error strings are ST1005-clean, the --auto-approve reachability claim checks out (only LIST_FAILED/INSTALL_FAILED), and skipping the changelog fragment matches the cited precedent.
Review performed by Isaac.
…messages Three fixes from review of the extension-category split: Ctrl-C during the extension check was counted as INSTALL_FAILED or LIST_FAILED. exec.CommandContext kills the child and returns *exec.ExitError, which does not wrap context.Canceled, so category()'s USER_ABORTED branch never fired. Record the connect context's error in the telemetry defer -- registered after `defer cancel()`, so it runs first (LIFO) and sees only genuine cancellation -- and let it win over the site category. Without this, the bucket the split exists to make diagnosable collects users who just gave up. Cover the declined path end to end by driving the real prompt. The mapping test hand-built the sentinel, so nothing checked that CheckIDESSHExtension actually returns ErrSSHExtensionInstallDeclined: swapping it for Unavailable kept both suites green. Keep the consent sentinels out of the message they carry. Prefixed with %w they made the most common path lead with a CLI-internal tag and then restate the problem. consentError renders the message and unwraps to the sentinel, the same shape as bundle/configsync's noMatchingSelectorError, so these two messages are unchanged from before the split. Co-authored-by: Isaac <no-reply@databricks.com>
Integration test reportCommit: 0c498db
Top 6 slowest tests (at least 2 minutes):
|
Follow-up from e2e review of the interrupted-attempt fix. `ctx.Err() != nil` also matches `context.DeadlineExceeded`, so a timeout would have been reported as USER_ABORTED, which means "Ctrl-C or a termination signal". No ancestor of the connect context carries a deadline today -- verified: no WithTimeout or WithDeadline on the command path, and Run never reassigns ctx after its own WithCancel -- so this is unreachable, but the predicate stated the wrong thing and a deadline added upstream later would have poisoned the category silently. Storing the cause instead of a bool also moves the discrimination out of the defer and into category(), where it is unit-testable: the deadline case is a table entry rather than untested glue. Re-verified e2e after the change: the same interrupted extension check on dogfood still reports USER_ABORTED, and an uninterrupted failing list still reports IDE_SSH_EXTENSION_LIST_FAILED. Co-authored-by: Isaac <no-reply@databricks.com>
Changes
CheckIDESSHExtensionreported four distinct outcomes as one telemetry category,IDE_SSH_EXTENSION_MISSING. Split it into four, each attributed through anerrors.Issentinel rather than message matching:--list-extensionsfailed, so what is installed is unknownIDE_SSH_EXTENSION_LIST_FAILEDIDE_SSH_EXTENSION_INSTALL_FAILEDIDE_SSH_EXTENSION_INSTALL_DECLINED--auto-approve, no usable prompt)IDE_SSH_EXTENSION_INSTALL_UNAVAILABLEIDE_SSH_EXTENSION_MISSINGis retired rather than reused, and the enum commentsays so: rows written before this change still carry it, so a query spanning the
release has to accept both spellings.
A Ctrl-C is no longer counted as a failed install
Splitting the category exposed that it was collecting users who simply gave up.
exec.CommandContextkills the child and returns*exec.ExitError, which doesnot wrap
context.Canceled, socategory()'s existingUSER_ABORTEDbranchnever fired for a step that shells out: an interrupted extension check was
reported as a rejected install or a failed list.
Runnow records the connect context's error alongside the returned error, and acancellation wins over the category recorded at the failure site. The telemetry
defer is registered after
defer cancel()and so runs before it (LIFO), whichmeans the context is cancelled there only by the signal handler or the caller,
never by
Run's own cleanup.It matches the cancellation cause rather than testing for any context error, so
an expired deadline would stay a timeout instead of becoming a user abort. No
ancestor of the connect context carries a deadline today — there is no
WithTimeout/WithDeadlineon the command path andRunnever reassignsctxafter its own
WithCancel— so that case is currently unreachable, but thepredicate should not assert something false, and storing the cause keeps the
discrimination in
category()where it is unit-testable.This matters most where the split is meant to pay off:
INSTALL_FAILEDis one ofonly two categories reachable under
--auto-approve, which the VS Codeextension's tunnel button always passes, so abandoned attempts were landing
directly in the bucket that is supposed to mean "the marketplace or a policy
blocked the install".
Error messages
The two exec failures now lead with their sentinel, so their wording changes. The
two consent messages are unchanged from before the split: prefixing those with
%wmade the most common path lead with a CLI-internal tag and then restate theproblem. They use a
consentErrorthat renders the message and unwraps to thesentinel, the same shape as
bundle/configsync'snoMatchingSelectorError. Noacceptance golden depends on any of this text.
Why
This category is the largest single source of failed IDE-mode tunnels, and the
stickiest: of the users whose first attempt hit it, only about an eighth ever
established a tunnel, despite averaging 2.7 attempts. It is also the category we
can say least about, because the four outcomes need different fixes — a list
failure means the check never ran, while a rejected install points at the
marketplace or a policy forbidding it. One value cannot tell us which to fix, so
this is a prerequisite for fixing the dominant failure rather than a fix itself.
Only
LIST_FAILEDandINSTALL_FAILEDare reachable under--auto-approve, sothe split also separates IDE-button traffic from direct CLI use, which the single
category could not.
Tests
Unit:
TestSshExtensionErrorCategorypins the sentinel-to-category mapping, includingthrough wrapping, and that an unsentinelled error falls back to
UNKNOWN.TestCheckIDESSHExtension_ListFailscovers a command that resolves on PATH butwhose
--list-extensionsexits non-zero — the case that must not read as aninstall problem.
TestCheckIDESSHExtension_AutoApprove_InstallFailscovers a rejected--install-extension, the one outcome the IDE button can produce here.TestCheckIDESSHExtension_Declineddrives the real prompt and answers "n", sothe declined path is covered end to end rather than against a hand-built error.
Verified by mutation: returning the
Unavailablesentinel from that branch isnot caught without this test, and is caught with it.
TestConnectOutcomeCategorygains cases for a cancellation winning over thecategory recorded at the failure site, for an expired deadline staying a
timeout, and for an interruption after the tunnel is up reporting no category.
the text the user reads.
End to end, against real workspaces, reading the telemetry the CLI actually
uploads:
error text (
could not list installed extensions in VS Code: signal: killed),IDE_SSH_EXTENSION_LIST_FAILEDbefore andUSER_ABORTEDafter.--list-extensionsstill reportsIDE_SSH_EXTENSION_LIST_FAILED, and ingestion accepts it (see Merge order).Connected!, remote outputreceived,
is_success: true,error_category: TYPE_UNSPECIFIED,server_start_time_ms: 29556, exit code 0. This covers the parent process; thessh connect --proxychild logs its own event, andToProxyCommanddoes notpropagate
--log-level, so that one was not observed directly.go build ./...,go vet, the full./experimental/ssh/...and./libs/telemetry/...suites,TestAccept/ssh, and./task lint-qare clean.Not in scope
CheckIDESSHExtensionreads--list-extensionswithexec.Cmd.Output(), whichwaits for the stdout pipe to close. If the IDE command forks, killing it on
cancellation leaves the grandchild holding the pipe and the call blocks past the
Ctrl-C. Pre-existing and unrelated to the categories; noted here because it
surfaced while testing the interrupt path.
No changelog fragment: this is internal telemetry under
experimental/, matching#4881, #6058 and #6321.
This pull request and its description were written by Isaac.