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
19 changes: 18 additions & 1 deletion .github/workflows/add-to-kanban.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,24 @@ jobs:
add-to-project:
runs-on: ubuntu-latest
steps:
# Board writes authenticate as the tracebloc-release-train App (backend#2036),
# not a human's PAT. `owner:` yields an ORG-scoped installation token; a
# repo-scoped one cannot write the org project. No fallback to the PAT: a
# fallback would let a broken App path keep working silently.
#
# This workflow also fires on DEPENDABOT PRs, which GitHub gates on a separate
# secret scope -- both app secrets are set there too, or Dependabot PRs would
# stop reaching the board with `Input required and not supplied`.
- name: Mint an installation token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.RELEASE_TRAIN_APP_ID }}
private-key: ${{ secrets.RELEASE_TRAIN_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}

- uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2.0.0
with:
project-url: https://github.com/orgs/tracebloc/projects/2
github-token: ${{ secrets.PROJECTS_KANBAN_TOKEN }}
github-token: ${{ steps.app-token.outputs.token }}

7 changes: 7 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,15 @@ jobs:
shellcheck --shell=bash --severity=error scripts/check-tool-pins.sh
dash -n scripts/install.sh
bash -n scripts/tests/install-verify.sh
shellcheck --shell=bash --severity=error scripts/tests/install-ps1-verify.sh
bash -n scripts/tests/install-ps1-verify.sh
- name: Verification harness (mandatory cosign / fail-closed)
run: bash scripts/tests/install-verify.sh
# Same property on Windows (backend#2078). pwsh is preinstalled on the
# ubuntu runner image; the harness FAILS rather than skips if it isn't,
# since "cannot tell" is not evidence that verification is mandatory.
- name: Verification harness — Windows (mandatory cosign / fail-closed)
run: bash scripts/tests/install-ps1-verify.sh

test:
timeout-minutes: 15
Expand Down
17 changes: 15 additions & 2 deletions cmd/tracebloc/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"os"
"os/signal"
"syscall"
"time"

"github.com/tracebloc/cli/internal/cli"
)
Expand Down Expand Up @@ -59,11 +60,23 @@ func main() {
syscall.SIGINT, syscall.SIGTERM)
defer stop()

executed, err := cli.NewRootCmd(cli.BuildInfo{
info := cli.BuildInfo{
Version: version,
GitSHA: gitSHA,
BuildDate: buildDate,
}).ExecuteContextC(ctx)
}
root := cli.NewRootCmd(info)

started := time.Now()
executed, err := root.ExecuteContextC(ctx)

// backend#1907: one command-outcome event per invocation, emitted from the
// single point every command path converges on — command name, duration,
// exit code, OS/arch, version, error class. No arguments, no paths (see
// internal/cli/telemetry.go for why that is structural rather than a rule).
// Opt-out via TRACEBLOC_NO_TELEMETRY / DO_NOT_TRACK; best-effort and silent,
// so nothing here can change what the customer sees or what we exit with.
cli.RecordCommandOutcome(root, executed, info, cli.ExitCodeFromError(err), time.Since(started))

// F1: after the command runs, a quiet once-a-day nudge if a newer release
// exists (best-effort; silent on dev builds, off a terminal, in CI, or with
Expand Down
28 changes: 28 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,34 @@ produces that code.
| `9` | The ingestion Job exited non-zero, completed with row-level failures the summary panel reports, or its outcome couldn't be determined / followed | `data ingest` | `exitIngestFailed` |
| `130` | You hit Ctrl-C while something was already running — the sign-in wait, `client status --wait`, the seal check, or an installer re-run (128+SIGINT). Ctrl-C at a *question* is `0` instead: nothing had started | `login`, `client status --wait`, `client status --seal`, `upgrade`, `prepare-host` | `exitInterrupted` |

## Usage reporting

The CLI records one outcome event per command so we can see failures like the
ones on this page without waiting for someone to report them. It is on by
default and it is a fixed, closed set of fields — there is no free-text field
in the record at all:

