Skip to content

feat(cli): report the installed binary, target versions, platform and package manager in usage metrics - #412

Merged
so0k merged 12 commits into
tele/s3-sentry-usage-metricsfrom
tele/s4-binary-target-attrs
Oct 12, 2026
Merged

so0k merged 12 commits into
tele/s3-sentry-usage-metricsfrom
tele/s4-binary-target-attrs

Conversation

@so0k

@so0k so0k commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Every usage metric now carries the environment it ran in: the installed Terraform or OpenTofu binary and version, the target versions the project declares, the OS and architecture, and the project's package manager. This answers which binaries, platforms and package managers people actually run. Part of #48.

What changes

  • The base attribute set of every metric grows by the attributes below (commons/src/telemetry.ts). No new metrics are added.
  • commons/src/terraform.ts probes the binary with <binary> version, lazily, at most once per process, shared across bundle copies via process state. The probe is killed after 1500 ms. A failed or timed-out probe reports binary: unknown; a binary that cannot be spawned reports missing. This replaces the terraform version -json call that ran at module import in every commons copy regardless of consent; with usage telemetry off, metrics trigger no probe.
  • cdktn debug uses the same probe and prints "terraform": null when it fails.
  • cli-core captures the project's target attributes once at init from the project that is running, so later metrics in the run carry the same values.
  • Values are validated before they become attributes: versions are reduced to MAJOR.MINOR.PATCH and sent only for a recognised product; ranges are canonical semver or the literal invalid. No path, binary location or free text is forwarded.
  • The package manager is detected once at command start in the project the run started in, and is omitted when it cannot be determined. Only members of a closed set are sent.
Attribute Value Source
os, arch process.platform, process.arch Node
binary terraform, opentofu, unknown, missing version probe
binary_version MAJOR.MINOR.PATCH, only for terraform or opentofu version probe
target_terraform, target_opentofu canonical semver range, else invalid targetVersions in cdktf.json, else the CLI defaults
targets_declared boolean cdktf.json has a targetVersions block
validate_installed_binary boolean validateInstalledBinary === true in cdktf.json
package_manager npm, pnpm, yarn, yarn-berry (TypeScript); pip, pipenv (Python); maven, gradle (Java); nuget; go TypeScript: detectNodePackageManager from #422. Python: pipenv if the app command uses it, else pip. Java: build.gradle present, else maven. C# and Go: constant

