Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion apps/cli-docs/src/fragments/commands/init.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ Path-like arguments (starting with `.`, `/`, or `~`) are always treated as the d
| `sourcemaps` | Source map uploads |
| `crons` | Cron job monitoring |
| `ai-monitoring` | AI/LLM monitoring |
| `user-feedback` | User feedback widget |

## What the Wizard Does

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Initialize Sentry in your project (experimental)
**Flags:**
- `-y, --yes - Accept non-interactive defaults (requires --features outside a TTY)`
- `-n, --dry-run - Show what would happen without making changes`
- `--features <value>... - Features to enable: errors,tracing,logs,replay,metrics,profiling,sourcemaps,crons,ai-monitoring,user-feedback`
- `--features <value>... - Features to enable: errors,tracing,logs,replay,metrics,profiling,sourcemaps,crons,ai-monitoring`
- `-t, --team <value> - Team slug to create the project under`
- `--app <value> - App to initialize in a monorepo (required with --yes when multiple apps are detected)`
- `--tui - Use the Ink-based interactive UI (default). Pass --no-tui to fall back to plain log output.`
Expand Down
5 changes: 1 addition & 4 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,6 @@ const FEATURE_ALIASES = {
crons: "crons",
"ai-monitoring": "aiMonitoring",
aiMonitoring: "aiMonitoring",
"user-feedback": "userFeedback",
userFeedback: "userFeedback",
} as const;

const SUPPORTED_FEATURE_NAMES = [
Expand All @@ -72,7 +70,6 @@ const SUPPORTED_FEATURE_NAMES = [
"sourcemaps",
"crons",
"ai-monitoring",
"user-feedback",
] as const;

const SUPPORTED_FEATURE_TEXT = SUPPORTED_FEATURE_NAMES.join(", ");
Expand Down Expand Up @@ -337,7 +334,7 @@ export const initCommand = buildCommand<
kind: "parsed",
parse: String,
brief:
"Features to enable: errors,tracing,logs,replay,metrics,profiling,sourcemaps,crons,ai-monitoring,user-feedback",
"Features to enable: errors,tracing,logs,replay,metrics,profiling,sourcemaps,crons,ai-monitoring",
variadic: true,
optional: true,
},
Expand Down
50 changes: 35 additions & 15 deletions packages/cli/src/lib/init/clack-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,53 +34,72 @@ export function abortIfCancelled<T>(value: T): Exclude<T, symbol> {
return value as Exclude<T, symbol>;
}