| Field | Example | Where it comes from |
|---|---|---|
| command | `data ingest` | the command you ran, looked up in the CLI's own command list. A value that isn't one of those commands is reported as `unregistered` |
| exit code | `4` | the table above |
| error class | `no_secure_environment` | derived from that exit code, nothing else |
| duration | `1520` ms | wall clock |
| OS / architecture | `darwin` / `arm64` | compiled into the binary |
| version | `0.10.9` | the release you're running |

**What is never sent:** your arguments, any file or directory path, any dataset
or file contents, your username, your hostname, your kubeconfig, your tokens.
Not "filtered out" — the record has nowhere to put them. Each run gets a fresh
random id, so runs are not linked to each other or to you.

Turn it off with either of:

```bash
export TRACEBLOC_NO_TELEMETRY=1
export DO_NOT_TRACK=1
```

## Still stuck?

Open an issue at [github.com/tracebloc/cli/issues](https://github.com/tracebloc/cli/issues)
Expand Down
193 changes: 193 additions & 0 deletions internal/cli/telemetry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package cli

// Command-outcome telemetry wiring — backend#1907.
//
// One event per invocation, emitted from the single place every command path
// converges on (main.go, after ExecuteContextC returns). Hooking each handler
// instead would mean N call sites that each have to remember, and §6.5's
// "terminal event on every path" would then be true only for the handlers
// somebody remembered.
//
// WHERE THIS STOPS TODAY. The transport is a seam. RFC-BACKEND-1872's Collector
// gateway was replaced on 17 Aug by an ingest endpoint on the backend
// (rfcs#28), which is backend#1905 and does not exist yet — so pendingSink
// returns nil and every event is validated and dropped. That is deliberate:
// validation runs on every build regardless, so a malformed event fails in CI
// wherever the binary was built, and connecting #1905 is one function.

import (
"crypto/rand"
"encoding/hex"
"os"
"strings"
"time"

"github.com/spf13/cobra"

"github.com/tracebloc/cli/internal/api"
"github.com/tracebloc/cli/internal/config"
"github.com/tracebloc/cli/internal/telemetry"
)

// telemetryOptOutVars disable emission when set. Opt-OUT, per the ticket:
// telemetry that only the already-convinced enable measures the wrong
// population, and the population this exists for is people whose install just
// failed. DO_NOT_TRACK is the cross-vendor spelling; supporting it means a user
// who has already expressed the preference once does not have to learn ours.
var telemetryOptOutVars = []string{"TRACEBLOC_NO_TELEMETRY", "DO_NOT_TRACK"}

// telemetryEnabled reports whether this invocation may emit.
//
// Anything other than the explicit "off" spellings counts as opting out. The
// asymmetry is on purpose: a user who typed TRACEBLOC_NO_TELEMETRY=please
// meant it, and guessing wrong in the other direction sends a record they
// declined.
func telemetryEnabled(getenv func(string) string) bool {
for _, name := range telemetryOptOutVars {
switch strings.ToLower(strings.TrimSpace(getenv(name))) {
case "", "0", "false":
continue
default:
return false
}
}
return true
}

// commandPaths enumerates every path the tree can dispatch, DERIVED from the
// live tree rather than listed here. That is what makes the closed set in
// telemetry.NewOutcomeRecorder maintain itself: a command added to NewRootCmd is
// reportable the day it lands, and a value that is not a command in the tree can
// never be emitted — including one assembled out of user input.
func commandPaths(root *cobra.Command) []string {
var out []string
var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
out = append(out, commandPathOf(c))
for _, sub := range c.Commands() {
walk(sub)
}
}
walk(root)
return out
}

// commandPathOf renders one command as the contract's tracebloc.cli.command
// value: the invocation minus the binary name, "data ingest" (§7.1). The bare
// root reports its own name rather than an empty string, which normalise would
// drop as absent — leaving the one invocation shape a first-time user is most
// likely to produce as the only one with no command on the record.
func commandPathOf(c *cobra.Command) string {
if c == nil {
return ""
}
path := strings.TrimSpace(c.CommandPath())
root := c.Root().Name()
if path == root || path == "" {
return root
}
return strings.TrimSpace(strings.TrimPrefix(path, root))
}

