Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
97dbb5c
feat(cli): replace HashiCorp checkpoint telemetry with Sentry usage m…
so0k Sep 9, 2026
a937dd4
feat(cli): ask for usage-telemetry consent and persist it in cdktf.json
so0k Sep 9, 2026
02e9b82
feat(cli): count failed command runs and flush through flushTelemetry
so0k Sep 9, 2026
2fb48c0
fix(cli): collect crash and usage consent in convert before the tempo…
so0k Sep 10, 2026
3ccd050
fix(cli): throw usage errors instead of exiting from the synth, watch…
so0k Sep 10, 2026
c52191e
fix(cli): normalise string-valued consent flags when parsing cdktf.json
so0k Sep 10, 2026
52e0a98
feat(cli): count every command run as invoked at start and as complet…
so0k Sep 10, 2026
9e6fafd
test(cli): split consent gating from the Sentry init hardening cases
so0k Sep 10, 2026
a2c8a2e
fix(commons): share the command scope across the bundle's module copies
so0k Sep 10, 2026
8249adc
fix(commons): share the consent decision, started command and languag…
so0k Sep 10, 2026
2a87611
chore(commons): state the bundle-copy direction of the shared telemet…
so0k Sep 10, 2026
27e32a6
fix(commons): read a malformed consent flag as unset, not as an opt-out
so0k Sep 22, 2026
c628e18
fix(cli): only prompt for consent when stdin is a terminal too
so0k Sep 22, 2026
c9dc2e7
fix(cli): count watch, list, output and a no-op get as completed
so0k Sep 22, 2026
eb05bd4
fix(cli): count provider add as invoked and completed like other comm…
so0k Sep 22, 2026
3dc8588
fix(cli): say why the usage-telemetry prompt asks for consent
so0k Sep 22, 2026
03e1c52
chore(cli): link crash-reporting consent to the telemetry page and fi…
so0k Sep 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/@cdktn/cli-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@
"@types/fs-extra": "11.0.4",
"@types/node": "22.20.1",
"@types/semver": "7.7.1",
"nock": "^14.0.16",
"prettier": "2.8.8",
"tsc-files": "1.1.4",
"typescript": "~6.0.0"
Expand Down
118 changes: 93 additions & 25 deletions packages/@cdktn/cli-core/src/lib/error-reporting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@
// SPDX-License-Identifier: MPL-2.0
import * as Sentry from "@sentry/node";
import {
Errors,
getProjectId,
getUserId,
getUsageTelemetryConsent,
setUsageTelemetryEnabled,
startCommandTelemetry,
collectDebugInformation,
DISPLAY_VERSION,
normalizeConsentFlag,
} from "@cdktn/commons";
import { logger } from "@cdktn/commons";
import * as path from "path";
Expand All @@ -21,9 +26,14 @@ export function shouldReportCrash(
fs.readFileSync(path.resolve(projectPath, "cdktf.json"), "utf8"),
);

return typeof cdktfJson.sendCrashReports === "boolean"
? cdktfJson.sendCrashReports
: cdktfJson.sendCrashReports === "true";
// tri-state: an absent or malformed flag means "unset" and triggers the
// interactive consent prompt; outside a project (no readable
// cdktf.json) crash reporting stays off
if (!("sendCrashReports" in cdktfJson)) {
return undefined;
}

