Skip to content

feat(cli): report aggregated stack, provider and command usage metrics - #413

Merged
so0k merged 21 commits into
tele/s4-binary-target-attrsfrom
tele/s5-stack-metrics
Oct 12, 2026
Merged

so0k merged 21 commits into
tele/s4-binary-target-attrsfrom
tele/s5-stack-metrics

Conversation

@so0k

@so0k so0k commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

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 fix(cli): resolve bundled handlers next to the entrypoint #414.

Stack: 5 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/s5 stack metrics feat(cli): report per-stack, per-provider and per-command 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. The privacy normalisers are the strongest part of this PR — normalizeProviderSource reducing non-public hosts to private-registry, classifyModuleSource collapsing anything path- or host-shaped, and groupSizes sending counts where the raw structure would carry resource ids. Collapsing four copies of the JSON.parse(stack.content)["//"].metadata incantation into SynthStack.telemetryPayload (with a try/catch, which the originals lacked) is a good catch too.

Four things.

1. cli.command.invoked means two different things depending on the command

finishStackRun (cdktf-project.ts:439) sends telemetry before throwing, so a failed deploy / diff / destroy emits both cli.command.invoked and cli.command.error. A failed synth exits inside synth-stack.ts (:181, :203, introduced in #411) and emits only cli.command.error, never invoked.

So invoked counts attempts for the stack commands and successes for synth. Any success-rate or funnel query across commands silently returns the wrong number, and it's the kind of thing that's very hard to notice once the data is in Sentry.

Worth picking one semantic and stating it in a comment next to the metric. Emitting invoked unconditionally at command start — and letting cli.command.error be the thing that varies — is the version I'd argue for: it makes error / invoked a real success rate, and it stops the meaning of invoked depending on which exit path a command happens to take.

2. Per-run metric volume is unbounded

sendStackTelemetry emits, per stack, one cli.stack + one cli.stack.override per resource type + one cli.stack.provider per provider; sendGetTelemetry emits one per generated binding. A 50-stack project with 10 providers each produces on the order of 550 metric events for a single cdktn deploy, each carrying ~12 attributes after #412's os / arch / binary / target_* additions. There's no cap anywhere.

Every individual attribute is bounded (that's the good part), but the number of emissions scales with project size, and Sentry bills per metric. Worth a sanity check against the plan's quota with a realistically large project before this ships — a cap, or aggregating the per-provider counts into the cli.stack metric rather than emitting one event each, would both bound it.

3. SYNTH_ORIGINS duplicates a type that could just move to commons

telemetry.ts:379:

// The synth origins the CLI passes through; see SynthOrigin in cli-core.
const SYNTH_ORIGINS = ["watch"];

The comment acknowledges the drift risk, on the premise that commons can't import cli-core. But the dependency only needs to go the other way: @cdktn/cli-core already depends on @cdktn/commons, commons doesn't depend on cli-core, and SynthOrigin is not exported from cli-core's public lib/index.ts (only SynthesizedStack is). So it's internal to lib/ and can move for free.

Make commons the source of truth and derive the type from the runtime array, so the allow-list and the type physically cannot drift:

// packages/@cdktn/commons/src/telemetry.ts
export const SYNTH_ORIGINS = ["watch"] as const;
export type SynthOrigin = (typeof SYNTH_ORIGINS)[number];
// packages/@cdktn/cli-core/src/lib/synth-stack.ts
export type { SynthOrigin } from "@cdktn/commons";

That leaves the four existing SynthOrigin references in synth-stack.ts and cdktf-project.ts working unchanged. One adjustment needed: ScalarAttribute.values is string[] at telemetry.ts:387 and won't accept a readonly tuple — widen it to readonly string[].

WATCH_EVENTS is a different case and I'd leave it: watch.ts:184 passes a bare { event: "start" } literal with no named type, so there's nothing being duplicated. If you want the same guarantee there it means introducing a WatchEvent type in commons and typing the call site against it, which is new work rather than de-duplication.

4. getGeneratedProviderSources() defaults to process.cwd()

telemetry.ts:470, inside sendStackTelemetry. Everything else that reads project state is deliberately captured at command start precisely because convert chdirs — usageTelemetryEnabledState and projectTargetAttributes both go through setX/getX pairs for exactly this reason. This one reads the cwd at emission time.

Harmless today (the stack commands don't chdir), so this is a consistency point rather than a bug — but it's the one reader that would silently do the wrong thing if a future command adopts the convert pattern, and the surrounding code has already established the safer idiom.

@so0k
so0k force-pushed the tele/s5-stack-metrics branch from 995d31e to f04c44c Compare September 10, 2026 04:08
@so0k
so0k force-pushed the tele/s5-stack-metrics branch from f04c44c to 60fc03a Compare September 10, 2026 11:37
@so0k

so0k commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Item 1 is resolved in #411 rather than here, since that is where the divergence starts: cli.command.invoked is now emitted exactly once per run at command start, so it counts attempts for every command, a successful run ends with a single cli.command.completed carrying the scalars only known at the end, and a failed run ends with a single cli.command.error. That makes error / invoked a real failure rate across commands, and it means finishStackRun no longer emits the command metric before throwing: a failed deploy counts as one invoked at start and one error from the reporter, and a self-exiting synth failure counts under the command the run was started as. Items 2 to 4 are being addressed in this PR's own round.

@so0k
so0k force-pushed the tele/s5-stack-metrics branch from 60fc03a to ef8eda1 Compare September 22, 2026 16:03
@so0k
so0k force-pushed the tele/s5-stack-metrics branch from ef8eda1 to 5ec5a12 Compare September 23, 2026 07:06
@so0k
so0k force-pushed the tele/s5-stack-metrics branch from 5ec5a12 to 97b5e3d Compare October 11, 2026 19:54
@so0k so0k changed the title feat(cli): report per-stack, per-provider and per-command usage metrics feat(cli): report aggregated stack, provider and command usage metrics Oct 11, 2026
@so0k

so0k commented Oct 11, 2026

Copy link
Copy Markdown
Contributor Author

@jsteinich items 2 to 4 are done, and the description is rewritten for the new shape. The title changed with it, since events are no longer per stack.

2. Unbounded volume. It was worse than the 550 you estimated: a deploy without --skip-synth sent the stack block twice (once from the nested synth, once from finishStackRun), and watch re-sent it on every iteration. Fixed in 3e3d36d by aggregating per run instead of capping a per-stack stream:

  • cli.stack: one event per distinct (backend, cloud, library_version), value = stacks in the group, counts summed.
  • cli.stack.provider: one per distinct (provider, version_constraint, binding), value = stacks requiring it.
  • cli.stack.override: one per distinct resource_type.
  • cli.get.* and cli.init.provider dedupe the same way.
  • Caps of 8 stack groups, 32 provider tuples and 32 override types. The most counted are kept; the rest fold into one event per metric with truncated: true, no identity attributes, and the folded sum as its value, so totals are conserved.
  • One block per process: a nested synth sends only cli.synth.duration.
Events per deploy before now
1 stack, 2 providers 9 6
50 stacks × 8 providers 1,103 14
300 stacks × 8 providers 8,403 17

Consequences, all intended: watch reports its first iteration only; output and list send no stack block; counts are project-level ("stacks using this provider"), so a single stack's backend can no longer be correlated with its providers. Aggregation keys on the normalised identity, so two different private registry hosts become one private-registry event with value 2, and a test asserts neither hostname is in the envelope bytes.

Also fixed while there: diff reported every stack although one is diffed (44b904b).

3. SYNTH_ORIGINS. Commons exports it as const with the derived SynthOrigin; cli-core imports the type, and the second declaration and its drift comment are gone (f7b7af1). ScalarAttribute.values is readonly string[].

4. getGeneratedProviderSources() read process.cwd() at emission. It is now captured at command start in the same store as the consent decision, language and targets (same commit). A test starts in one project, changes directory into another with different generated providers, and the classification does not move. One consequence: a long watch session classifies against the list from when it started.

Tests. The audit against the new test-audit skill found privacy guards with no test: removing the 64-character limit on version constraints passed everything, as did removing the git::/hg::/s3::/gcs:: branch of module-source classification. Both have rows now. The normaliser tables run through the delivered metric instead of calling the functions directly, which let six functions stop being public exports of commons. The stack, get and init metrics assert their exact attribute key sets.

@so0k
so0k force-pushed the tele/s5-stack-metrics branch 2 times, most recently from fa4de2f to cc56119 Compare October 12, 2026 01:20
@so0k

so0k commented Oct 12, 2026

Copy link
Copy Markdown
Contributor Author

Failed Terraform runs skip the stack telemetry they are meant to count

Found by Lumen's delta review at cc561198e and confirmed against the code.

The problem

In packages/@cdktn/cli-core/src/lib/cdktf-project.ts, finishStackRun() is only reached when the Terraform operation resolves:

  • diff() awaits stack.diff() at :415; its catch throws Errors.External(...) at :416-420, before finishStackRun() at :422.
  • deploy() awaits execute() at :539 and calls finishStackRun() only at :541.
  • destroy() awaits execute() at :601 and calls finishStackRun() only at :603.

And a real failure always rejects: CdktfStack.run() records the stack as errored and then rethrows (cdktf-stack.ts:377-385), and execute() propagates the rejection (:490-510).

So for an ordinary failed plan, apply or destroy, the method exits before finishStackRun() runs.

How it shows up

cli.command.error is still counted by the command-level reporter, so nothing looks broken. But for exactly the runs they exist to describe, these are never emitted:

  • cli.stack.failed
  • the stack aggregate for the run
  • the provider and override aggregates

The failed-stack metric will read near zero indefinitely. The comment above finishStackRun ("Reports before throwing, so a failed run still sends its stack metrics") describes the intent, not the behaviour, and the stack.error ? [...] : [] branch in diff() is only reachable if diff() resolves with an error set, which the production path does not do.

Why the tests pass

cdktf-project-telemetry.test.ts models failure synthetically:

  • deploy/destroy mock execute() as resolving (:53) while the executor state is set by hand;
  • diff uses a resolving diff() mock with an independently populated error.

Neither exercises a rejected Terraform operation, which is the only way a failure happens in production.

Fix (Lumen's suggestion)

Catch the operation's rejection, emit the stack telemetry from the executors' resulting states, then rethrow the original error:

  • diff(): on rejection, report with the stack as failed, then throw the same Errors.External as today.
  • deploy() / destroy(): wrap execute(); on rejection, derive the failed stacks from stacksToRun (the errored executors), report, then rethrow what execute() threw.

Telemetry must stay best-effort on this path: finishStackRun already swallows a failed send, and that has to hold here too so a telemetry problem can never replace the Terraform error the user sees. Take care not to double-report when the operation resolves and finishStackRun then throws its own failure.

Tests to add

  • diff() where stack.diff() rejects: assert the stack metrics are sent with failedStackCount: 1 and the original error still propagates.
  • deploy() and destroy() where execute() rejects: same assertions, including a multi-stack case where one stack fails and another succeeds.

Unchanged

The rebase-compatibility parts of the review (package_manager propagation and the typescript-pnpm template assertion) had no findings.

@so0k
so0k force-pushed the tele/s5-stack-metrics branch from cc56119 to a843cd9 Compare October 12, 2026 08:01
so0k added 17 commits October 12, 2026 16:36
…r distinct values

One counter per distinct normalised identity instead of one event per
stack, provider or override: the value carries the count, at most 8 stack
groups and 32 identities per metric are kept and the rest folds into one
truncated event, so totals are conserved and a run sends at most 75 stack
events. A nested synth leaves the stacks to its command, a process reports
them once, and the command metric is emitted before them.

The normalisation tables are asserted on the delivered metric.
…try payload

Stack content is re-serialised JSON by the time it is read, and the stack
executor parses it unguarded.
The producer of the targets and the metric reading them are proven
together, so the copied payload shapes go.
… rejects

A failed plan, apply or destroy rejects, so diff, deploy and destroy left before the stack block and the failed count were sent. The rejection is now caught, reported from the executors' states and rethrown unchanged; a failed send still cannot replace it. The tests drive real executors against a rejecting Terraform client and assert the delivered metrics.
@so0k
so0k force-pushed the tele/s5-stack-metrics branch from dd0a812 to 5704c36 Compare October 12, 2026 09:39
@so0k

so0k commented Oct 12, 2026

Copy link
Copy Markdown
Contributor Author

The case I listed as not covered is now tested (5704c36), since #410 fixes the unobserved rejection in execute() that blocked it: one stack errors while another never starts, for deploy and destroy, gives cli.stack 2 and cli.stack.failed 1 with no cli.command.completed. Counting pending stacks as failed again fails all four.

@so0k
so0k merged commit ed9b1e5 into main Oct 12, 2026
266 checks passed
@so0k
so0k deleted the tele/s5-stack-metrics 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
… package manager in usage metrics (#412)

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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