// telemetryEnv picks deployment.environment for the records.
//
// It labels each record with the backend the client is ACTUALLY talking to,
// resolved exactly the way api.BaseURL resolves it — because that is the host
// these records are about. The mapping mirrors BaseURL: a known env is itself; a
// present-but-unrecognised value is prod, because api.BaseURL routes every
// unknown value to https://api.tracebloc.io (sessionEnv hands cfg.CurrentEnv to
// api.New verbatim). So prod is the accurate label for that population, not a
// guess — and NOT withheld: a misconfigured install that hits prod and fails is
// exactly the run this feature exists to see.
//
// $CLIENT_ENV is consulted only when there is no signed-in env, matching
// sessionEnv: once cfg.CurrentEnv is set the client ignores $CLIENT_ENV, so
// resolving a signed-in unknown through $CLIENT_ENV would label the record for a
// backend the client never contacts (the bug this replaces).
//
// NOTE: that api.BaseURL silently routes an unknown env to prod — so an install
// believing it is on another backend sends its token there — is a real defect,
// but in client.go, not here; tracked separately. This function must match that
// behaviour until it changes, not diverge from it.
func telemetryEnv(env string) string {
resolved := env
if resolved == "" {
// Not signed in: $CLIENT_ENV, then the prod default (as sessionEnv does).
resolved = api.ResolveEnv("")
}
if api.IsKnownEnv(resolved) {
return strings.ToLower(resolved)
}
// Unrecognised: api.BaseURL sends it to prod, so prod is where these records
// belong.
return api.EnvProd
}
Comment thread
cursor[bot] marked this conversation as resolved.

// signedInEnv reads the environment the config points at, best-effort. A
// missing or unreadable config is simply "not signed in".
func signedInEnv() string {
cfg, err := config.Load()
if err != nil || cfg == nil {
return ""
}
return cfg.CurrentEnv
}

// processInstanceID is the per-PROCESS id §2 asks for off-cluster.
//
// Not the hostname, and not a persisted machine id. Hostnames in this product's
// field data are overwhelmingly "<firstname>-macbook", which §7.3 forbids
// outright; a persisted id would be a durable identifier we would then have to
// answer erasure requests about. A fresh random value per run still separates
// concurrent runs, which is all service.instance.id is for here.
func processInstanceID() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
// Omitted rather than faked. New() drops an empty instance id, and a
// constant stand-in would silently fuse every affected run into one.
return ""
}
return hex.EncodeToString(b)
}

// pendingSink is the transport seam for backend#1905.
//
// nil means validate-and-drop (telemetry.SetSink's documented contract). When
// the ingest endpoint lands this returns the client that posts to it, and
// nothing else in this file changes.
func pendingSink() telemetry.Sink { return nil }

// RecordCommandOutcome emits the single terminal event for this invocation.
// main.go calls it once, after the command tree has returned and before exit.
//
// It never returns an error and never panics: a CLI that died because telemetry
// was unhappy would be a strictly worse CLI. A malformed event is caught by the
// tests below, where it is free.
func RecordCommandOutcome(root, executed *cobra.Command, info BuildInfo, exitCode int, elapsed time.Duration) {
_ = recordCommandOutcome(root, executed, info, exitCode, elapsed, os.Getenv, pendingSink())
}

// recordCommandOutcome is RecordCommandOutcome with its two ambient
// dependencies passed in, so the tests drive the real thing.
func recordCommandOutcome(
root, executed *cobra.Command,
info BuildInfo,
exitCode int,
elapsed time.Duration,
getenv func(string) string,
sink telemetry.Sink,
) error {
if !telemetryEnabled(getenv) {
return nil
}
emitter := telemetry.New(telemetryEnv(signedInEnv()), info.Version, processInstanceID())
if sink != nil {
emitter.SetSink(sink)
}
recorder := telemetry.NewOutcomeRecorder(emitter, commandPaths(root))
return recorder.Record(telemetry.Outcome{
Command: commandPathOf(executed),
ExitCode: exitCode,
Elapsed: elapsed,
})
}
Loading
Loading