return normalizeConsentFlag("sendCrashReports", cdktfJson.sendCrashReports);
} catch (e) {
logger.debug(
`Error determining if crash reporting should be enabled, defaulting to false: ${e}`,
Expand All @@ -32,20 +42,35 @@ export function shouldReportCrash(
}
}

export function persistReportCrashReportDecision(
function persistConsentDecision(
key: "sendCrashReports" | "sendUsageTelemetry",
decision: boolean,
projectPath = process.cwd(),
) {
const cdktfJson = JSON.parse(
fs.readFileSync(path.resolve(projectPath, "cdktf.json"), "utf8"),
);
cdktfJson.sendCrashReports = decision;
cdktfJson[key] = decision;
fs.writeFileSync(
path.resolve(projectPath, "cdktf.json"),
JSON.stringify(cdktfJson, null, 2),
);
}

export function persistReportCrashReportDecision(
decision: boolean,
projectPath = process.cwd(),
) {
persistConsentDecision("sendCrashReports", decision, projectPath);
}

export function persistSendUsageTelemetryDecision(
decision: boolean,
projectPath = process.cwd(),
) {
persistConsentDecision("sendUsageTelemetry", decision, projectPath);
}

function isPromise(p: any): p is Promise<any> {
return (
typeof p === "object" &&
Expand All @@ -54,33 +79,64 @@ function isPromise(p: any): p is Promise<any> {
);
}

/**
* `projectPath` is the project whose consent flags apply: the cwd for every
* command, the freshly created project for `init`.
*/
export async function initializErrorReporting(
runConsentPrompt?: () => Promise<boolean>,
runCrashConsentPrompt?: () => Promise<boolean>,
runUsageTelemetryConsentPrompt?: () => Promise<boolean>,
projectPath = process.cwd(),
) {
let shouldReport = shouldReportCrash();
const ci: string | false = ciInfo.isCI ? ciInfo.name || "unknown" : false;

// We have no info yet, so we need to ask the user
if (shouldReport === undefined && runConsentPrompt) {
// But only if it's a user
if (ci) {
return;
}
let shouldReport = shouldReportCrash(projectPath);
let usageConsent = getUsageTelemetryConsent(projectPath);

shouldReport = await runConsentPrompt();
persistReportCrashReportDecision(shouldReport);
// Prompting requires a real user at a terminal (TTY and not CI) and a
// cdktf.json to persist the decision into; otherwise fall through to
// the per-flag non-interactive defaults below.
const canPrompt =
Boolean(process.stdin.isTTY) &&
Boolean(process.stdout.isTTY) &&
!ciInfo.isCI &&
!process.env.CI &&
fs.existsSync(path.resolve(projectPath, "cdktf.json"));

if (canPrompt) {
if (shouldReport === undefined && runCrashConsentPrompt) {
shouldReport = await runCrashConsentPrompt();
persistReportCrashReportDecision(shouldReport, projectPath);
}
if (
usageConsent === undefined &&
runUsageTelemetryConsentPrompt &&
!process.env.CHECKPOINT_DISABLE
) {
usageConsent = await runUsageTelemetryConsentPrompt();
persistSendUsageTelemetryDecision(usageConsent, projectPath);
}
}

if (!shouldReport) {
logger.debug("Error reporting disabled");
// Non-interactive defaults: crash reporting is opt-in (off), usage
// telemetry is on unless CHECKPOINT_DISABLE is set.
const crashReportingEnabled = shouldReport === true;
const usageTelemetryEnabled =
!process.env.CHECKPOINT_DISABLE && usageConsent !== false;

// Capture the decision while we are still in the user's working
// directory: some commands (convert) chdir into a temporary project
// before sendTelemetry runs and must not consult that project's flags.
setUsageTelemetryEnabled(usageTelemetryEnabled);

if (!crashReportingEnabled && !usageTelemetryEnabled) {
logger.debug("Error reporting and usage telemetry disabled");
return;
}
if (!process.env.SENTRY_DSN) {
logger.info("Error reporting disabled: SENTRY_DSN not set");
logger.info("Reporting disabled: SENTRY_DSN not set");
return;
}

logger.debug("Initializing error reporting");
logger.debug("Initializing reporting");

Sentry.init({
dsn: process.env.SENTRY_DSN,
Expand All @@ -97,6 +153,12 @@ export async function initializErrorReporting(
// SDK default flip cannot silently stop delivery.
enableMetrics: true,
async beforeSend(event, hint) {
// Error/crash events require their own consent: when Sentry is
// initialized only for usage metrics, drop every error event
// (metrics do not pass through beforeSend).
if (!crashReportingEnabled) {
return null;
}
if (!hint) {
return event;
}
Expand Down Expand Up @@ -136,10 +198,16 @@ export async function initializErrorReporting(
});
scope.setTag("projectId", getProjectId());

logger.debug("Collecting environment information for error reporting");
collectDebugInformation().then((debugOutput) => {
Sentry.setContext("environment", debugOutput);
});
if (crashReportingEnabled) {
logger.debug("Collecting environment information for error reporting");
collectDebugInformation().then((debugOutput) => {
Sentry.setContext("environment", debugOutput);
});
}

// The run is counted as started here, under the command scope every
// command sets before it initializes reporting.
await startCommandTelemetry(Errors.getScope(), projectPath);
}

export function captureException({
Expand Down
3 changes: 3 additions & 0 deletions packages/@cdktn/cli-core/src/lib/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export type InitArgs = {
projectInfo: Project;
templatePath: string;
sendCrashReports: boolean;
sendUsageTelemetry: boolean;
silent?: boolean;
};

Expand All @@ -64,6 +65,7 @@ export async function init({
projectInfo,
templatePath,
sendCrashReports,
sendUsageTelemetry,
providers,
providersForceLocal,
silent,
Expand All @@ -84,6 +86,7 @@ export async function init({
futureFlags,
projectId,
sendCrashReports,
sendUsageTelemetry,
silent,
});
const cdktfConfig = CdktfConfig.read(destination);
Expand Down
33 changes: 29 additions & 4 deletions packages/@cdktn/cli-core/src/lib/synth-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,15 @@ import {
TerraformStackMetadata,
} from "cdktn";
import { performance } from "perf_hooks";
import { logger, readConfigSync, sendTelemetry, shell } from "@cdktn/commons";
import {
Errors,
commandErrorType,
flushTelemetry,
logger,
readConfigSync,
sendTelemetry,
shell,
} from "@cdktn/commons";
import { CdktfConfig } from "./cdktf-config";
import { format } from "@cdktn/hcl-tools";

Expand Down Expand Up @@ -164,12 +172,15 @@ Command output on stdout:
`
: ""
}`;
await this.synthErrorTelemetry(synthOrigin);
if (graceful) {
e.errorOutput = errorOutput;
throw e;
}
console.error(`ERROR: ${errorOutput}`);
// hard exit skips the entrypoint's failure reporter and flush, so
// count and flush the failed run here (bounded)
await this.synthErrorTelemetry(e, synthOrigin);
await flushTelemetry();
process.exit(1);
}

Expand All @@ -190,6 +201,8 @@ Command output on stdout:
throw new Error(errorMessage);
}
logger.error(errorMessage);
await this.synthErrorTelemetry(e, synthOrigin);
await flushTelemetry();
process.exit(1);
}

Expand Down Expand Up @@ -290,8 +303,20 @@ Command output on stdout:
});
}

public static async synthErrorTelemetry(synthOrigin?: SynthOrigin) {
await sendTelemetry("synth", { error: true, synthOrigin });
/**
* One `cli.command.error` per failed run, under the running command (a
* deploy's synth fails the deploy). Only the self-exiting paths above count
* here; anything thrown is counted by the entrypoint's failure reporter.
*/
public static async synthErrorTelemetry(
error: unknown,
synthOrigin?: SynthOrigin,
) {
await sendTelemetry(Errors.getScope(), {
error: true,
errorType: commandErrorType(error),
synthOrigin,
});
}
}

Expand Down
3 changes: 1 addition & 2 deletions packages/@cdktn/cli-core/src/lib/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from "./cdktf-project";
import * as fs from "fs";
import * as chokidar from "chokidar";
import { logger, Errors, sendTelemetry } from "@cdktn/commons";
import { logger, Errors } from "@cdktn/commons";
import { CdktfStack } from "./cdktf-stack";

// In this very first iteration we will find out which files to watch by asking the user to provide the files
Expand Down Expand Up @@ -185,6 +185,5 @@ export async function watch(
// initially run once
onFileChange();

await sendTelemetry("watch", { event: "start" });
await stopped;
}
56 changes: 0 additions & 56 deletions packages/@cdktn/cli-core/src/test/checkpoint.test.ts

This file was deleted.

Loading
Loading