All are added to every metric that carries the base attributes (command, ci, language, from #411), except cli.error, which keeps only type and command.

Not in this PR

  • A shell wrapper named terraform that runs the real binary as a child is killed only at the wrapper, so the probe can wait for the child to close stdout. Shims that exec or symlink (tfenv, asdf, mise) are unaffected. Fixing it means changing exec in commons/src/util.ts.

Testing

  • Unit: the sorted attribute key set of cli.command.invoked is pinned, so an extra attribute fails; target attributes captured at init appear on later metrics.
  • Unit with fixture binaries: the probe runs once, a hanging binary resolves unknown at the timeout, a missing binary resolves missing, an unrecognised product sends no version.
  • Table-driven validation of versions and ranges, including prerelease and build text.
  • Package manager: each detected value appears on invoked and on the terminal metric; it is absent outside a project, and a detector result outside the closed set is dropped.

Stack: 4 of 6 — #409 → #410 → #411 → #412 → #413 → #414

@so0k
so0k added this pull request to stack #415 September 9, 2026 18:44
@so0k so0k changed the title tele/s4 binary target attrs feat(cli): report the installed binary, target versions and platform in usage metrics Sep 9, 2026
@so0k
so0k marked this pull request as ready for review September 9, 2026 18:45
@so0k
so0k requested a review from a team as a code owner September 9, 2026 18:45

@jsteinich jsteinich left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed as part of the full tele/s1–tele/s6 stack. Breaking the errors.ts → terraform.ts cycle so telemetry.ts can be imported from errors.ts is a nice bit of untangling, and the process-global Symbol.for probe key is the right answer to the "bundle carries several copies of this module" problem.

One real issue and one smaller one.

The 1500 ms bound doesn't bound what the docstring says it does

telemetry.ts:149:

 * Binary attributes from the version probe, bounded so a hung binary never
 * delays the command; a timed-out probe reports `binary: "unknown"`.

The Promise.race bounds the attribute, not the process. exec in commons/src/util.ts uses spawn with no unref() and no kill-on-timeout, so a hung terraform version leaves a live child handle on the event loop: the race resolves at 1500 ms, the metric gets binary: "unknown", and then the CLI sits there until the child exits. beforeExit won't fire while that handle is active, so createBeforeExitFlush doesn't run either — the command hangs at exit rather than degrading gracefully.

Either kill the child when the timer fires, or unref() the probe's child so it can't hold the loop open. Worth fixing because "the version probe hangs" is exactly the scenario the timeout was written for.

The probe now runs on commands that never needed the binary

Related but separate: getBinaryAttributes() is awaited unconditionally from sendTelemetry (telemetry.ts:229), so init, get, convert and synth now spawn terraform version purely to populate a metric attribute. It's memoised per process via the Symbol.for key so it's one spawn, not many, and it's correctly behind the isUsageTelemetryEnabled() early-return — but it's still a new subprocess on commands that previously touched no binary at all, and on the failure path in reportFailure it can add up to 1500 ms before the process exits.

Not necessarily wrong, but worth a deliberate decision: is binary / binary_version valuable enough on an init or convert metric to justify spawning for it? Restricting the probe to the commands that already shell out would remove both this and most of the exposure above.

Minor: fabricated error prose as a success value

terraform.ts:71-77:

.catch(
  (err) =>
    `Error: Usage Error: Unknown: Error loading terraform version ${err}`,
);

Resolving to a string that impersonates the old Errors.Usage output is a smell — the "Error: Usage Error: " prefix now exists only to make cdktn debug print something that looks like it used to. Since the only consumer is debug.ts:407 (which already does ?? null), returning undefined and letting debug.ts render the failure message would be clearer and wouldn't leave a fake error string floating around for someone to pattern-match on later.

(The Promise → function change itself is fine — all in-repo consumers are updated.)

@so0k
so0k force-pushed the tele/s4-binary-target-attrs branch 2 times, most recently from 86cb1d4 to 7adca39 Compare September 10, 2026 11:37

@jsteinich jsteinich left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 7adca3969. One small new point, from the processState helper that landed in #411 below this layer.

terraform.ts:35 still hand-rolls the same globalThis pattern the helper now exists for:

const PROBE_KEY = Symbol.for("cdktn.terraformCli");

errors.ts was converted to processState("cdktn.errorScope", ...) in the same round, so this is the last remaining copy. Worth folding in for consistency while the layer is still open — it also means the "one spawn per process across bundle copies" guarantee is expressed the same way as the other two pieces of cross-copy state, rather than looking like an unrelated mechanism.

@so0k
so0k force-pushed the tele/s4-binary-target-attrs branch from 7adca39 to de26232 Compare September 22, 2026 16:03
@so0k

so0k commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor Author

@jsteinich on "the probe now runs on commands that never needed the binary":

The probe isn't new work for those commands. Before this layer, @cdktn/commons/src/terraform.ts ran terraform version -json at import, once per commons copy. That's 3× on convert, debug, synth, list and provider list, and 1× on --version, with or without consent. terraformCheck() added terraform -v on 11 commands. With this layer, consent off runs zero probes (only terraformCheck's -v), and consent on runs one, shared by every commons copy. convert goes from 3 to 0. The hang case also improves: no orphaned processes once the probe is killed at 1500 ms, and debug is bounded where it previously waited indefinitely.

full probe results on binary runs before and after this PR (from agent session):
image

@jsteinich jsteinich left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — you're right about the spawn counts and I'll withdraw that half of the finding. I'd missed that the old terraform.ts exported terraformVersion as a module-scope exec(...), so it ran at import rather than on use. Per-copy at import is strictly worse than once-per-process on demand, and gating it behind consent means consent-off now runs zero. That's a net improvement, not a regression.

Two corrections to the record, both small.

There is no kill. grep -nE "\.kill\(|unref\(|AbortController" over packages/@cdktn/commons/src returns nothing, and getBinaryAttributes' finally block is clearTimeout(timer) — which clears the timer, not the child. Promise.race resolves the attribute at 1500 ms; the spawn from exec is untouched and still holds a handle.

My "hangs at exit" framing is superseded — by #410, not by this layer. A hung probe no longer delays the CLI because runCli now ends the success path with an explicit process.exit(process.exitCode ?? 0) after the flush and drainStdio, and process.exit does not wait on live handles. So the docstring's "bounded so a hung binary never delays the command" is true today, but for a reason that lives in another PR rather than anything in this function.

What's left is much milder than I first said: a genuinely hung binary leaves one orphaned child after the CLI exits, reparented and harmless in the normal case. Not worth blocking on. Either add the .kill() the docstring implies, or reword it to say the attribute is bounded and the process exit is what actually caps the wait — I'd slightly prefer the latter, since it keeps the honest version of the guarantee next to the code that relies on it.

(The PROBE_KEY point from my other comment still stands independently — errors.ts moved to processState, terraform.ts:35 is the last hand-rolled copy.)

@so0k
so0k force-pushed the tele/s4-binary-target-attrs branch from de26232 to d8fe558 Compare September 23, 2026 07:06
@so0k

so0k commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the re-review, and for the two corrections — both are right, and I went with the kill rather than the reworded docstring.

The bound now reaches the child — fix(commons): bound the terraform version probe with exec's timeout. versionOutput() passes { timeout: PROBE_TIMEOUT_MS } (1500, named once) to exec, which forwards it to spawn, so the child is killed with SIGTERM and the probe settles on its own. The Promise.race, its timer and the timeoutMs parameter are gone from getBinaryAttributes, and its docstring is now just "Binary attributes from the version probe" — no guarantee stated where it isn't implemented. terraformCli() reports missing only for a spawn error with code === "ENOENT"; a timed-out or otherwise failed version reports unknown.

Measured against real fixtures: a binary that hangs resolves { name: "unknown" } after ~1505 ms with no child left behind (pgrep clean), and an empty PATH resolves { name: "missing" } in 2 ms.

And you're right that #410's explicit process.exit is what actually caps the wait today — with the kill in place, the orphan goes away too, so neither the docstring nor the behaviour leans on the other PR any more.

One known gap, small enough that I left it: if terraform on PATH is a shell wrapper that runs the real binary as a child rather than execing it, the kill only reaches the wrapper; its child keeps stdout open and exec() settles on close, so the probe still waits for it (measured ~11 s). Closing that means changing exec in util.ts to settle on exit or to kill the process group, which is wider than this layer. tfenv, asdf and mise shims all exec or symlink, so they aren't affected.

Fabricated error string — fix(commons): report no terraform version in debug when the probe fails. terraformVersion() resolves to undefined on failure, debug.ts keeps its ?? null, and cdktn debug prints "terraform": null.

PROBE_KEY — refactor(commons): keep the terraform probe in process state. The probe now uses processState("cdktn.terraformCli", ...), like cdktn.errorScope, so the hand-rolled symbol is gone. Same key, so it's still one spawn per process across the bundle copies, and seedTerraformCliProbeForTests seeds and clears through the same object.

Also in this push: the consent prompt now lists what this layer adds — "(command, language, timing, Terraform/OpenTofu binary and version, target versions, OS and architecture)".

@so0k
so0k requested a review from jsteinich September 23, 2026 07:07
@so0k
so0k force-pushed the tele/s4-binary-target-attrs branch from d8fe558 to a154316 Compare October 11, 2026 19:54
@so0k
so0k force-pushed the tele/s4-binary-target-attrs branch from a154316 to a6be446 Compare October 12, 2026 01:14
@so0k so0k changed the title feat(cli): report the installed binary, target versions and platform in usage metrics feat(cli): report the installed binary, target versions, platform and package manager in usage metrics Oct 12, 2026

@jsteinich jsteinich left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads-up on a rebase artifact rather than a code finding: this layer currently reverts #411's consent-prompt fix, and the diff against its stated base renders that revert as if it were intentional.

What the diff shows

packages/cdktn-cli/src/bin/cmds/helper/error-reporting.ts in this branch carries the old 334-character field-list prompt:

Do you want to send anonymous usage telemetry (command, language, timing, Terraform/OpenTofu binary and version, target versions, OS and architecture) to the CDKTN team? …

ad45ff8da ("describe usage telemetry by category in the consent prompt") replaced that on tele/s3-sentry-usage-metrics with the 238-character category + negative-guarantee version. git merge-base --is-ancestor ad45ff8da says:

branch contains the fix
tele/s3-sentry-usage-metrics yes
tele/s4-binary-target-attrs no
tele/s5-stack-metrics no
tele/s6-telemetry-e2e yes

So s4 and s5 predate the fix and still carry the field-list commit that s6's lineage dropped. packages/cdktn-cli/src/bin/cmds/init.ts on s4 is byte-identical to its pre-fix state, which confirms the branch point rather than a deliberate change.

Why it is easy to miss

The chain is no longer linear. Checking each link against its stated base:

main -> s1 : behind (main moved to 81da16486)
s1   -> s2 : intact
s2   -> s3 : broken
s3   -> s4 : broken
s4   -> s5 : intact
s5   -> s6 : broken

merge-base(s3, s4) and merge-base(s5, s6) are both 705e5b939, so those branches were each rebased onto main independently and at different times; each now carries its own copy of the lower layers rather than descending from the branch GitHub names as its base. The bottom (s1→s2) and the top (s6) both look correct in isolation, and only the middle is stale — which is exactly why the commit list reads fine.

It is not only the prompt, either: s3 carries feat(cli): add a typescript-pnpm init template and deprecate Yarn (#422) and s4 does not, which is why dependency-manager.ts, node-package-manager.ts and the typescript templates also differ between the two.

Suggested fix

Rebase s4 onto the current s3, then s5 onto s4, then s6 onto s5, dropping s4's field-list prompt commit on the way (s6's lineage already has). Worth re-running the ancestry check afterwards rather than trusting the PR view — GitHub will show a diff against a base the branch no longer descends from without any indication that it has diverged.

No action needed on the code in this layer itself; the binary/target/platform work is unaffected.

@so0k
so0k force-pushed the tele/s4-binary-target-attrs branch from 9ff6271 to 6753229 Compare October 12, 2026 08:01
@so0k

so0k commented Oct 12, 2026

Copy link
Copy Markdown
Contributor Author

@jsteinich that was a snapshot taken between two pushes of a cascade. Re-checked on the current remote branches: each of the six links contains the branch below it, the field-list prompt commit is gone from this layer, and #422 is in every layer's base. No code change here for it.

Since that review this layer also gained package_manager (closed set, detected once at command start); the description is updated.

so0k and others added 12 commits October 12, 2026 16:36
Replace the hand-rolled globalThis symbol handling with the shared
processState helper, keeping the "cdktn.terraformCli" key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
terraformVersion() resolved a failed probe to a fabricated
"Error: Usage Error: Unknown: Error loading terraform version ..." string,
which `cdktn debug` printed as the version. It now resolves to undefined,
so debug prints "terraform": null.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The probe raced a 1500 ms timer in getBinaryAttributes, which left a hung
`terraform version` child running and did not bound `cdktn debug`.
versionOutput() now passes `timeout: 1500` to exec, so spawn kills the
child and the probe settles on its own; the race and its timeoutMs
parameter are gone.

terraformCli() reports `missing` only for a spawn ENOENT; a timed-out or
failed `version` reports `unknown`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@so0k
so0k force-pushed the tele/s4-binary-target-attrs branch from 6753229 to 183b39b Compare October 12, 2026 09:39
@so0k
so0k merged commit bfe26a8 into main Oct 12, 2026
273 of 275 checks passed
@so0k
so0k deleted the tele/s4-binary-target-attrs branch October 12, 2026 14:05
so0k added a commit that referenced this pull request Oct 12, 2026
Upgrades `@sentry/node` from 7.120.4 to 10.73.0 so the usage metrics
added later in the stack can use the v10 metrics API; the 7.x SDK's
custom metrics are no longer accepted by Sentry (sunset 2024-10-07).
Error and crash reporting behave as before, with one privacy
improvement: crash reports no longer carry the machine hostname.

Part of #48.

### What changes

- `@sentry/node` is pinned to exactly `10.73.0` in `@cdktn/commons`,
`@cdktn/cli-core` and `cdktn-cli`, so the bundle keeps one shared SDK
instance. Only `@sentry/*` and its transitive dependencies move in
`pnpm-lock.yaml`.
- `Sentry.configureScope` becomes `Sentry.getCurrentScope()` in
`cli-core/src/lib/error-reporting.ts` and in `Errors.setScope`
(`commons/src/errors.ts`).
- The `Sentry.init` options in `error-reporting.ts` are rebuilt for v10:
the removed `autoSessionTracking` is dropped; `tracesSampleRate: 0`;
`environment: "production"`, so `SENTRY_ENVIRONMENT` is never read;
`serverName: "cdktn-cli"`, so the hostname is never attached;
`enableMetrics: true`, stated explicitly instead of relying on the SDK
default.
- A fresh propagation context is set at init, so an inherited
`SENTRY_TRACE` / `SENTRY_BAGGAGE` is discarded and never propagates.

### Not in this PR

- No usage metrics are emitted yet; they start in #411.
- Opt-in crash reports still carry what they carry on `main` apart from
the hostname: the CLI's install path in stack frames and the SDK's
default device, OS and locale contexts. Scrubbing those is a follow-up.
- `@sentry/node` is a devDependency of `cdktn-cli` (it reaches users
through the bundle and `@cdktn/commons`). Whether that is intended is a
separate question.

### Testing

- Unit: `error-reporting.test.ts` asserts the exact init options
(release, `tracesSampleRate`, `environment`, `serverName`,
`enableMetrics`) and that a fresh trace is seeded.
- Unit: the existing crash-reporting tests (`shouldReportCrash`,
`captureException`) pass unchanged.
- Delivery of metrics through the real SDK is proven by the bundle e2e
in #414, not here.

Stack: 1 of 6 — **#409** → #410 → #411 → #412 → #413 → #414
so0k added a commit that referenced this pull request Oct 12, 2026
Fixes the top-level CLI error handler printing every failure twice and
aborting with `ERR_UNHANDLED_REJECTION`, and makes the process flush and
exit explicitly. The handler registered `.fail()` as `async`, which
yargs does not await, so the rejection escaped to Node.

Fixes #360, fixes #361.

### What changes

- `.fail()` is synchronous and only records the failure. `runCli`
(`cdktn-cli/src/bin/error-handling.ts`) awaits `parseAsync()` with
`.exitProcess(false)`, reports through one awaited `reportFailure`, and
makes the single `process.exit()` call. It also catches synchronous
handler throws, which yargs never routes to `.fail()`.
- `terraform-cli.ts` throws an `Error` instead of a raw string, which is
what printed `undefined` / `undefined`.
- More crash events than before: `reportFailure` captures `External` and
unexpected errors explicitly (never `Usage`). It stays consent-gated,
since Sentry is only initialised after consent.
- The success path (including `--help` and `--version`) awaits a bounded
Sentry flush (4000 ms), drains stdout and stderr, then exits with
`process.exitCode ?? 0`. An ingest endpoint that never answers cannot
hold the process open, and `cdktn convert | less` is no longer truncated
at 64 KiB. A reader that closes early (`| head -1`) exits 0.
- A shell completion request returns early and lets the loop drain,
because yargs does not await its asynchronous completion callback.
- `watch()` in cli-core stays pending until its abort signal fires, so
the explicit exit does not end a watch session after its first run. A
deploy that fails for any reason other than the abort now rejects the
session, so `cdktn watch` ends through the normal error path with exit
1. Before, that failure was an unhandled rejection: a raw stack without
Sentry, a silently stuck watcher with it. Runs start in one place and
one promise covers a run and the re-runs queued behind it.
- `CdktfProject.execute()` observes every stack promise it starts. Two
early exits (the wait at the parallelism limit, and the catch around
picking the next stack) skipped the final await, so a failed stack in a
multi-stack deploy or destroy also surfaced as an unhandled rejection
next to the real error. The error still propagates the same way.

### Not in this PR

- When a stack fails at the parallelism limit, `execute()` rejects
without waiting for the other stacks that are still running, unlike its
other exits. This predates the change and is left as it is.
- `cmds/get.ts` calls `readConfigSync()` at import, so an invalid
`cdktf.json` still crashes before the failure reporter runs. This
predates the change.
- Commands that call `process.exit()` directly bypass the flush; nothing
emits metrics on those paths yet, and #411 flushes before each one that
does.

### Testing

- Unit: `runCli` is the only export and is driven directly, with console
and `process.exit` spies: one failure matrix (async and sync throws, raw
strings, error-shaped objects, Usage, External, invalid choice) asserts
the exact output, what is captured, exit 1, and that a rejected argument
never runs the handler.
- Multi-stack `deploy` and `destroy` through real stack executors: first
stack fails under `parallelism: 1`, a failing stack with a dependant
that never starts, and one failing while another runs; each rejects with
the original error and leaves no unhandled rejection.
- `watch`: pending until aborted; a failed deploy rejects with the same
error, closes the watcher and stops; a change during a deploy yields
exactly one follow-up run; a failing queued re-run rejects; a change
after the session ended starts nothing.
- Child-process integration: a real Sentry client against a local sink
delivers the crash envelope before exit, including for an `External`
error; a sink that accepts and never answers still lets the success path
exit 0 within the bound.
- The #360 / #361 triggers print once, reach `Debug Information:`, and
print a real message with no unhandled-rejection output.

Stack: 2 of 6 — #409 → **#410** → #411 → #412 → #413 → #414
so0k added a commit that referenced this pull request Oct 12, 2026
…etrics and consent (#411)

The CLI stops sending anything to `checkpoint-api.hashicorp.com`. Usage
analytics now go to the project's own Sentry as counted metrics, behind
a separate `sendUsageTelemetry` consent flag in `cdktf.json`. Resolves
#48.

### What changes

- The HashiCorp transport (`commons/src/checkpoint.ts`) and its test are
deleted, and `nock` leaves `cli-core`. The user and project ids move to
`commons/src/identity.ts`. `commons/src/telemetry.ts` holds the whole
contract: the consent gate, the metrics, and the single bounded
`flushTelemetry`.
- Consent: `initializErrorReporting` prompts once per unset flag (stdin
and stdout are TTYs, not CI) and saves the answer to `cdktf.json`;
`convert` asks before it chdirs into its temporary project. The first
decision captured in a process wins: a nested command (`convert` →
`init` → `get`, `init` → `get`, `provider add` → `get`) returns from
reporting init without reading flags, prompting or building a second
client. `parseConfig`, the usage reader and the crash reader share one
tri-state reader: booleans and `"true"`/`"false"` count, anything else
reads as unset. Usage telemetry follows `CHECKPOINT_DISABLE` > decision
at command start > `sendUsageTelemetry` > on; with only usage consent no
error events are sent.
- Run model: a run counts once as `cli.command.invoked` at start, then
once as `cli.command.completed` or `cli.command.error`, so `error /
invoked` is a failure rate. A nested operation (a deploy's synth, init's
get) adds only its own metrics; `init` completes after its nested get
succeeds.
- Failure paths that used to `process.exit(1)` (synth code-maker guard,
`watch --auto-approve`, Terraform availability check) throw
`Errors.Usage`, reaching the single failure path. The two self-exiting
synth paths count and flush themselves.
- `cli.error` counts every error built by the `Errors` factories by type
and command (never message or context); `cli.command.error` counts
failed runs. A handled Usage error counts only as `cli.error`.
- Command scope, consent and run state live in a `Symbol.for` store
(`process-state.ts`), because the esbuild bundles each carry their own
copy of commons.

| Metric | When emitted | Attributes (base: `command`, `ci`, `language`)
|
|---|---|---|
| `cli.command.invoked` | once per run, at start | base only |
| `cli.command.completed` | once per successful run | base only |
| `cli.command.error` | once per failed run | base + `error_type`
(`Usage`, `External`, `Internal`, `unexpected`) |
| `cli.synth.duration` | each synth, ms distribution | base only |
| `cli.error` | each error from the `Errors` factories | `type`,
`command` only |

Sentry itself adds the random install id (`~/.cdktf/config.json`),
release `cdktn-cli-<version>`, environment `production`, and server
`cdktn-cli`. Never sent: stack names, ids, paths, hostname or username,
error messages and context, `SENTRY_*` values.

### Migration notes

- cdktn sends no data to HashiCorp. The new `sendUsageTelemetry` flag is
independent of `sendCrashReports`; the six templates carry it.
- Interactive runs ask once about each unset flag. The crash-reporting
prompt also appears now where `sendCrashReports` is missing; it never
fired before because a missing key was read as "no".
- `provider list` and `provider upgrade` now initialise reporting like
the other commands, so their first interactive run in a project with
unset flags shows the prompts before the output.
- CI and piped runs never prompt: usage telemetry stays on by default
(now to Sentry), crash reporting stays off. Builds without a Sentry DSN
send nothing.

### Coverage

- Counted: `convert`, `deploy`, `destroy`, `diff`, `get`, `init`,
`list`, `output`, `synth`, `watch`, `provider add`, `provider list`,
`provider upgrade`. Not counted: `login` (handles a credential), `debug`
(must work when the environment is broken, without consent or reporting
init), shell completion, `--help`, `--version` and unknown commands (no
handler runs).
- A package-manager attribute is a follow-up after #422.

### Testing

- Unit: a real v10 client with a capturing transport asserts the metric
names, attributes and the exact multiset of run metrics; raw-envelope
scans assert no host identity; the consent matrix and the config
normalisation tables.
- Handler level: `convert` prompts and persists before its chdir, and
never prompts on piped stdin; each former `process.exit` guard yields
one `cli.command.error` and one flush.
- Bundle-level delivery is covered by the e2e in #414.

Stack: 3 of 6 — #409 → #410 → **#411** → #412 → #413 → #414

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
so0k added a commit that referenced this pull request Oct 12, 2026
#413)

Restores the per-stack signal the HashiCorp transport used to carry
(backend, providers, overrides) as counted Sentry metrics, and reports
what `get` and `init` generate. Volume is bounded per run: metrics are
aggregated into counters with caps, never one event per stack or
provider. Part of #48.

### What changes

- `sendStackTelemetry` reduces each stack's metadata block and
`required_providers` to enumerated values and counts; stack names,
resource ids and addresses never enter a metric. Providers are
public-registry `namespace/type`; other hosts become `private-registry`,
malformed input `other`. Modules reduce to `local`, `git`,
`private-registry`, `other` or a public registry address.
- Aggregation: one event per distinct identity, value = stacks sharing
it, count attributes summed over the group. Caps: 8 stack groups, 32
provider tuples, 32 override types, 32 get/init sources. The most
counted are kept; the rest fold into one event per metric with
`truncated: true`, no identity attributes and the folded sum as value,
so totals hold. A run sends at most 75 stack events.
- One stack block per process: a synth nested in another command sends
only `cli.synth.duration`; `deploy`/`destroy` report the stacks they
selected, `diff` the one diffed stack. So `watch` reports its first
deploy only, `output` and `list` send no stack block, counts are
project-level (stacks using a provider), and stacks cannot be
correlated.
- `diff`, `deploy` and `destroy` send the stack metrics whether the
Terraform operation resolves or rejects, then rethrow the original
error; a failed send never replaces it. That includes a failed Terraform
init. The failure itself is counted once as `cli.command.error`, and a
run that failed is never counted as `cli.command.completed`, even with
zero errored stacks.
- Per-command scalars ride on `cli.command.completed` through a typed
allow-list; nothing else is forwarded. `provider`/`binding` are
classified against `terraformProviders` read at command start.

| Metric | When emitted | Attributes (plus base) |
|---|---|---|
| `cli.stack` | per distinct group of synth/diff/deploy/destroy |
`backend` (enumerated, `other`, `unknown`), `cloud`, `library_version`,
`override_count`, `import_count`, `moved_count` (sums), `truncated` |
| `cli.stack.provider` | per distinct provider tuple; value = stacks
requiring it | `provider`, `version_constraint` (canonical or
`invalid`), `binding` (`generated`/`prebuilt`), `truncated` |
| `cli.stack.override` | per distinct resource type; value = stacks
overriding it | `resource_type` (grammar, 64-char cap, `module.<kind>`,
else `other`), `override_count` (total paths), `truncated` |
| `cli.stack.failed` | when at least one stack errored; value = stacks
whose Terraform operation errored. Stacks that never started are not
counted, and a failed init counts none | base only |
| `cli.get.provider`, `cli.get.module` | per distinct generated binding
source; value = entries | `provider` / `module`, `truncated` |
| `cli.init.provider` | per distinct provider passed to init |
`provider`, `truncated` |
| scalars on `cli.command.completed` | per run | synth `synth_origin`
(`watch`); init `template` (built-in name or `remote`), `is_remote`,
`provider_count`; convert `module_count`, `provider_count`,
`converted_lines`; get `provider_count`, `module_count` |

### Testing

- Unit: normaliser tables (provider and module sources, backends,
constraints, resource types, identity length caps); aggregation values,
tie order, residual `truncated` events with conserved totals, and
tolerance of malformed entries.
- Unit with a real v10 client: the delivered stack, get and init metrics
carry only the documented attributes; raw-envelope `LEAK-*` markers
prove no name, host or path is sent.
- Failed runs are driven through real stack executors against a
Terraform stub that rejects, not hand-set state: a rejected plan, apply
or destroy yields the stack metrics and `cli.stack.failed`, then the
original error and no `cli.command.completed`; a rejected init (diff,
deploy, destroy) yields the stack metrics with no failed count.
- One stack errors while another never starts (`parallelism: 1`, and a
failing stack with a dependant; deploy and destroy): `cli.stack` 2,
`cli.stack.failed` 1, no `cli.command.completed`, the original error
propagates.
- The bundle-level aggregation check is the e2e in #414.

Stack: 5 of 6 — #409 → #410 → #411 → #412 → **#413** → #414
so0k added a commit that referenced this pull request Oct 12, 2026
A bundled CLI now loads the handlers that sit next to its own entrypoint
instead of always falling back to the shipped `bundle/` at the package
root. The telemetry delivery e2e, which needs this, now runs in CI as
its own job. Part of #48.

### What changes

- `requireHandlers` (`cdktn-cli/src/bin/cmds/helper/utilities.ts`)
probes `__dirname/cmds/handlers.js` first. In a bundle `__dirname` is
`<outdir>/bin`, so this is the file the bundle lays down; the old probe,
`__dirname/../handlers.js`, matched no bundle layout. Unbundled code
still falls back to `<package root>/bundle/bin/cmds/handlers.js`.
- `tools/validate-sentry-e2e.sh` builds a scratch bundle in
`packages/cdktn-cli/bundle-e2e` with a local-sink DSN baked in
(`CDKTN_BUNDLE_OUTDIR` in `build.ts`, gitignored), starts
`tools/sentry-sink.mjs` on a free port, and runs `convert`, a failing
synth, two stacks and a crashing command. It polls the sink and fails
naming the metric that never arrived.
- It asserts the delivered run accounting (one `invoked`, then one
`completed` or `error` per run), the per-run stack aggregation, the
crash path, and the identity on the wire (install id, `cdktn-cli`,
`production`). The hostname, username, working directories, stack names,
provider hosts and `SENTRY_*` values must appear nowhere in what the
sink received, and the bundle must not reference
`checkpoint-api.hashicorp.com`.
- `.github/workflows/telemetry-e2e.yml` runs it as its own job,
`Telemetry E2E`, called from `pr.yml` after `quality` and `build`. It
runs `nx build cdktn-cli`, needs no `SENTRY_DSN` secret, and is skipped
by `ci/skip-unit`. The job is part of the `PR - Result` gate, and
`build-and-package` is unchanged.

### Not in this PR

- The e2e needs `pnpm build` outputs, not `pnpm package`: `convert
--language typescript` never reads `dist`. The script checks for the
build outputs first and fails with "run pnpm build first".
- The job does not run on `main` or in the release workflow.
- Three envelope parsers (`telemetry.test.ts`, `sentry-sink.mjs`,
`error-handling.integration.test.ts`) remain separate; a shared
test-support helper is a follow-up.

### Testing

- Bundle e2e (`bash tools/validate-sentry-e2e.sh`): the real esbuild
bundle, run as child processes against the local sink.
- Unit and child-process suites from the earlier slices still run in the
existing unit jobs; no new unit test is needed for the handler probe,
which the e2e exercises on every run.
- `TIERS.md` lists the e2e as its own test tier.

Stack: 6 of 6 — #409 → #410 → #411 → #412 → #413 → **#414**
so0k pushed a commit that referenced this pull request Oct 27, 2026
🤖 Release PR — merge to cut a new release. Kept open and rebased
as commits land on `main`.
---


<details><summary>0.25.0</summary>

##
[0.25.0](v0.24.0...v0.25.0)
(2026-10-27)


### ⚠ BREAKING CHANGES

* **lib:** `IAssetPackaging` gains `acceptsDirectorySource` as a
required member. Custom implementations of the interface must add it.
* **lib:** ValidateBinaryVersion and ValidateTerraformVersion are
removed (they were only reachable through deep imports, not the cdktn
package root). Synth no longer executes the terraform/tofu binary for
feature validation; set validateInstalledBinary: true in cdktf.json to
verify the installed binary before commands that run it.
* **lib:** resource moves (moved blocks) are now validated at synth
against the project's declared targetVersions (default terraform
>=1.5.7, opentofu >=1.6.0) instead of the locally installed binary.
Projects that declare terraform targets below 1.5.0 now fail synth;
projects whose installed binary is older than their declared targets
should set validateInstalledBinary: true in cdktf.json.
* **lib:** import blocks (importFrom / ImportableResource) are now
validated at synth against the project's declared targetVersions
(default terraform >=1.5.7, opentofu >=1.6.0) instead of the locally
installed binary. Projects that declare terraform targets below 1.5.0
now fail synth; projects whose installed binary is older than their
declared targets should set validateInstalledBinary: true in cdktf.json.
* **lib:** terraform_data resources are now validated at synth against
the project's declared targetVersions (default terraform >=1.5.7,
opentofu >=1.6.0) instead of the locally installed binary. Projects that
declare terraform targets below 1.4.0 now fail synth; projects whose
installed binary is older than their declared targets should set
* **lib:** cloud backends (the cloud block) are now validated at synth
against the project's declared targetVersions (default terraform
>=1.5.7, opentofu >=1.6.0) instead of the locally installed binary.
Projects that declare terraform targets below 1.1.0 now fail synth;
projects whose installed binary is older than their declared targets
should set validateInstalledBinary: true in cdktf.json.
* **lib:** IAssetPackaging gains a required member
omitsDirectoryEntries; external implementations must add it. Custom
TerraformAsset assetHash values are now validated against
`/^[A-Za-z0-9_.-]+$/` and throw if they contain other characters (e.g.
"feature/my-branch").
* **lib:** add asset pipeline foundation types and hash utilities
([#371](#371))

### Features

* **cli:** add a typescript-pnpm init template and deprecate Yarn
([#422](#422))
([81da164](81da164))
* **cli:** move the TypeScript template to tsx and TypeScript 6
([#396](#396))
([b50349d](b50349d))
* **cli:** point AI coding agents at the CDKTN docs MCP server
([#518](#518))
([f257cd9](f257cd9))
* **cli:** rename `--cdktf-version` to `--cdktn-version` in `init`
([#228](#228))
([dd3818e](dd3818e))
* **cli:** replace HashiCorp checkpoint telemetry with Sentry usage
metrics and consent
([#411](#411))
([e17046a](e17046a))
* **cli:** report aggregated stack, provider and command usage metrics
([#413](#413))
([ed9b1e5](ed9b1e5))
* **cli:** report the installed binary, target versions, platform and
package manager in usage metrics
([#412](#412))
([bfe26a8](bfe26a8))
* **cli:** resolve providers from the registry the project targets
([#445](#445))
([78898ae](78898ae))
* **deps:** adopt jsii 6.0 and TypeScript 6
([#395](#395))
([26fa8fa](26fa8fa))
* **gha:** Revert "run the affected unit tests against OpenTofu
([#439](#439))"
([#446](#446))
([ae015ef](ae015ef))
* **gha:** run the affected unit tests against OpenTofu
([#439](#439))
([823682d](823682d))
* **gha:** run the affected unit tests against OpenTofu
([#447](#447))
([c0c4c1c](c0c4c1c))
* **lib:** add asset bundling extension interface
([#442](#442))
([7839625](7839625))
* **lib:** add asset pipeline foundation types and hash utilities
([#371](#371))
([a008d12](a008d12))
* **lib:** add asset staging core with no bundling
([#433](#433))
([44d8bd1](44d8bd1))
* **lib:** add support for single-file bundler outputs
([#464](#464))
([3592db0](3592db0))
* **lib:** pass ignore strategy through terraform asset
([#472](#472))
([705e5b9](705e5b9))
* **tests:** add per-test runtime pinning to the integration matrix
([#405](#405))
([17c2278](17c2278))


### Bug Fixes

* **cli:** catch failures of the background environment collection
([#531](#531))
([c5feed8](c5feed8))
* **cli:** correct the constructs range comment in the TypeScript
template hook
([#408](#408))
([9fc0e43](9fc0e43))
* **cli:** don't crash after a successful deploy when an output is
missing
([#375](#375))
([0bbd64a](0bbd64a))
* **cli:** fail cleanly when the --outputs-file write fails
([#376](#376))
([8f9d03a](8f9d03a))
* **cli:** make `cdktn get` remove stale files for resources removed
from a provider
([#421](#421))
([d6e28dc](d6e28dc))
* **cli:** match prebuilt providers regardless of the project's registry
([#493](#493))
([ea82124](ea82124))
* **cli:** probe with `version` instead of `-v`
([#471](#471))
([b87ee22](b87ee22))
* **cli:** require patched Node 22.23.4 and bump undici for security
fixes
([#516](#516))
([76436f5](76436f5))
* **cli:** resolve bundled handlers next to the entrypoint
([#414](#414))
([49600c1](49600c1))
* **cli:** resolve upgraded providers from the CLI's own registry
([#489](#489))
([10672ee](10672ee))
* **cli:** stop the top-level error handler racing yargs
([#410](#410))
([5d0c2ef](5d0c2ef))
* **deps:** move jsii-rosetta to 6.0.16 for stream-json 3
([#463](#463))
([f1ec16f](f1ec16f))
* **gha:** authenticate to public.ecr.aws in the docker image build
([#519](#519))
([c65bf2f](c65bf2f))
* **gha:** don't fail the pnpm upgrade jobs on ignored dep builds
([#399](#399))
([5b66342](5b66342))
* **gha:** move the jsii package cache out of the container HOME
([#514](#514))
([df5b490](df5b490))
* **gha:** pass -R to gh pr edit in release_github relabel step
([#372](#372))
([358edc7](358edc7))
* **gha:** push only the prerelease tag from prepare-next
([#476](#476))
([848f2a6](848f2a6))
* **lib:** avoid terraform init plugin-cache race in test matchers
([#467](#467))
([26847f9](26847f9))
* **lib:** retry the staging install without --prefer-offline outside CI
([#407](#407))
([7bb26e7](7bb26e7))
* **provider-generator:** pass the default provider configuration a
module requires on get
([#466](#466))
([a5225ab](a5225ab))
* **provider-generator:** pick the docs registry from targetVersions
([#443](#443))
([00f2884](00f2884))
* **provider-generator:** pick the module docs registry from
targetVersions
([#484](#484))
([8200330](8200330))
* **provider-generator:** regenerate bindings when the target registry
changes
([#490](#490))
([813e022](813e022))
* **provider-generator:** satisfy module provider configuration aliases
on get
([#383](#383))
([54a8217](54a8217))
* **provider-generator:** stop a provider_functions resource clobbering
the functions submodule
([#403](#403))
([cf19de9](cf19de9))
* **tests:** await the pip install in the python provider command tests
([#478](#478))
([1c83a36](1c83a36))
* **tests:** bound and retry the dist publish to the local registry
([#425](#425))
([cc9f9e2](cc9f9e2))
* **tests:** build the edge provider bindings in their own cached Nx
target
([#482](#482))
([f480cc9](f480cc9))
* **tests:** drop unused ts-jest transform from shared jest preset
([#382](#382))
([03f6e16](03f6e16))
* **tests:** mark the terraform-cloud target Terraform-only in the
matrix
([#430](#430))
([b0deefa](b0deefa))
* **tests:** run example builds with the Nx cache skipped
([#477](#477))
([3e25e13](3e25e13))
* **tools:** declare the cdktn-cli bundle as a cached Nx output with its
real inputs
([#485](#485))
([10dd118](10dd118))
* **tools:** make the workspace build, package, lint and test on Windows
([#364](#364))
([8dcddbe](8dcddbe))


### Code Refactoring

* **lib:** Remove the ValidateBinaryVersion synth-time binary probe
([#457](#457))
([e60142e](e60142e))
* **lib:** Use declared targetVersions for synth-time validation of
import blocks
([#454](#454))
([c0097f4](c0097f4))
* **lib:** Use declared targetVersions for synth-time validation of
resource moves
([#453](#453))
([43edc1e](43edc1e))
* **lib:** Use declared targetVersions for synth-time validation of
terraform_data
([#455](#455))
([a1c125b](a1c125b))
* **lib:** Use declared targetVersions for synth-time validation of the
cloud backend
([#456](#456))
([4a83507](4a83507))


### Miscellaneous Chores

* add OpenTofu and current Terraform to the jsii-terraform image
([#394](#394))
([2f29d5a](2f29d5a))
* declare complete Nx build outputs so cache hits restore them
([#474](#474))
([794cf77](794cf77))
* **deps:** move the remaining packages to TypeScript 6
([#406](#406))
([c55d360](c55d360))
* **deps:** move the root TypeScript to 6
([#417](#417))
([3cae1e2](3cae1e2))
* **deps:** Update xstate to v5.32.4
([#333](#333))
([5ebf74f](5ebf74f))
* **deps:** upgrade @sentry/node to 10.x
([#409](#409))
([2714fff](2714fff))
* **deps:** use caret ranges for unbundled runtime dependencies
([#503](#503))
([e722357](e722357))
* **docs:** add the test-audit skill for authoring and auditing tests
([#475](#475))
([909c612](909c612))
* **examples:** move the TypeScript examples to tsx and TypeScript 6
([#419](#419))
([857e182](857e182))
* Flexible python version
([#420](#420))
([955f62a](955f62a))
* **gha:** adopt the new jsii-terraform image and test Terraform 1.5.7 +
1.16.1
([#398](#398))
([3ad760b](3ad760b))
* **gha:** gate dependency upgrades on release age
([#418](#418))
([b611a87](b611a87))
* **gha:** retire Docker Hub publishing of jsii-terraform
([#388](#388))
([e53cddf](e53cddf))
* **gha:** select the CLI by binary name instead of concatenating a
version
([#438](#438))
([914da56](914da56))
* **gha:** skip the remote Nx cache in the release workflow
([#487](#487))
([bce91da](bce91da))
* **lib:** stop publishing build state in the cdktn package
([#397](#397))
([51e4a95](51e4a95))
* npm-check-updates && pnpm update
([#326](#326))
([7095f6b](7095f6b))
* **readme:** point to the cdktn-io/action GitHub Action
([#504](#504))
([e387852](e387852)),
closes [#58](#58)
* **tests:** add timeout handling to spawned commands
([#432](#432))
([ddb4a8a](ddb4a8a))
* **tests:** run the function-binding scripts with tsx
([#431](#431))
([fec67bf](fec67bf))


### Tests

* **lib:** ensure TerraformAsset stage within stack own directory
([#434](#434))
([9a80366](9a80366))
* **lib:** move matcher Docker fixture to a provider version OpenTofu
accepts
([#429](#429))
([7b0da12](7b0da12))
* **tests:** move the python providers fixture to Docker provider 3.9.0
([#440](#440))
([a678473](a678473))
* **tests:** move the remaining kreuzwerker/docker pins off unsigned
versions
([#459](#459))
([d855cc1](d855cc1))
* **tests:** move the testing-matchers fixtures to Docker provider 3.x
([#436](#436))
([8e2c60e](8e2c60e))
* **tests:** re-enable the Python edge integration test
([#404](#404))
([0a80626](0a80626))
</details>

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: open-constructs-cdktn[bot] <291052431+open-constructs-cdktn[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants