diff --git a/docs/src/content/docs/contributing.md b/docs/src/content/docs/contributing.md index b42f9c7a5..fe6c060f1 100644 --- a/docs/src/content/docs/contributing.md +++ b/docs/src/content/docs/contributing.md @@ -60,6 +60,7 @@ cli/ │ │ ├── issue/ # archive, events, explain, list, merge, plan, resolve, unresolve, view │ │ ├── local/ # run, serve │ │ ├── log/ # list, view +│ │ ├── monitor/ # list, run │ │ ├── org/ # list, view │ │ ├── proguard/ # uuid │ │ ├── project/ # create, delete, list, view diff --git a/docs/src/fragments/commands/monitor.md b/docs/src/fragments/commands/monitor.md new file mode 100644 index 000000000..fd2f16782 --- /dev/null +++ b/docs/src/fragments/commands/monitor.md @@ -0,0 +1,33 @@ + + +## Examples + +```bash +# Wrap a command with cron monitor check-ins (DSN-based) +SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0 \ + sentry monitor run nightly-job -- python manage.py cron + +# The -- separator is optional when the command has no flags +sentry monitor run nightly-job npm run task + +# Create/update the monitor on the first check-in via --schedule (crontab) +sentry monitor run nightly-job -s "0 0 * * *" --max-runtime 30 --timezone UTC -- ./backup.sh + +# List cron monitors in an org +sentry monitor list my-org/ + +# Paginate through monitors +sentry monitor list my-org/ -c next + +# Output as JSON +sentry monitor list --json +``` + +## Check-in lifecycle + +`monitor run` sends an `in_progress` check-in when the wrapped command starts, +then an `ok` or `error` check-in (with duration) when it finishes, based on the +exit code. The wrapped command inherits stdio, has `SIGINT`/`SIGTERM` +forwarded, receives the `SENTRY_MONITOR_SLUG` environment variable, and its +exit code is preserved. Check-in delivery failures are non-fatal — the wrapped +command still runs and exits with its own code. diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index b39355ffe..d6d1ddb4e 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -472,6 +472,15 @@ View Sentry logs → Full flags and examples: `references/log.md` +### Monitor + +Work with Sentry cron monitors + +- `sentry monitor run ` — Wrap a command with cron monitor check-ins +- `sentry monitor list ` — List cron monitors + +→ Full flags and examples: `references/monitor.md` + ### Sourcemap Manage sourcemaps diff --git a/plugins/sentry-cli/skills/sentry-cli/references/monitor.md b/plugins/sentry-cli/skills/sentry-cli/references/monitor.md new file mode 100644 index 000000000..beaf811b1 --- /dev/null +++ b/plugins/sentry-cli/skills/sentry-cli/references/monitor.md @@ -0,0 +1,73 @@ +--- +name: sentry-cli-monitor +version: 0.36.0-dev.0 +description: Work with Sentry cron monitors +requires: + bins: ["sentry"] + auth: true +--- + +# Monitor Commands + +Work with Sentry cron monitors + +### `sentry monitor run ` + +Wrap a command with cron monitor check-ins + +**Flags:** +- `--dsn - DSN to send check-ins to (overrides SENTRY_DSN env var)` +- `-e, --environment - Environment of the monitor - (default: "production")` +- `-s, --schedule - Upsert the monitor with this crontab schedule (e.g. '0 * * * *')` +- `--check-in-margin - Minutes after the expected check-in before it is missed (requires --schedule)` +- `--max-runtime - Minutes a check-in may run before timing out (requires --schedule)` +- `--timezone - Timezone of the schedule, tz database string (requires --schedule)` +- `--failure-issue-threshold - Consecutive failures before an issue is created (requires --schedule)` +- `--recovery-threshold - Consecutive successes before an issue is resolved (requires --schedule)` + +### `sentry monitor list ` + +List cron monitors + +**Flags:** +- `-n, --limit - Maximum number of monitors to list - (default: "25")` +- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` +- `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` + +**JSON Fields** (use `--json --fields` to select specific fields): + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Monitor ID | +| `slug` | string | Monitor slug | +| `name` | string | Monitor name | +| `status` | string | Monitor status (e.g. active, disabled) | +| `isMuted` | boolean | Whether the monitor is muted | +| `config` | object | Schedule configuration | +| `dateCreated` | string | Creation date (ISO 8601) | +| `project` | object | Owning project | + +**Examples:** + +```bash +# Wrap a command with cron monitor check-ins (DSN-based) +SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0 \ + sentry monitor run nightly-job -- python manage.py cron + +# The -- separator is optional when the command has no flags +sentry monitor run nightly-job npm run task + +# Create/update the monitor on the first check-in via --schedule (crontab) +sentry monitor run nightly-job -s "0 0 * * *" --max-runtime 30 --timezone UTC -- ./backup.sh + +# List cron monitors in an org +sentry monitor list my-org/ + +# Paginate through monitors +sentry monitor list my-org/ -c next + +# Output as JSON +sentry monitor list --json +``` + +All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/src/app.ts b/src/app.ts index ba54e70df..e6cf2292b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -22,6 +22,8 @@ import { listCommand as issueListCommand } from "./commands/issue/list.js"; import { localRoute } from "./commands/local/index.js"; import { logRoute } from "./commands/log/index.js"; import { listCommand as logListCommand } from "./commands/log/list.js"; +import { monitorRoute } from "./commands/monitor/index.js"; +import { listCommand as monitorListCommand } from "./commands/monitor/list.js"; import { orgRoute } from "./commands/org/index.js"; import { listCommand as orgListCommand } from "./commands/org/list.js"; import { proguardRoute } from "./commands/proguard/index.js"; @@ -77,6 +79,7 @@ const PLURAL_TO_SINGULAR: Record = { repos: "repo", teams: "team", logs: "log", + monitors: "monitor", replays: "replay", spans: "span", @@ -104,6 +107,7 @@ export const routes = buildRouteMap({ events: eventListCommand, explore: exploreCommand, log: logRoute, + monitor: monitorRoute, sourcemap: sourcemapRoute, sourcemaps: sourcemapRoute, span: spanRoute, @@ -125,6 +129,7 @@ export const routes = buildRouteMap({ repos: repoListCommand, teams: teamListCommand, logs: logListCommand, + monitors: monitorListCommand, spans: spanListCommand, traces: traceListCommand, trials: trialListCommand, @@ -147,6 +152,7 @@ export const routes = buildRouteMap({ repos: true, teams: true, logs: true, + monitors: true, spans: true, traces: true, trials: true, @@ -370,6 +376,10 @@ export const app = buildApplication(routes, { }, scanner: { caseStyle: "allow-kebab-for-camel", + // Allow `--` to stop flag parsing so wrapper commands (e.g. + // `sentry monitor run -- `) can pass through flags + // like `-e` or `--verbose` to the wrapped command unambiguously. + allowArgumentEscapeSequence: true, }, determineExitCode: getExitCode, localization: { diff --git a/src/commands/monitor/index.ts b/src/commands/monitor/index.ts new file mode 100644 index 000000000..dd2170967 --- /dev/null +++ b/src/commands/monitor/index.ts @@ -0,0 +1,19 @@ +import { buildRouteMap } from "../../lib/route-map.js"; +import { listCommand } from "./list.js"; +import { runCommand } from "./run.js"; + +export const monitorRoute = buildRouteMap({ + routes: { + run: runCommand, + list: listCommand, + }, + defaultCommand: "list", + docs: { + brief: "Work with Sentry cron monitors", + fullDescription: + "Run commands with cron monitor check-ins and list configured monitors.\n\n" + + " sentry monitor run -- # wrap a command with check-ins\n" + + " sentry monitor list # list configured monitors\n\n" + + "Alias: `sentry monitors` → `sentry monitor list`", + }, +}); diff --git a/src/commands/monitor/list.ts b/src/commands/monitor/list.ts new file mode 100644 index 000000000..bde6b5dda --- /dev/null +++ b/src/commands/monitor/list.ts @@ -0,0 +1,96 @@ +/** + * sentry monitor list + * + * List cron monitors in an organization, with flexible targeting and cursor + * pagination. + * + * Supports: + * - Auto-detection from DSN/config + * - Org-scoped listing with cursor pagination (e.g., sentry/) + * - Project-scoped targeting (e.g., sentry/cli) — monitors are org-scoped, so + * this lists the org's monitors + * - Cross-org project search (e.g., sentry) + */ + +import { listMonitors, listMonitorsPaginated } from "../../lib/api-client.js"; +import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; +import { type Column, formatTable } from "../../lib/formatters/table.js"; +import { + buildOrgListCommand, + type OrgListCommandDocs, +} from "../../lib/list-command.js"; +import type { OrgListConfig } from "../../lib/org-list.js"; +import { type SentryMonitor, SentryMonitorSchema } from "../../types/index.js"; + +/** Command key for pagination cursor storage */ +export const PAGINATION_KEY = "monitor-list"; + +/** Monitor with its organization context for display */ +type MonitorWithOrg = SentryMonitor & { orgSlug?: string }; + +/** + * Render a monitor's schedule for display. + * + * Crontab schedules show the raw expression (e.g. `"0 * * * *"`). + * Interval schedules show `"every "` (e.g. `"every 1 hour"`). + * Returns an empty string when no schedule is configured. + */ +function formatSchedule(monitor: MonitorWithOrg): string { + const config = monitor.config; + if (!config?.schedule) { + return ""; + } + if (Array.isArray(config.schedule)) { + return `every ${config.schedule[0]} ${config.schedule[1]}`; + } + return config.schedule; +} + +/** Column definitions for the monitor table. */ +const MONITOR_COLUMNS: Column[] = [ + { header: "ID", value: (m) => m.id }, + { header: "SLUG", value: (m) => m.slug }, + { header: "NAME", value: (m) => escapeMarkdownCell(m.name) }, + { header: "STATUS", value: (m) => m.status }, + { header: "SCHEDULE", value: (m) => escapeMarkdownCell(formatSchedule(m)) }, +]; + +/** Shared config that plugs into the org-list framework. */ +const monitorListConfig: OrgListConfig = { + paginationKey: PAGINATION_KEY, + entityName: "monitor", + entityPlural: "monitors", + commandPrefix: "sentry monitor list", + listForOrg: (org) => listMonitors(org), + listPaginated: (org, opts) => listMonitorsPaginated(org, opts), + withOrg: (monitor, orgSlug) => ({ ...monitor, orgSlug }), + displayTable: (monitors: MonitorWithOrg[]) => + formatTable(monitors, MONITOR_COLUMNS), + schema: SentryMonitorSchema, +}; + +const docs: OrgListCommandDocs = { + brief: "List cron monitors", + fullDescription: + "List cron monitors in an organization.\n\n" + + "Target specification:\n" + + " sentry monitor list # auto-detect from DSN or config\n" + + " sentry monitor list / # list all monitors in org (paginated)\n" + + " sentry monitor list / # list monitors in org (project context)\n" + + " sentry monitor list # list monitors in org\n\n" + + "Pagination:\n" + + " sentry monitor list / -c next # fetch next page\n" + + " sentry monitor list / -c prev # fetch previous page\n\n" + + "Examples:\n" + + " sentry monitor list # auto-detect or list all\n" + + " sentry monitor list my-org/ # list monitors in my-org (paginated)\n" + + " sentry monitor list --limit 10\n" + + " sentry monitor list --json\n\n" + + "Alias: `sentry monitors` → `sentry monitor list`", +}; + +export const listCommand = buildOrgListCommand( + monitorListConfig, + docs, + "monitor" +); diff --git a/src/commands/monitor/run.ts b/src/commands/monitor/run.ts new file mode 100644 index 000000000..db3582e9e --- /dev/null +++ b/src/commands/monitor/run.ts @@ -0,0 +1,392 @@ +/** + * sentry monitor run + * + * Wrap an arbitrary command with cron monitor check-ins. Sends an + * `in_progress` check-in when the command starts, then `ok`/`error` (with + * duration) on completion based on the child's exit code. + * + * Check-ins are sent via DSN (not an auth token), reusing the envelope + * transport in `src/lib/envelope/`. The DSN is resolved from `--dsn`, the + * `SENTRY_DSN` env var, or by auto-detecting it from the project sources. + * + * The wrapped command inherits the parent's stdio and signals (SIGINT/SIGTERM + * are forwarded), and its exit code is preserved. Check-in send failures are + * non-fatal — they are logged and the wrapped command still runs and exits + * with its own code. + */ + +import { spawn } from "node:child_process"; +import { constants as osConstants } from "node:os"; +import { + createCheckInEnvelope, + makeDsn, + serializeEnvelope, + uuid4, +} from "@sentry/core"; +import type { SentryContext } from "../../context.js"; +import { buildCommand, numberParser } from "../../lib/command.js"; +import { detectDsn } from "../../lib/dsn/index.js"; +import { + buildCheckIn, + buildMonitorConfig, + type CheckInConfigFlags, +} from "../../lib/envelope/checkin-builder.js"; +import { + resolveDsn, + sendEnvelopeRequest, +} from "../../lib/envelope/transport.js"; +import { CliError, ConfigError, ValidationError } from "../../lib/errors.js"; +import { logger } from "../../lib/logger.js"; + +const log = logger.withTag("monitor.run"); + +/** Usage hint shown when no command is provided. */ +const USAGE_HINT = "sentry monitor run -- "; + +type RunFlags = CheckInConfigFlags & { + dsn?: string; + environment: string; +}; + +/** Parse a positive integer flag value (minimum 1). */ +function parsePositiveInt(value: string): number { + const num = numberParser(value); + if (!Number.isInteger(num) || num < 1) { + throw new ValidationError( + `Invalid value: ${value}. Must be a positive integer.`, + "monitor-config" + ); + } + return num; +} + +/** + * Resolve the DSN to send check-ins to. + * + * Priority: `--dsn` flag → `SENTRY_DSN` env var → auto-detected project DSN. + * Throws {@link ConfigError} if none can be found. + */ +async function resolveCheckInDsn( + flags: RunFlags, + cwd: string +): Promise { + const explicit = resolveDsn(flags); + if (explicit) { + return explicit; + } + + const detected = await detectDsn(cwd); + if (detected) { + log.debug(`Using auto-detected DSN from ${detected.source}`); + return detected.raw; + } + + throw new ConfigError( + "No DSN found. Provide one via --dsn , set the SENTRY_DSN environment variable, or run from a project where a DSN can be detected.", + USAGE_HINT + ); +} + +/** Maximum time (ms) to wait for a check-in envelope to be sent. */ +const CHECKIN_SEND_TIMEOUT_MS = 30_000; + +/** + * Send a check-in envelope, swallowing (but logging) any error. + * + * Check-in delivery must never abort or stall the wrapped command, so: + * - Failures are non-fatal (logged, then continues). + * - A timeout prevents a slow/unreachable ingest endpoint from blocking + * the child process spawn or exit. + */ +async function sendCheckInSafely( + dsn: string, + dsnComponents: ReturnType, + checkIn: ReturnType, + phase: "in-progress" | "final" +): Promise { + try { + const envelope = createCheckInEnvelope( + checkIn, + undefined, + undefined, + undefined, + dsnComponents + ); + const body = serializeEnvelope(envelope); + const send = sendEnvelopeRequest(dsn, body); + // Prevent unhandled rejection if the timeout wins the race but the + // fetch later rejects (Node 15+ terminates on unhandled rejections). + send.catch(() => { + // Intentionally empty — prevents unhandled rejection if timeout wins. + }); + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + reject, + CHECKIN_SEND_TIMEOUT_MS, + new Error("Check-in send timed out") + ); + }); + try { + await Promise.race([send, timeout]); + } finally { + clearTimeout(timer); + } + } catch (err) { + log.warn( + `Failed to send ${phase} check-in: ${err instanceof Error ? err.message : String(err)}` + ); + log.debug("Continuing despite check-in failure..."); + } +} + +export const runCommand = buildCommand({ + docs: { + brief: "Wrap a command with cron monitor check-ins", + fullDescription: `\ +Run a command and report its execution to a Sentry cron monitor. + +An \`in_progress\` check-in is sent when the command starts, then an \`ok\` or +\`error\` check-in (with duration) is sent when it finishes, based on the exit +code. The wrapped command's stdio and signals are forwarded and its exit code +is preserved. + +Check-ins are sent via DSN — no \`sentry auth login\` required. The DSN is +resolved from \`--dsn\`, the \`SENTRY_DSN\` environment variable, or by +auto-detecting it from your project sources. + +## Usage + +\`\`\` +sentry monitor run -- +\`\`\` + +The \`--\` separator is recommended so flags belonging to your command are not +interpreted by \`monitor run\`. It is optional when your command has no flags: + +\`\`\` +sentry monitor run nightly-job -- python manage.py cron +sentry monitor run nightly-job npm run task # -- optional here +\`\`\` + +## Creating/updating the monitor + +Pass \`--schedule\` (crontab format) to upsert the monitor on the first +check-in. Dependent flags require \`--schedule\`: + +\`\`\` +sentry monitor run nightly-job -s "0 0 * * *" --max-runtime 30 --timezone UTC -- ./backup.sh +\`\`\` + +The wrapped command receives the \`SENTRY_MONITOR_SLUG\` environment variable.`, + }, + auth: "dsn", + parameters: { + positional: { + kind: "array", + parameter: { + brief: "Monitor slug followed by the command to run", + parse: String, + placeholder: "monitor-slug command", + }, + }, + flags: { + dsn: { + kind: "parsed", + parse: String, + brief: "DSN to send check-ins to (overrides SENTRY_DSN env var)", + optional: true, + }, + environment: { + kind: "parsed", + parse: String, + brief: "Environment of the monitor", + default: "production", + }, + schedule: { + kind: "parsed", + parse: String, + brief: + "Upsert the monitor with this crontab schedule (e.g. '0 * * * *')", + optional: true, + }, + "check-in-margin": { + kind: "parsed", + parse: parsePositiveInt, + brief: + "Minutes after the expected check-in before it is missed (requires --schedule)", + optional: true, + }, + "max-runtime": { + kind: "parsed", + parse: parsePositiveInt, + brief: + "Minutes a check-in may run before timing out (requires --schedule)", + optional: true, + }, + timezone: { + kind: "parsed", + parse: String, + brief: + "Timezone of the schedule, tz database string (requires --schedule)", + optional: true, + }, + "failure-issue-threshold": { + kind: "parsed", + parse: parsePositiveInt, + brief: + "Consecutive failures before an issue is created (requires --schedule)", + optional: true, + }, + "recovery-threshold": { + kind: "parsed", + parse: parsePositiveInt, + brief: + "Consecutive successes before an issue is resolved (requires --schedule)", + optional: true, + }, + }, + aliases: { + e: "environment", + s: "schedule", + }, + }, + async *func(this: SentryContext, flags: RunFlags, ...rawArgs: string[]) { + const { cwd } = this; + + // The scanner consumes the "--" escape token (allowArgumentEscapeSequence + // is enabled in app.ts), but strip a leading one defensively in case it is + // ever passed through (e.g. via a wrapping shell). + const args = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs; + const monitorSlug = args[0]; + const command = args.slice(1); + + if (!monitorSlug) { + throw new ValidationError( + `No monitor slug provided. Usage: ${USAGE_HINT}`, + "monitor-slug" + ); + } + if (command.length === 0) { + throw new ValidationError( + `No command provided. Usage: ${USAGE_HINT}`, + "command" + ); + } + + // Validate config flags (throws if a dependent flag lacks --schedule). + const monitorConfig = buildMonitorConfig(flags); + + const dsn = await resolveCheckInDsn(flags, cwd); + let dsnComponents: ReturnType; + try { + dsnComponents = makeDsn(dsn); + } catch (err) { + log.debug("makeDsn threw for DSN input", err); + dsnComponents = undefined; + } + if (!dsnComponents) { + throw new ValidationError(`Invalid DSN: ${dsn}`, "dsn"); + } + + const checkInId = uuid4(); + const { environment } = flags; + + // Opening check-in carries the upsert config (if any). + await sendCheckInSafely( + dsn, + dsnComponents, + buildCheckIn({ + checkInId, + monitorSlug, + status: "in_progress", + environment, + monitorConfig, + }), + "in-progress" + ); + + const startedAt = Date.now(); + + const [cmd = "", ...cmdArgs] = command; + const child = spawn(cmd, cmdArgs, { + env: { + ...process.env, + SENTRY_MONITOR_SLUG: monitorSlug, + }, + stdio: "inherit", + }); + + // Forward signals so the whole process tree shuts down together. + const onSigint = () => child.kill("SIGINT"); + const onSigterm = () => child.kill("SIGTERM"); + // Use `on` (not `once`) so repeated signals are still forwarded to the + // child instead of letting Node's default handler kill the parent — which + // would skip the closing check-in and leave the monitor stuck in_progress. + process.on("SIGINT", onSigint); + process.on("SIGTERM", onSigterm); + + let exitCode: number; + let spawnError: Error | undefined; + try { + exitCode = await new Promise((resolve) => { + let settled = false; + child.on("close", (code, signal) => { + if (!settled) { + settled = true; + if (code !== null) { + resolve(code); + } else if (signal) { + // Map signal kills to 128+N (Unix convention: e.g. 130 for + // SIGINT, 137 for SIGKILL, 143 for SIGTERM) so CI pipelines + // and shell scripts inspecting $? see the correct exit code. + const sigNum = + osConstants.signals[signal as keyof typeof osConstants.signals]; + resolve(128 + (sigNum ?? 1)); + } else { + resolve(1); + } + } + }); + // If spawn itself fails (e.g. ENOENT), 'close' may never fire. + // Record the error, treat as a failed run (exit code 1) so the close + // check-in still reports an `error` status, then surface a CliError. + child.on("error", (err) => { + log.debug(`Child process error: ${err.message}`); + if (!settled) { + settled = true; + spawnError = err; + resolve(1); + } + }); + }); + } finally { + process.removeListener("SIGINT", onSigint); + process.removeListener("SIGTERM", onSigterm); + } + + const durationSeconds = (Date.now() - startedAt) / 1000; + + // Closing check-in: status from exit code, with duration. No config. + await sendCheckInSafely( + dsn, + dsnComponents, + buildCheckIn({ + checkInId, + monitorSlug, + status: exitCode === 0 ? "ok" : "error", + environment, + duration: durationSeconds, + }), + "final" + ); + + if (spawnError) { + throw new CliError(`Failed to start "${cmd}": ${spawnError.message}`, 1); + } + + if (exitCode !== 0) { + throw new CliError(`Process exited with code ${exitCode}`, exitCode); + } + }, +}); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 4f1c1776e..82ae7058b 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -101,6 +101,7 @@ export { listLogs, listTraceLogs, } from "./api/logs.js"; +export { listMonitors, listMonitorsPaginated } from "./api/monitors.js"; export { getOrganization, getUserRegions, diff --git a/src/lib/api/monitors.ts b/src/lib/api/monitors.ts new file mode 100644 index 000000000..96e3160d5 --- /dev/null +++ b/src/lib/api/monitors.ts @@ -0,0 +1,82 @@ +/** + * Cron Monitor API functions + * + * Read operations for Sentry cron monitors via the stable + * `/organizations/{org}/monitors/` endpoint. Uses region-aware routing for + * multi-region support. Check-in ingestion is handled separately via the DSN + * envelope transport (`src/lib/envelope/`), not this module. + */ + +import { retrieveMonitorsForAnOrganization } from "@sentry/api"; + +import type { SentryMonitor } from "../../types/index.js"; + +import { + API_MAX_PER_PAGE, + autoPaginate, + getOrgSdkConfig, + MAX_PAGINATION_PAGES, + type PaginatedResponse, + unwrapPaginatedResult, +} from "./infrastructure.js"; + +/** + * List all cron monitors in an organization. + * + * Transparently fetches multiple pages when the org has more monitors than + * the API page size (100). Matches the `listProjects` pattern. + * + * @param orgSlug - Organization slug + * @returns Monitors, including nested monitor environments + */ +export async function listMonitors(orgSlug: string): Promise { + const config = await getOrgSdkConfig(orgSlug); + + const { data: allResults } = await autoPaginate(async (cursor) => { + const result = await retrieveMonitorsForAnOrganization({ + ...config, + path: { organization_id_or_slug: orgSlug }, + query: { cursor, per_page: API_MAX_PER_PAGE } as { + cursor?: string; + per_page?: number; + }, + }); + return unwrapPaginatedResult( + result as + | { data: SentryMonitor[]; error: undefined } + | { data: undefined; error: unknown }, + "Failed to list monitors" + ); + }, MAX_PAGINATION_PAGES * API_MAX_PER_PAGE); + + return allResults as unknown as SentryMonitor[]; +} + +/** + * List cron monitors in an organization with pagination control. + * Returns a single page of results with cursor metadata. + * + * @param orgSlug - Organization slug + * @param options - Pagination options + * @returns Single page of monitors with cursor metadata + */ +export async function listMonitorsPaginated( + orgSlug: string, + options: { cursor?: string; perPage?: number } = {} +): Promise> { + const config = await getOrgSdkConfig(orgSlug); + + const result = await retrieveMonitorsForAnOrganization({ + ...config, + path: { organization_id_or_slug: orgSlug }, + query: { + cursor: options.cursor, + per_page: options.perPage ?? API_MAX_PER_PAGE, + } as { cursor?: string; per_page?: number }, + }); + + return unwrapPaginatedResult( + result, + "Failed to list monitors" + ); +} diff --git a/src/lib/complete.ts b/src/lib/complete.ts index 6bc81bef6..7da59f006 100644 --- a/src/lib/complete.ts +++ b/src/lib/complete.ts @@ -138,6 +138,7 @@ export const ORG_ONLY_COMMANDS = new Set([ "release set-commits", "team list", "repo list", + "monitor list", "trial list", "trial start", ]); diff --git a/src/lib/envelope/checkin-builder.ts b/src/lib/envelope/checkin-builder.ts new file mode 100644 index 000000000..cd9358446 --- /dev/null +++ b/src/lib/envelope/checkin-builder.ts @@ -0,0 +1,133 @@ +/** + * Constructs cron monitor check-in payloads for `sentry monitor run`. + * + * Mirrors the behaviour of the legacy Rust sentry-cli `monitors run` command: + * an `in_progress` check-in is sent when the wrapped command starts, then an + * `ok`/`error` check-in (with duration) is sent on completion. The optional + * `monitor_config` (built from `--schedule` and dependent flags) upserts the + * monitor and is only attached to the opening `in_progress` check-in. + * + * The payloads are `SerializedCheckIn` objects (the snake_case wire format), + * ready to be wrapped via `createCheckInEnvelope` and serialized for posting + * to the ingest endpoint. + */ + +import type { SerializedCheckIn } from "@sentry/core"; +import { ValidationError } from "../errors.js"; + +/** + * CLI flags accepted by `sentry monitor run` that affect the monitor config. + * + * `schedule` is a crontab string (matching the legacy CLI, which only supports + * crontab schedules — not intervals). The remaining flags require `schedule` + * to be set and are forwarded to the monitor's upsert config. + */ +export type CheckInConfigFlags = { + schedule?: string; + "check-in-margin"?: number; + "max-runtime"?: number; + timezone?: string; + "failure-issue-threshold"?: number; + "recovery-threshold"?: number; +}; + +/** Non-undefined `monitor_config` shape of {@link SerializedCheckIn}. */ +type MonitorConfig = NonNullable; + +/** + * Build a monitor upsert config from `--schedule` and its dependent flags. + * + * Returns `undefined` when `--schedule` is not provided (no upsert requested). + * Throws {@link ValidationError} when a dependent flag (`--check-in-margin`, + * `--max-runtime`, `--timezone`, `--failure-issue-threshold`, + * `--recovery-threshold`) is set without `--schedule`, matching the legacy + * CLI's `requires("schedule")` constraint. + */ +export function buildMonitorConfig( + flags: CheckInConfigFlags +): MonitorConfig | undefined { + const dependentFlags: [keyof CheckInConfigFlags, string][] = [ + ["check-in-margin", "--check-in-margin"], + ["max-runtime", "--max-runtime"], + ["timezone", "--timezone"], + ["failure-issue-threshold", "--failure-issue-threshold"], + ["recovery-threshold", "--recovery-threshold"], + ]; + + if (!flags.schedule) { + for (const [key, flagName] of dependentFlags) { + if (flags[key] !== undefined) { + throw new ValidationError( + `${flagName} requires --schedule to be set.`, + "schedule" + ); + } + } + return; + } + + const config: MonitorConfig = { + schedule: { type: "crontab", value: flags.schedule }, + }; + + if (flags["check-in-margin"] !== undefined) { + config.checkin_margin = flags["check-in-margin"]; + } + if (flags["max-runtime"] !== undefined) { + config.max_runtime = flags["max-runtime"]; + } + if (flags.timezone !== undefined) { + config.timezone = flags.timezone; + } + if (flags["failure-issue-threshold"] !== undefined) { + config.failure_issue_threshold = flags["failure-issue-threshold"]; + } + if (flags["recovery-threshold"] !== undefined) { + config.recovery_threshold = flags["recovery-threshold"]; + } + + return config; +} + +/** Options for {@link buildCheckIn}. */ +export type BuildCheckInOptions = { + /** Shared check-in ID linking the open and close check-ins. */ + checkInId: string; + /** The monitor's distinct slug. */ + monitorSlug: string; + /** Check-in status. */ + status: SerializedCheckIn["status"]; + /** Environment name (e.g. "production"). */ + environment?: string; + /** Duration in seconds — only meaningful for `ok`/`error` (close) check-ins. */ + duration?: number; + /** Monitor upsert config — only attached to the opening `in_progress` check-in. */ + monitorConfig?: MonitorConfig; +}; + +/** + * Assemble a {@link SerializedCheckIn} payload. + * + * The caller generates a single `checkInId` (via `uuid4()`) and passes it to + * both the opening and closing check-ins so Sentry links them. `duration` is + * only set for close check-ins; `monitorConfig` only for the open one. + */ +export function buildCheckIn(opts: BuildCheckInOptions): SerializedCheckIn { + const checkIn: SerializedCheckIn = { + check_in_id: opts.checkInId, + monitor_slug: opts.monitorSlug, + status: opts.status, + }; + + if (opts.environment !== undefined) { + checkIn.environment = opts.environment; + } + if (opts.duration !== undefined) { + checkIn.duration = opts.duration; + } + if (opts.monitorConfig !== undefined) { + checkIn.monitor_config = opts.monitorConfig; + } + + return checkIn; +} diff --git a/src/lib/envelope/transport.ts b/src/lib/envelope/transport.ts index d4ec5b4a8..7facfc5d4 100644 --- a/src/lib/envelope/transport.ts +++ b/src/lib/envelope/transport.ts @@ -70,15 +70,23 @@ export function resolveDsn(flags: DsnFlags): string | undefined { * * Auto-detection via project scanning is intentionally deferred — callers * that want it can call the DSN detector before this. + * + * @param flags - DSN flag source (`--dsn`), with `SENTRY_DSN` fallback. + * @param usageHint - Optional command-specific usage example shown in the + * error (e.g. `"sentry monitor run -- "`). Defaults to the + * `event send` example. */ -export function requireDsn(flags: DsnFlags): string { +export function requireDsn( + flags: DsnFlags, + usageHint = "sentry event send --dsn " +): string { const dsn = resolveDsn(flags); if (dsn) { return dsn; } throw new ConfigError( "No DSN found. Provide one via --dsn or set the SENTRY_DSN environment variable.", - "sentry event send --dsn " + usageHint ); } diff --git a/src/types/index.ts b/src/types/index.ts index 2ebff423b..c3defe907 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -116,6 +116,7 @@ export type { IssueStatus, LogsResponse, Mechanism, + MonitorConfig, OsContext, ProductTrial, ProjectKey, @@ -127,6 +128,7 @@ export type { SentryEvent, SentryIssue, SentryLog, + SentryMonitor, SentryOrganization, SentryProject, SentryRelease, @@ -157,11 +159,13 @@ export { IssueEventSchema, IssueViewOutputSchema, LogsResponseSchema, + MonitorConfigSchema, ProductTrialSchema, RegionSchema, RepositoryProviderSchema, SentryIssueSchema, SentryLogSchema, + SentryMonitorSchema, SentryRepositorySchema, SentryTeamSchema, SentryUserSchema, diff --git a/src/types/sentry.ts b/src/types/sentry.ts index 49956ff77..e52e5f8a2 100644 --- a/src/types/sentry.ts +++ b/src/types/sentry.ts @@ -1108,6 +1108,87 @@ export const SentryRepositorySchema = z export type SentryRepository = z.infer; +// Cron Monitor + +/** + * Configuration of a cron monitor's expected schedule and thresholds. + * + * Returned by the `/organizations/{org}/monitors/` endpoint. The `schedule` + * field is either a crontab string (when `schedule_type` is `"crontab"`) or a + * `[value, unit]` tuple (when `"interval"`). Other fields are nullable because + * the API returns `null` for unset thresholds. + */ +export const MonitorConfigSchema = z + .object({ + schedule_type: z + .string() + .optional() + .describe("Schedule type: 'crontab' or 'interval'"), + schedule: z + .union([z.string(), z.array(z.union([z.string(), z.number()]))]) + .optional() + .describe("Crontab string or [value, unit] interval tuple"), + timezone: z + .string() + .nullable() + .optional() + .describe("Schedule timezone (tz database string)"), + checkin_margin: z + .number() + .nullable() + .optional() + .describe("Allowed minutes after the expected check-in time"), + max_runtime: z + .number() + .nullable() + .optional() + .describe("Allowed minutes a check-in may run before timing out"), + failure_issue_threshold: z + .number() + .nullable() + .optional() + .describe("Consecutive failures before an issue is created"), + recovery_threshold: z + .number() + .nullable() + .optional() + .describe("Consecutive successes before an issue is resolved"), + }) + .passthrough(); + +export type MonitorConfig = z.infer; + +/** + * A cron monitor configured in a Sentry organization. + * + * Cron monitors are not modeled by the `@sentry/api` types this project + * re-exports, so this is a hand-written internal schema (Pattern B). Core + * identifiers (id, slug, name, status) are required; richer fields are widened + * to optional and `.passthrough()` preserves any unmodeled API fields. + */ +export const SentryMonitorSchema = z + .object({ + id: z.string().describe("Monitor ID"), + slug: z.string().describe("Monitor slug"), + name: z.string().describe("Monitor name"), + status: z.string().describe("Monitor status (e.g. active, disabled)"), + isMuted: z.boolean().optional().describe("Whether the monitor is muted"), + config: MonitorConfigSchema.optional().describe("Schedule configuration"), + dateCreated: z.string().optional().describe("Creation date (ISO 8601)"), + project: z + .object({ + id: z.string().optional().describe("Project ID"), + slug: z.string().optional().describe("Project slug"), + name: z.string().optional().describe("Project name"), + }) + .passthrough() + .optional() + .describe("Owning project"), + }) + .passthrough(); + +export type SentryMonitor = z.infer; + // Team /** diff --git a/test/commands/monitor/run.test.ts b/test/commands/monitor/run.test.ts new file mode 100644 index 000000000..0e76fc232 --- /dev/null +++ b/test/commands/monitor/run.test.ts @@ -0,0 +1,188 @@ +/** + * Tests for `sentry monitor run` command func(). + * + * Uses a real child process (`node -e ...`) to verify exit-code propagation, + * the `SENTRY_MONITOR_SLUG` env var, and that check-in send failures do not + * abort the wrapped command. The envelope transport is mocked. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { runCommand } from "../../../src/commands/monitor/run.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn +import * as transport from "../../../src/lib/envelope/transport.js"; +import { CliError, ValidationError } from "../../../src/lib/errors.js"; +import { useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("monitor-run-"); + +const SAAS_DSN = "https://abc123@o1.ingest.us.sentry.io/999"; + +const NODE = process.execPath; + +function makeContext() { + return { + ctx: { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + cwd: "/tmp", + }, + }; +} + +describe("monitor runCommand.func()", () => { + let func: Awaited>; + let sendSpy: ReturnType; + + beforeEach(async () => { + func = await runCommand.loader(); + sendSpy = vi + .spyOn(transport, "sendEnvelopeRequest") + .mockResolvedValue(undefined); + }); + + afterEach(() => { + sendSpy.mockRestore(); + }); + + test("sends in_progress then ok check-ins for a successful command", async () => { + const { ctx } = makeContext(); + await func.call( + ctx, + { dsn: SAAS_DSN, environment: "production" }, + "my-job", + NODE, + "-e", + "process.exit(0)" + ); + + expect(sendSpy).toHaveBeenCalledTimes(2); + const openBody = sendSpy.mock.calls[0]?.[1] as string; + const closeBody = sendSpy.mock.calls[1]?.[1] as string; + expect(openBody).toContain('"type":"check_in"'); + expect(openBody).toContain('"status":"in_progress"'); + expect(openBody).toContain('"monitor_slug":"my-job"'); + expect(closeBody).toContain('"status":"ok"'); + // close check-in carries a duration; open does not + expect(closeBody).toContain('"duration"'); + }); + + test("sends error check-in and propagates non-zero exit code", async () => { + const { ctx } = makeContext(); + await expect( + func.call( + ctx, + { dsn: SAAS_DSN, environment: "production" }, + "my-job", + NODE, + "-e", + "process.exit(3)" + ) + ).rejects.toMatchObject({ exitCode: 3 }); + + expect(sendSpy).toHaveBeenCalledTimes(2); + const closeBody = sendSpy.mock.calls[1]?.[1] as string; + expect(closeBody).toContain('"status":"error"'); + }); + + test("passes SENTRY_MONITOR_SLUG to the child environment", async () => { + const { ctx } = makeContext(); + // Child exits 0 only if SENTRY_MONITOR_SLUG matches; else exits 1. + await func.call( + ctx, + { dsn: SAAS_DSN, environment: "production" }, + "env-check-job", + NODE, + "-e", + "process.exit(process.env.SENTRY_MONITOR_SLUG === 'env-check-job' ? 0 : 1)" + ); + // No throw => exit 0 => env var was present and correct. + const closeBody = sendSpy.mock.calls[1]?.[1] as string; + expect(closeBody).toContain('"status":"ok"'); + }); + + test("check-in send failure does not abort the wrapped command", async () => { + sendSpy.mockRejectedValue(new Error("network down")); + const { ctx } = makeContext(); + // Command still runs and succeeds despite check-in failures. + await expect( + func.call( + ctx, + { dsn: SAAS_DSN, environment: "production" }, + "my-job", + NODE, + "-e", + "process.exit(0)" + ) + ).resolves.toBeUndefined(); + expect(sendSpy).toHaveBeenCalledTimes(2); + }); + + test("upsert config from --schedule appears on the open check-in only", async () => { + const { ctx } = makeContext(); + await func.call( + ctx, + { + dsn: SAAS_DSN, + environment: "production", + schedule: "0 * * * *", + "max-runtime": 30, + }, + "scheduled-job", + NODE, + "-e", + "process.exit(0)" + ); + + const openBody = sendSpy.mock.calls[0]?.[1] as string; + const closeBody = sendSpy.mock.calls[1]?.[1] as string; + expect(openBody).toContain('"monitor_config"'); + expect(openBody).toContain('"0 * * * *"'); + expect(closeBody).not.toContain('"monitor_config"'); + }); + + test("missing command throws ValidationError", async () => { + const { ctx } = makeContext(); + await expect( + func.call(ctx, { dsn: SAAS_DSN, environment: "production" }, "my-job") + ).rejects.toBeInstanceOf(ValidationError); + }); + + test("missing monitor slug throws ValidationError", async () => { + const { ctx } = makeContext(); + await expect( + func.call(ctx, { dsn: SAAS_DSN, environment: "production" }) + ).rejects.toBeInstanceOf(ValidationError); + }); + + test("dependent flag without --schedule throws ValidationError", async () => { + const { ctx } = makeContext(); + await expect( + func.call( + ctx, + { dsn: SAAS_DSN, environment: "production", "max-runtime": 30 }, + "my-job", + NODE, + "-e", + "process.exit(0)" + ) + ).rejects.toBeInstanceOf(ValidationError); + // No check-ins sent because validation fails before any send. + expect(sendSpy).not.toHaveBeenCalled(); + }); + + test("non-existent binary throws CliError and sends error check-in", async () => { + const { ctx } = makeContext(); + await expect( + func.call( + ctx, + { dsn: SAAS_DSN, environment: "production" }, + "my-job", + "this-binary-does-not-exist-xyz" + ) + ).rejects.toBeInstanceOf(CliError); + // Both check-ins are still sent; the close one reports an error. + expect(sendSpy).toHaveBeenCalledTimes(2); + const closeBody = sendSpy.mock.calls[1]?.[1] as string; + expect(closeBody).toContain('"status":"error"'); + }); +}); diff --git a/test/lib/api/monitors.test.ts b/test/lib/api/monitors.test.ts new file mode 100644 index 000000000..45b34210b --- /dev/null +++ b/test/lib/api/monitors.test.ts @@ -0,0 +1,80 @@ +/** + * Tests for cron monitor types. + * + * Validates that `SentryMonitorSchema` accepts a representative + * `/organizations/{org}/monitors/` API response, including crontab and + * interval schedule shapes and nullable threshold fields. + */ + +import { describe, expect, test } from "vitest"; +import { SentryMonitorSchema } from "../../../src/types/sentry.js"; + +const crontabMonitor = { + id: "12345", + slug: "nightly-job", + name: "Nightly Job", + status: "active", + isMuted: false, + isUpserting: false, + config: { + schedule_type: "crontab", + schedule: "0 0 * * *", + checkin_margin: null, + max_runtime: 30, + timezone: "UTC", + failure_issue_threshold: null, + recovery_threshold: null, + alert_rule_id: null, + }, + dateCreated: "2024-01-01T00:00:00Z", + project: { + id: "1", + slug: "my-project", + name: "My Project", + platform: "python", + }, +}; + +const intervalMonitor = { + id: "67890", + slug: "hourly-job", + name: "Hourly Job", + status: "disabled", + config: { + schedule_type: "interval", + schedule: [1, "hour"], + checkin_margin: 5, + max_runtime: null, + timezone: null, + }, +}; + +describe("SentryMonitorSchema", () => { + test("accepts a crontab monitor response", () => { + const result = SentryMonitorSchema.safeParse(crontabMonitor); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.slug).toBe("nightly-job"); + expect(result.data.config?.schedule).toBe("0 0 * * *"); + // unknown fields are preserved via passthrough + expect((result.data as Record).isUpserting).toBe(false); + } + }); + + test("accepts an interval monitor with tuple schedule", () => { + const result = SentryMonitorSchema.safeParse(intervalMonitor); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.config?.schedule).toEqual([1, "hour"]); + } + }); + + test("rejects a monitor missing required core identifiers", () => { + const result = SentryMonitorSchema.safeParse({ + slug: "no-id", + name: "No ID", + status: "active", + }); + expect(result.success).toBe(false); + }); +}); diff --git a/test/lib/checkin-builder.property.test.ts b/test/lib/checkin-builder.property.test.ts new file mode 100644 index 000000000..6716aa7c7 --- /dev/null +++ b/test/lib/checkin-builder.property.test.ts @@ -0,0 +1,174 @@ +/** + * Property-Based Tests for cron monitor check-in builders. + * + * Verifies invariants of `buildMonitorConfig` and `buildCheckIn` that should + * hold for any input: dependent-flag validation, schedule round-tripping, + * and which fields appear on open vs. close check-ins. + */ + +import { + constantFrom, + assert as fcAssert, + integer, + option, + property, + record, + string, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { + buildCheckIn, + buildMonitorConfig, + type CheckInConfigFlags, +} from "../../src/lib/envelope/checkin-builder.js"; +import { ValidationError } from "../../src/lib/errors.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +const crontabArb = constantFrom( + "0 * * * *", + "*/5 * * * *", + "0 0 * * *", + "30 2 * * 1" +); + +const positiveIntArb = integer({ min: 1, max: 1440 }); + +describe("property: buildMonitorConfig", () => { + test("returns undefined when --schedule is absent and no dependent flags", () => { + fcAssert( + property(option(string(), { nil: undefined }), (timezone) => { + // Only timezone might be set; if it is, expect a throw, else undefined + const flags: CheckInConfigFlags = {}; + if (timezone !== undefined) { + flags.timezone = timezone; + expect(() => buildMonitorConfig(flags)).toThrow(ValidationError); + } else { + expect(buildMonitorConfig(flags)).toBeUndefined(); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("throws when any dependent flag is set without --schedule", () => { + const dependentFlagSetters: Array<(f: CheckInConfigFlags) => void> = [ + (f) => { + f["check-in-margin"] = 5; + }, + (f) => { + f["max-runtime"] = 30; + }, + (f) => { + f.timezone = "UTC"; + }, + (f) => { + f["failure-issue-threshold"] = 2; + }, + (f) => { + f["recovery-threshold"] = 3; + }, + ]; + for (const setter of dependentFlagSetters) { + const flags: CheckInConfigFlags = {}; + setter(flags); + expect(() => buildMonitorConfig(flags)).toThrow(ValidationError); + } + }); + + const thresholdsArb = record({ + margin: option(positiveIntArb, { nil: undefined }), + maxRuntime: option(positiveIntArb, { nil: undefined }), + failure: option(positiveIntArb, { nil: undefined }), + recovery: option(positiveIntArb, { nil: undefined }), + }); + + test("round-trips schedule and thresholds when --schedule is set", () => { + fcAssert( + property(crontabArb, thresholdsArb, (schedule, thresholds) => { + const flags: CheckInConfigFlags = { schedule }; + if (thresholds.margin !== undefined) { + flags["check-in-margin"] = thresholds.margin; + } + if (thresholds.maxRuntime !== undefined) { + flags["max-runtime"] = thresholds.maxRuntime; + } + if (thresholds.failure !== undefined) { + flags["failure-issue-threshold"] = thresholds.failure; + } + if (thresholds.recovery !== undefined) { + flags["recovery-threshold"] = thresholds.recovery; + } + + const config = buildMonitorConfig(flags); + expect(config).toBeDefined(); + expect(config?.schedule).toEqual({ type: "crontab", value: schedule }); + expect(config?.checkin_margin).toBe(thresholds.margin); + expect(config?.max_runtime).toBe(thresholds.maxRuntime); + expect(config?.failure_issue_threshold).toBe(thresholds.failure); + expect(config?.recovery_threshold).toBe(thresholds.recovery); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: buildCheckIn", () => { + const slugArb = string({ minLength: 1, maxLength: 40 }); + const statusArb = constantFrom("in_progress", "ok", "error" as const); + + test("always carries the provided check_in_id, slug, and status", () => { + fcAssert( + property( + slugArb, + slugArb, + statusArb, + (checkInId, monitorSlug, status) => { + const checkIn = buildCheckIn({ + checkInId, + monitorSlug, + status: status as "in_progress" | "ok" | "error", + }); + expect(checkIn.check_in_id).toBe(checkInId); + expect(checkIn.monitor_slug).toBe(monitorSlug); + expect(checkIn.status).toBe(status); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("duration is only present when explicitly provided", () => { + const open = buildCheckIn({ + checkInId: "a", + monitorSlug: "job", + status: "in_progress", + }); + expect(open.duration).toBeUndefined(); + + const close = buildCheckIn({ + checkInId: "a", + monitorSlug: "job", + status: "ok", + duration: 12.5, + }); + expect(close.duration).toBe(12.5); + }); + + test("monitor_config is only present when explicitly provided", () => { + const withConfig = buildCheckIn({ + checkInId: "a", + monitorSlug: "job", + status: "in_progress", + monitorConfig: { schedule: { type: "crontab", value: "0 * * * *" } }, + }); + expect(withConfig.monitor_config).toBeDefined(); + + const withoutConfig = buildCheckIn({ + checkInId: "a", + monitorSlug: "job", + status: "ok", + duration: 1, + }); + expect(withoutConfig.monitor_config).toBeUndefined(); + }); +}); diff --git a/test/lib/completions.property.test.ts b/test/lib/completions.property.test.ts index 17dd14286..1e34785c8 100644 --- a/test/lib/completions.property.test.ts +++ b/test/lib/completions.property.test.ts @@ -195,6 +195,7 @@ describe("proposeCompletions: Stricli integration", () => { "span", "log", "local", + "monitor", ]); test("subcommands match extractCommandTree for each group without defaultCommand", async () => {