const FEATURE_INFO: Record<string, { label: string; hint: string }> = {
const FEATURE_INFO: Record<string, { label: string; description: string }> = {
errorMonitoring: {
label: "Error Monitoring",
hint: "Group exceptions into issues with context",
description: "Automatically capture exceptions and stack traces",
},
performanceMonitoring: {
label: "Tracing",
hint: "See request paths, spans, and bottlenecks",
description:
"Find bottlenecks, broken requests, and understand application flow end-to-end",
},
sessionReplay: {
label: "Session Replay",
hint: "Replay sessions linked to errors",
description: "Watch real user sessions to see what went wrong",
},
profiling: {
label: "Profiling",
hint: "Find CPU-heavy functions in production",
description:
"Pinpoint the functions and lines of code responsible for performance issues",
},
logs: {
label: "Logging",
description: "See logs in context with errors and performance issues",
},
metrics: {
label: "Application Metrics",
description:
"Track application performance and usage over time with custom metrics",
},
logs: { label: "Logging", hint: "Search logs beside errors and traces" },
metrics: { label: "Metrics", hint: "Track custom measurements over time" },
sourceMaps: {
label: "Source Maps",
hint: "Turn minified stacks into your source",
description:
"Turn minified production stack traces back into your original source code",
},
crons: {
label: "Crons",
hint: "Alert on failed or missed scheduled jobs",
description: "Detect failed, missed, or delayed scheduled jobs",
},
aiMonitoring: {
label: "AI Monitoring",
hint: "Track AI calls, latency, cost, and failures",
description:
"Understand AI calls, latency, token usage, cost, and failures",
},
mcpObservability: {
label: "MCP Observability",
description:
"Trace MCP tool calls and understand failures across agent workflows",
},
userFeedback: {
label: "User Feedback",
hint: "Collect user reports with issue context",
description:
"Collect user reports with the error and session context needed to investigate",
},
reactFeatures: {
label: "React Features",
hint: "Add React-specific context and integrations",
description:
"Capture React-specific errors with component and rendering context",
},
};

export function featureLabel(id: string): string {
return FEATURE_INFO[id]?.label ?? id;
}

export function featureHint(id: string): string | undefined {
return FEATURE_INFO[id]?.hint;
/** Returns product-oriented supporting copy for a known feature. */
export function featureDescription(id: string): string | undefined {
return FEATURE_INFO[id]?.description;
}

const FEATURE_DISPLAY_ORDER = [
Expand All @@ -93,11 +112,12 @@ const FEATURE_DISPLAY_ORDER = [
"sourceMaps",
"crons",
"aiMonitoring",
"mcpObservability",
"userFeedback",
"reactFeatures",
];

/** Sort features into canonical display order for the multi-select prompt. */
/** Sort features into the canonical order used by summaries and final output. */
export function sortFeatures(features: string[]): string[] {
return features.slice().sort((a, b) => {
const ai = FEATURE_DISPLAY_ORDER.indexOf(a);
Expand Down
153 changes: 101 additions & 52 deletions packages/cli/src/lib/init/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,11 @@
*/

import { setTag } from "@sentry/node-core/light";
import chalk from "chalk";
import { WizardError } from "../errors.js";
import {
abortIfCancelled,
featureHint,
featureDescription,
featureLabel,
sortFeatures,
} from "./clack-utils.js";
import { REQUIRED_FEATURE } from "./constants.js";
import type {
Expand All @@ -27,18 +25,67 @@ import type {
MultiSelectPayload,
SelectPayload,
} from "./types.js";
import type { WizardUI } from "./ui/types.js";
import type { PromptDetail, WizardUI } from "./ui/types.js";

function prependRequiredFeature(
features: string[],
hasRequired: boolean
): string[] {
if (!(hasRequired && !features.includes(REQUIRED_FEATURE))) {
function prependRequiredFeature(features: string[]): string[] {
if (features.includes(REQUIRED_FEATURE)) {
return features;
}
return [REQUIRED_FEATURE, ...features];
}

type FeatureReviewAction = "continue" | "back";

const DEFAULT_FEATURE_ORDER = [
REQUIRED_FEATURE,
"logs",
"sessionReplay",
"performanceMonitoring",
] as const;
const DEFAULT_FEATURES = new Set<string>(DEFAULT_FEATURE_ORDER);
const DEFAULT_FEATURE_RANK = new Map<string, number>(
DEFAULT_FEATURE_ORDER.map((feature, index) => [feature, index])
);
// Feedback setup needs an in-app placement choice this wizard cannot make yet.
const UNSUPPORTED_INIT_FEATURES = new Set(["userFeedback"]);
const FEATURE_SELECTION_CONTEXT =
"Based on your project, these features are available to set up.";

function sortFeatureOptions(features: string[]): string[] {
return features.slice().sort((a, b) => {
const rankDifference =
(DEFAULT_FEATURE_RANK.get(a) ?? Number.MAX_SAFE_INTEGER) -
(DEFAULT_FEATURE_RANK.get(b) ?? Number.MAX_SAFE_INTEGER);
if (rankDifference !== 0) {
return rankDifference;
}
return featureLabel(a).localeCompare(featureLabel(b), "en");
});
}

function normalizeFeatureSelection(features: string[]): string[] {
const normalized = new Set(features);
// The server enforces this dependency too; normalizing before review keeps
// the reviewed, resumed, and restored-on-Back selections identical.
if (
features.includes("aiMonitoring") ||
features.includes("mcpObservability")
) {
normalized.add("performanceMonitoring");
}
return sortFeatureOptions([...normalized]);
}

function buildFeatureReviewDetails(features: string[]): PromptDetail[] {
return [
{ text: "We'll add these features:" },
...features.map((feature) => ({
text: `✓ ${featureLabel(feature)}`,
tone: "success" as const,
})),
];
}

export async function handleInteractive(
payload: InteractivePayload,
options: InteractiveContext,
Expand Down Expand Up @@ -163,13 +210,11 @@ async function handleMultiSelect(
options: InteractiveContext,
ui: WizardUI
): Promise<Record<string, unknown>> {
const available = payload.availableFeatures ?? payload.options ?? [];

if (available.length === 0) {
return { features: [] };
}

const hasRequired = available.includes(REQUIRED_FEATURE);
const available = prependRequiredFeature(
(payload.availableFeatures ?? payload.options ?? []).filter(
(feature) => !UNSUPPORTED_INIT_FEATURES.has(feature)
)
);

if (options.yes) {
ui.log.info(
Expand All @@ -178,46 +223,50 @@ async function handleMultiSelect(
return { features: available };
}

const optional = sortFeatures(
available.filter((f) => f !== REQUIRED_FEATURE)
const sorted = sortFeatureOptions(available);
setTag("wizard.features.offered", available.join(","));
let initialValues: string[] = sorted.filter((feature) =>
DEFAULT_FEATURES.has(feature)
);

if (optional.length === 0) {
if (hasRequired) {
ui.log.info(`${featureLabel(REQUIRED_FEATURE)} is always included.`);
}
return { features: hasRequired ? [REQUIRED_FEATURE] : [] };
}

const hints: string[] = [];
// Use clack's vertical bar character so hint lines align with the option lines below
const bar = chalk.gray("\u2502");
if (hasRequired) {
hints.push(
`${bar} ${chalk.dim(`${featureLabel(REQUIRED_FEATURE)} is always included`)}`
);
}
hints.push(`${bar} ${chalk.dim("space=toggle, a=all, enter=confirm")}`);
while (true) {
const selected = await ui.multiselect<string>({
message: payload.prompt,
details: [{ text: FEATURE_SELECTION_CONTEXT }],
options: sorted.map((feature) => {
const description = featureDescription(feature);
return {
value: feature,
label: featureLabel(feature),
...(description ? { description } : {}),
...(feature === REQUIRED_FEATURE ? { locked: true } : {}),
};
}),
initialValues,
required: false,
});

setTag("wizard.features.offered", available.join(","));
const selected = await ui.multiselect<string>({
message: `${payload.prompt}\n${hints.join("\n")}`,
options: optional.map((feature) => {
const hint = featureHint(feature);
return {
value: feature,
label: featureLabel(feature),
...(hint ? { hint } : {}),
};
}),
initialValues: optional.filter((f) => f === "performanceMonitoring"),
required: false,
});
const chosen = abortIfCancelled(selected);
const features = normalizeFeatureSelection(prependRequiredFeature(chosen));
const review = await ui.select<FeatureReviewAction>({
message: "Review your Sentry setup",
details: buildFeatureReviewDetails(features),
footer: {
text: "We'll modify project files for this Sentry setup.",
},
options: [
{ value: "continue", label: "Continue" },
{ value: "back", label: "Back" },
],
initialValue: "continue",
});

const chosen = abortIfCancelled(selected);
const features = prependRequiredFeature(chosen, hasRequired);
setTag("wizard.features.selected", features.join(","));
return { features };
if (abortIfCancelled(review) === "continue") {
setTag("wizard.features.selected", features.join(","));
return { features };
}
initialValues = features;
}
}

async function handleConfirm(
Expand Down
Loading
Loading