Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ const { URL } = require('node:url')

const {
EmailAlertChannel,
SlackAppAlertChannel,
WebhookAlertChannel,
} = require('checkly/constructs')

Expand All @@ -25,10 +24,13 @@ const emailChannel = new EmailAlertChannel('email-channel-1', {
...sendDefaults,
})

const slackChannel = new SlackAppAlertChannel('slack-channel-1', {
slackChannels: ['#ops'],
...sendDefaults,
})
// A Slack channel needs the Checkly Slack app connected to your account first
// (https://www.checklyhq.com/docs/integrations/alerts/slack/). Once it is, import
// SlackAppAlertChannel from 'checkly/constructs' and uncomment:
// const slackChannel = new SlackAppAlertChannel('slack-channel-1', {
// slackChannels: ['#ops'],
// ...sendDefaults,
// })

const webhookChannel = new WebhookAlertChannel('webhook-channel-1', {
name: 'Pushover webhook',
Expand All @@ -49,6 +51,5 @@ const webhookChannel = new WebhookAlertChannel('webhook-channel-1', {

module.exports = {
emailChannel,
slackChannel,
webhookChannel,
}
12 changes: 7 additions & 5 deletions examples/advanced-project/src/__checks__/utils/alert-channels.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { URL } from 'node:url'
import {
EmailAlertChannel,
SlackAppAlertChannel,
WebhookAlertChannel
} from 'checkly/constructs'

Expand All @@ -25,10 +24,13 @@ export const emailChannel = new EmailAlertChannel('email-channel-1', {
...sendDefaults
})

export const slackChannel = new SlackAppAlertChannel('slack-channel-1', {
slackChannels: ['#ops'],
...sendDefaults
})
// A Slack channel needs the Checkly Slack app connected to your account first
// (https://www.checklyhq.com/docs/integrations/alerts/slack/). Once it is, import
// SlackAppAlertChannel from 'checkly/constructs' and uncomment:
// export const slackChannel = new SlackAppAlertChannel('slack-channel-1', {
// slackChannels: ['#ops'],
// ...sendDefaults
// })

export const webhookChannel = new WebhookAlertChannel('webhook-channel-1', {
name: 'Pushover webhook',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ All available alerts are described in the [Checkly docs](https://www.checklyhq.c

*Important*: Don't make up email addresses, phone numbers, Slack channel names or similar static values. Scan the project to discover a valid configuration or ask what the values should be.

For new Slack notifications, prefer `SlackAppAlertChannel`. It uses the Checkly Slack App and only needs Slack `#channel` names or `@user` handles in `slackChannels`.
For new Slack notifications, prefer `SlackAppAlertChannel`. It uses the Checkly Slack App and only needs Slack `#channel` names or `@user` handles in `slackChannels`. It needs the Checkly Slack App connected to the Checkly account: without it, `npx checkly validate`, `test` and `deploy` fail with "Checkly Slack app not connected". If you can't confirm the app is connected, ask before adding one.

## Email Alert Channel

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/authCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export abstract class AuthCommand extends BaseCommand {
config.pinEnvironment()
// Constructs validate against account-specific limits and have no access to
// the command instance.
Session.accountFeatures = this.#account?.features ?? []
Session.account = this.#account
}

protected async validateProject (
Expand Down
22 changes: 15 additions & 7 deletions packages/cli/src/constructs/__tests__/account-features.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ApiCheck, TcpMonitor, TcpRequest, UrlMonitor } from '../index.js'
import { Diagnostics } from '../diagnostics.js'
import { Project } from '../project.js'
import { Session } from '../session.js'
import type { Account } from '../../rest/accounts.js'

const EXTENDED_RESPONSE_TIME_LIMITS = 'EXTENDED_RESPONSE_TIME_LIMITS'

Expand All @@ -19,6 +20,13 @@ const newProject = () => {
})
}

const withFeatures = (features: string[]): Account => ({
id: 'acc-1',
name: 'Test Account',
runtimeId: '2025.04',
features,
})

const validate = async (construct: { validate: (diagnostics: Diagnostics) => Promise<void> }) => {
const diagnostics = new Diagnostics()
await construct.validate(diagnostics)
Expand All @@ -27,12 +35,12 @@ const validate = async (construct: { validate: (diagnostics: Diagnostics) => Pro

describe('account-aware response time limits', () => {
afterEach(() => {
Session.accountFeatures = []
Session.account = undefined
})

it('rejects an extended response time without the feature', async () => {
newProject()
Session.accountFeatures = []
Session.account = withFeatures([])

const diagnostics = await validate(new TcpMonitor('tcp-standard', {
name: 'Test Check',
Expand All @@ -50,7 +58,7 @@ describe('account-aware response time limits', () => {

it('accepts an extended response time with the feature', async () => {
newProject()
Session.accountFeatures = [EXTENDED_RESPONSE_TIME_LIMITS]
Session.account = withFeatures([EXTENDED_RESPONSE_TIME_LIMITS])

const diagnostics = await validate(new TcpMonitor('tcp-extended', {
name: 'Test Check',
Expand All @@ -64,7 +72,7 @@ describe('account-aware response time limits', () => {

it('defers values above 45s to the API with the feature', async () => {
newProject()
Session.accountFeatures = [EXTENDED_RESPONSE_TIME_LIMITS]
Session.account = withFeatures([EXTENDED_RESPONSE_TIME_LIMITS])

const diagnostics = await validate(new TcpMonitor('tcp-over-extended', {
name: 'Test Check',
Expand All @@ -77,7 +85,7 @@ describe('account-aware response time limits', () => {

it('keeps the degraded <= max cross-check with the feature', async () => {
newProject()
Session.accountFeatures = [EXTENDED_RESPONSE_TIME_LIMITS]
Session.account = withFeatures([EXTENDED_RESPONSE_TIME_LIMITS])

const diagnostics = await validate(new TcpMonitor('tcp-degraded-above-max', {
name: 'Test Check',
Expand All @@ -96,7 +104,7 @@ describe('account-aware response time limits', () => {

it('raises the API check limit above its 30s standard', async () => {
newProject()
Session.accountFeatures = [EXTENDED_RESPONSE_TIME_LIMITS]
Session.account = withFeatures([EXTENDED_RESPONSE_TIME_LIMITS])

const diagnostics = await validate(new ApiCheck('api-extended', {
name: 'Test Check',
Expand All @@ -109,7 +117,7 @@ describe('account-aware response time limits', () => {

it('raises the URL monitor limit above its 30s standard', async () => {
newProject()
Session.accountFeatures = [EXTENDED_RESPONSE_TIME_LIMITS]
Session.account = withFeatures([EXTENDED_RESPONSE_TIME_LIMITS])

const diagnostics = await validate(new UrlMonitor('url-extended', {
name: 'Test Check',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, it, expect, afterEach, beforeEach } from 'vitest'

import { SlackAppAlertChannel } from '../index.js'
import { Diagnostics } from '../diagnostics.js'
import { Project } from '../project.js'
import { Session } from '../session.js'
import type { Account, AccountIntegration } from '../../rest/accounts.js'

const withIntegrations = (integrations?: AccountIntegration[]): Account => ({
id: 'acc-1',
name: 'Test Account',
runtimeId: '2025.04',
integrations,
})

const validate = async (slackChannels: string[]) => {
const diagnostics = new Diagnostics()
await new SlackAppAlertChannel('ops-slack', { slackChannels }).validate(diagnostics)
return diagnostics
}

describe('SlackAppAlertChannel', () => {
beforeEach(() => {
Session.project = new Project('project-id', {
name: 'Test Project',
repoUrl: 'https://github.com/checkly/checkly-cli',
})
})

afterEach(() => {
Session.account = undefined
})

it('refuses the channel when the account has no Checkly Slack app', async () => {
Session.account = withIntegrations([{ type: 'SLACK', connected: false }])

const diagnostics = await validate(['#ops'])

expect(diagnostics.isFatal()).toBe(true)
expect(diagnostics.observations).toEqual([
expect.objectContaining({
title: 'Checkly Slack app not connected',
message: expect.stringContaining(
`SlackAppAlertChannel 'ops-slack' needs the Checkly Slack app, which is not connected to this account, so its alerts cannot be delivered.`,
),
}),
])
expect(diagnostics.observations[0].message).toContain(
'https://app.checklyhq.com/accounts/acc-1/settings/account/integrations',
)
})

it('accepts the channel when the Checkly Slack app is connected', async () => {
Session.account = withIntegrations([{ type: 'SLACK', connected: true }])

const diagnostics = await validate(['#ops'])

expect(diagnostics.isFatal()).toBe(false)
})

it('skips the check when the API does not report integrations', async () => {
Session.account = withIntegrations(undefined)

const diagnostics = await validate(['#ops'])

expect(diagnostics.isFatal()).toBe(false)
})

it('skips the check when the API does not report the Slack app', async () => {
Session.account = withIntegrations([{ type: 'OTHER', connected: false }])

const diagnostics = await validate(['#ops'])

expect(diagnostics.isFatal()).toBe(false)
})

it('accepts a channel without Slack destinations, which needs no Slack app', async () => {
Session.account = withIntegrations([{ type: 'SLACK', connected: false }])

const diagnostics = await validate([])

expect(diagnostics.isFatal()).toBe(false)
})
})
2 changes: 1 addition & 1 deletion packages/cli/src/constructs/internal/account-features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const EXTENDED_RESPONSE_TIME_LIMITS = 'EXTENDED_RESPONSE_TIME_LIMITS'
* the API does not report features at all (older or self-hosted deployments).
*/
export function responseTimeLimits (standard: number): ResponseTimeLimits {
const limit = Session.accountFeatures.includes(EXTENDED_RESPONSE_TIME_LIMITS)
const limit = Session.account?.features?.includes(EXTENDED_RESPONSE_TIME_LIMITS)
? Number.MAX_SAFE_INTEGER
: standard

Expand Down
9 changes: 7 additions & 2 deletions packages/cli/src/constructs/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { type EngineDetectionResult } from '../services/engine-detector.js'
import { Parser } from '../services/check-parser/parser.js'
import { Construct } from './construct.js'
import { PrivateLocationApi } from '../rest/private-locations.js'
import type { Account } from '../rest/accounts.js'
import {
FileLoader,
JitiFileLoader,
Expand Down Expand Up @@ -89,7 +90,11 @@ export class Session {
static verifyRuntimeDependencies = true
static loadingChecklyConfigFile: boolean
static checklyConfigFileConstructs?: Construct[]
static accountFeatures: string[] = []
/**
* The authenticated account, for constructs that validate against it.
* Undefined when the command does not authenticate.
*/
static account?: Account
static privateLocations: PrivateLocationApi[]
static parsers = new Map<string, Parser>()
static playwrightProjectBundler?: PlaywrightProjectBundler
Expand Down Expand Up @@ -123,7 +128,7 @@ export class Session {
this.verifyRuntimeDependencies = true
this.loadingChecklyConfigFile = false
this.checklyConfigFileConstructs = undefined
this.accountFeatures = []
this.account = undefined
this.privateLocations = []
this.parsers = new Map<string, Parser>()
this.playwrightProjectBundler = undefined
Expand Down
30 changes: 30 additions & 0 deletions packages/cli/src/constructs/slack-app-alert-channel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { AlertChannel, AlertChannelProps } from './alert-channel.js'
import { Diagnostics, ErrorDiagnostic } from './diagnostics.js'
import { Session } from './session.js'

const SLACK_INTEGRATION = 'SLACK'

export interface SlackAppAlertChannelProps extends AlertChannelProps {
/**
* List of Slack #channels or @handles to notify.
Expand Down Expand Up @@ -38,6 +41,33 @@ export class SlackAppAlertChannel extends AlertChannel {
return `SlackAppAlertChannel:${this.logicalId}`
}

/**
* Refuses the channel when the account has no Checkly Slack app: its alerts
* cannot be delivered, whether the channel is new or was deployed before the
* app was disconnected. Skipped when the API does not report the Slack app.
*/
async validate (diagnostics: Diagnostics): Promise<void> {
await super.validate(diagnostics)

const slack = Session.account?.integrations?.find(({ type }) => type === SLACK_INTEGRATION)
if (slack === undefined || slack.connected || !this.slackChannels?.length) {
return
}
const error = new Error(
`SlackAppAlertChannel '${this.logicalId}' needs the Checkly Slack app, which is not connected to this account,`
+ ` so its alerts cannot be delivered.`,
)
diagnostics.add(new ErrorDiagnostic({
title: 'Checkly Slack app not connected',
message:
`${error.message}\n\n`
+ `Hint: Connect the Checkly Slack app at`
+ ` https://app.checklyhq.com/accounts/${Session.account?.id}/settings/account/integrations,`
+ ` or remove the channel.`,
error,
}))
}

synthesize () {
return {
...super.synthesize(),
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/rest/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@ export interface Account {
* in which case standard limits apply.
*/
features?: string[]
/**
* Every integration type the API reports, each marked connected or not, e.g.
* `SLACK` for the Checkly Slack app. A type missing from the list is one the
* API does not report. Absent when the API predates it.
*/
integrations?: AccountIntegration[]
}

export interface AccountIntegration {
type: string
connected: boolean
}

class Accounts {
Expand Down
Loading