diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 59f8713b181e..4190f12927f3 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -122,6 +122,8 @@ export { httpServerIntegration } from './integrations/httpServer'; export { fetchIntegration } from './integrations/fetch'; export type { FetchIntegrationOptions } from '@sentry/core'; export { spotlightIntegration } from './integrations/spotlight'; +export { cronTriggersIntegration } from './integrations/cronTriggers'; +export type { CronTriggerMonitorSettings, CronTriggersOptions } from './integrations/cronTriggers'; export { openTelemetryIntegration, getOtlpTracesEndpoint, diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts index b0da3a279a39..8285830b73c1 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts @@ -12,10 +12,12 @@ import { SENTRY_ORIGIN, } from '@sentry/conventions/attributes'; import { FUNCTION } from '@sentry/conventions/op'; -import { captureException, hasSpanStreamingEnabled, startSpan, withIsolationScope } from '@sentry/core'; +import { captureException, debug, hasSpanStreamingEnabled, startSpan, withIsolationScope } from '@sentry/core'; import type { CloudflareOptions } from '../../client'; +import { DEBUG_BUILD } from '../../debug-build'; import { flushAndDispose } from '../../flush'; import { ensureInstrumented } from '../../instrument'; +import type { CronTriggersIntegration } from '../../integrations/cronTriggers'; import { getFinalOptions } from '../../options'; import { addCloudResourceContext } from '../../scope-utils'; import { init } from '../../sdk'; @@ -58,9 +60,29 @@ function wrapScheduledHandler( }, }, async () => { + let finishCheckIn: ReturnType; try { - return await fn(); + finishCheckIn = client + ?.getIntegrationByName('CronTriggers') + ?.startCheckIn(controller.cron); } catch (e) { + DEBUG_BUILD && debug.warn('Failed to send the in_progress cron check-in', e); + } + + const finish = (status: 'ok' | 'error'): void => { + try { + finishCheckIn?.(status); + } catch (e) { + DEBUG_BUILD && debug.warn(`Failed to send the ${status} cron check-in`, e); + } + }; + + try { + const result = await fn(); + finish('ok'); + return result; + } catch (e) { + finish('error'); captureException(e, { mechanism: { handled: false, type: 'auto.faas.cloudflare.scheduled' } }); throw e; } finally { diff --git a/packages/cloudflare/src/integrations/cronTriggers.ts b/packages/cloudflare/src/integrations/cronTriggers.ts new file mode 100644 index 000000000000..460e56166bf1 --- /dev/null +++ b/packages/cloudflare/src/integrations/cronTriggers.ts @@ -0,0 +1,175 @@ +import type { IntegrationFn, MonitorConfig } from '@sentry/core'; +import { captureCheckIn, debug, defineIntegration, timestampInSeconds } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; + +const INTEGRATION_NAME = 'CronTriggers' as const; + +/** + * The monitor slug and settings for a Cron Trigger, see `cronTriggersIntegration`. + */ +export type CronTriggerMonitorSettings = { slug: string } & Omit; + +export interface CronTriggersOptions { + /** + * Chooses the monitor slug for a cron expression. It can also return an object with the `slug` + * and other monitor settings, such as `checkinMargin` or `maxRuntime`. Returning `undefined` + * sends no check-ins for that trigger, and so does a function that throws. + */ + slug?: (cron: string) => string | CronTriggerMonitorSettings | undefined; +} + +/** @internal Used by the scheduled handler instrumentation. */ +export interface CronTriggersIntegration { + name: string; + startCheckIn(cron: string): ((status: 'ok' | 'error') => void) | undefined; +} + +const SLUG_TOKENS: Record = { ' ': '-', '*': 'x', ',': '_', '-': 'to', '/': 'by' }; + +/** + * Derives a monitor slug from a cron expression, e.g. `30 9 * * MON-FRI` -> `cron-30-9-x-x-montofri`. + * + * A hash of the expression is appended when it has any other characters or the slug would be + * longer than 50 characters, so different expressions don't share a slug. + */ +function cronToMonitorSlug(cron: string): string { + const expression = cron.trim().toLowerCase().split(/\s+/).join(' '); + const slug = `cron-${expression.replace(/[ *,\-/]/g, char => SLUG_TOKENS[char] as string)}`; + if (/^[a-z0-9_-]{1,50}$/.test(slug)) { + return slug; + } + + // A polynomial string hash modulo 2^31 - 1, at most 6 characters in base 36. + let hash = 0; + for (let i = 0; i < expression.length; i++) { + hash = (hash * 31 + expression.charCodeAt(i)) % 2147483647; + } + return `${slug.replace(/[^a-z0-9_-]+/g, '-').slice(0, 43)}-${hash.toString(36)}`; +} + +const WEEKDAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']; + +// Cloudflare numbers weekdays from 1 = Sunday to 7 = Saturday. Returns -1 for anything else. +function weekdayIndex(value: string): number { + return /^[1-7]$/.test(value) ? Number(value) - 1 : WEEKDAYS.indexOf(value.toUpperCase()); +} + +function convertWeekdayItem(item: string): string | undefined { + const match = item.match(/^(\*|\w+)(?:-(\w+))?(?:\/(\d+))?$/); + if (!match) { + return undefined; + } + const [, from, to, step] = match; + if (step && !(Number(step) > 0)) { + return undefined; + } + + // `*` and `*/n` give the same days in both numberings, and keep their `*` meaning for Sentry. + if (from === '*') { + return to ? undefined : item; + } + + const start = weekdayIndex(from as string); + // A step without an end runs to Saturday, so it is written as a range. + const end = to ? weekdayIndex(to) : step ? 6 : start; + if (start < 0 || end < start) { + return undefined; + } + + // A step over a single day would run on to Sunday in Sentry's cron parser, so it is left out. + if (start === end) { + return WEEKDAYS[start]; + } + const days = `${WEEKDAYS[start]}-${WEEKDAYS[end]}`; + return step ? `${days}/${step}` : days; +} + +/** + * Converts a Cloudflare cron expression into a crontab Sentry accepts, which numbers weekdays from + * 0 = Sunday. Returns `undefined` if the weekday field can't be converted. + */ +function cloudflareCronToCrontab(cron: string): string | undefined { + const fields = cron.trim().split(/\s+/); + // Sentry rejects `W` and `?` in the day of month. + if (fields.length !== 5 || /[w?]/i.test(fields[2] as string)) { + return undefined; + } + + const weekdays = (fields[4] as string).split(',').map(convertWeekdayItem); + return weekdays.includes(undefined) ? undefined : [...fields.slice(0, 4), weekdays.join(',')].join(' '); +} + +const _cronTriggersIntegration = ((options: CronTriggersOptions = {}): CronTriggersIntegration => { + return { + name: INTEGRATION_NAME, + startCheckIn(cron) { + // Manual runs, e.g. through `wrangler dev --test-scheduled`, can have no cron expression. + if (!cron) { + return undefined; + } + + let monitor: string | CronTriggerMonitorSettings | undefined; + try { + monitor = options.slug ? options.slug(cron) : cronToMonitorSlug(cron); + } catch (e) { + DEBUG_BUILD && debug.warn(`[Cron Triggers] \`slug\` threw for "${cron}", sending no check-ins:`, e); + return undefined; + } + + if (!monitor) { + return undefined; + } + + const { slug: monitorSlug, ...monitorSettings } = typeof monitor === 'string' ? { slug: monitor } : monitor; + if (!monitorSlug) { + return undefined; + } + + const crontab = cloudflareCronToCrontab(cron); + if (!crontab) { + DEBUG_BUILD && + debug.warn(`[Cron Triggers] Can't convert "${cron}" to a Sentry schedule, sending check-ins without one.`); + } + + // Check-ins are captured directly rather than through `withMonitor`, which would fork the + // isolation scope and lose the invocation state attached to it. + const checkInId = captureCheckIn( + { monitorSlug, status: 'in_progress' }, + crontab ? { ...monitorSettings, schedule: { type: 'crontab', value: crontab } } : undefined, + ); + const startTime = timestampInSeconds(); + + return status => { + captureCheckIn({ monitorSlug, status, checkInId, duration: timestampInSeconds() - startTime }); + }; + }, + }; +}) satisfies IntegrationFn; + +/** + * Sends cron check-ins for every Cron Trigger run of the `scheduled` handler, with the trigger's + * schedule, so Sentry creates the monitor on the first run. + * + * Cron Triggers have no names, so map each cron expression to a slug. Without `slug`, the slug is + * derived from the expression (`30 9 * * MON-FRI` becomes `cron-30-9-x-x-montofri`) and changes with it. + * + * @example + * ```ts + * const jobs = { + * '30 9 * * MON-FRI': { slug: 'daily-report', run: dailyReport }, + * }; + * + * export default Sentry.withSentry( + * (env) => ({ + * dsn: env.SENTRY_DSN, + * integrations: [Sentry.cronTriggersIntegration({ slug: (cron) => jobs[cron]?.slug })], + * }), + * { + * async scheduled(controller, env) { + * await jobs[controller.cron]?.run(env); + * }, + * }, + * ); + * ``` + */ +export const cronTriggersIntegration = defineIntegration(_cronTriggersIntegration); diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts index 46090684daea..482df9f6c868 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts @@ -2,10 +2,15 @@ // Although this is not ideal, this is the best we can do until we have a better way to test cloudflare workers. import type { ExecutionContext, ScheduledController } from '@cloudflare/workers-types'; -import type { Event } from '@sentry/core'; +import type { Event, Integration } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { beforeEach, describe, expect, onTestFinished, test, vi } from 'vitest'; import { CloudflareClient } from '../../../src/client'; +import { + instrumentWorkerEntrypoint, + type WorkerEntrypointConstructor, +} from '../../../src/instrumentations/instrumentWorkerEntrypoint'; +import { cronTriggersIntegration } from '../../../src/integrations/cronTriggers'; import { withSentry } from '../../../src/withSentry'; import { resetSdk } from '../../testUtils'; @@ -302,6 +307,178 @@ describe('instrumentScheduled', () => { }); }); + describe('cron monitoring', () => { + function controllerFor(cron: string): ScheduledController { + return { scheduledTime: 123, cron, noRetry: vi.fn() }; + } + + async function runScheduled( + integrations: Integration[], + scheduled: ExportedHandler['scheduled'] = () => {}, + ): Promise { + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN, integrations }), { scheduled }); + await wrappedHandler.scheduled?.(controllerFor('30 9 * * 1-5'), MOCK_ENV, createMockExecutionContext()); + } + + test('sends no check-ins without the integration', async () => { + const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn'); + + await runScheduled([]); + + expect(captureCheckInSpy).not.toHaveBeenCalled(); + }); + + test('sends check-ins with the cron schedule with the integration', async () => { + const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn'); + + await runScheduled([cronTriggersIntegration()]); + + expect(captureCheckInSpy).toHaveBeenCalledTimes(2); + expect(captureCheckInSpy).toHaveBeenNthCalledWith( + 1, + { monitorSlug: 'cron-30-9-x-x-1to5', status: 'in_progress' }, + { schedule: { type: 'crontab', value: '30 9 * * SUN-THU' } }, + ); + expect(captureCheckInSpy).toHaveBeenNthCalledWith(2, { + monitorSlug: 'cron-30-9-x-x-1to5', + status: 'ok', + checkInId: expect.any(String), + duration: expect.any(Number), + }); + }); + + test('marks the check-in as failed when the handler throws', async () => { + const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn'); + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException'); + const error = new Error('test'); + + await expect( + runScheduled([cronTriggersIntegration()], () => { + throw error; + }), + ).rejects.toThrow('test'); + + expect(captureCheckInSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ status: 'error', duration: expect.any(Number) }), + ); + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { handled: false, type: 'auto.faas.cloudflare.scheduled' }, + }); + }); + + test('marks the check-in as failed when the handler rejects', async () => { + const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn'); + + await expect( + runScheduled([cronTriggersIntegration()], () => Promise.reject(new Error('rejected'))), + ).rejects.toThrow('rejected'); + + expect(captureCheckInSpy).toHaveBeenCalledTimes(2); + expect(captureCheckInSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ monitorSlug: 'cron-30-9-x-x-1to5', status: 'error' }), + ); + }); + + test('runs the handler without check-ins when the in_progress check-in throws', async () => { + const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn').mockImplementation(() => { + throw new Error('check-in error'); + }); + onTestFinished(() => captureCheckInSpy.mockRestore()); + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException'); + const scheduled = vi.fn().mockResolvedValue('result'); + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN, integrations: [cronTriggersIntegration()] }), { + scheduled, + } as ExportedHandler); + + const result = await wrappedHandler.scheduled?.( + controllerFor('30 9 * * 1-5'), + MOCK_ENV, + createMockExecutionContext(), + ); + + expect(result).toBe('result'); + expect(scheduled).toHaveBeenCalledTimes(1); + expect(captureCheckInSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + test('returns the handler result when the ok check-in throws', async () => { + const captureCheckInSpy = vi + .spyOn(SentryCore, 'captureCheckIn') + .mockReturnValueOnce('check-in-id') + .mockImplementationOnce(() => { + throw new Error('check-in error'); + }); + onTestFinished(() => captureCheckInSpy.mockRestore()); + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException'); + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN, integrations: [cronTriggersIntegration()] }), { + scheduled: () => Promise.resolve('result'), + } as unknown as ExportedHandler); + + const result = await wrappedHandler.scheduled?.( + controllerFor('30 9 * * 1-5'), + MOCK_ENV, + createMockExecutionContext(), + ); + + expect(result).toBe('result'); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + test('sends check-ins for the scheduled method of a WorkerEntrypoint', async () => { + const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn'); + const TestEntrypoint = class { + scheduled() {} + }; + const instrumented = instrumentWorkerEntrypoint( + () => ({ dsn: MOCK_ENV.SENTRY_DSN, integrations: [cronTriggersIntegration()] }), + TestEntrypoint as unknown as WorkerEntrypointConstructor, + ); + const entrypoint = Reflect.construct(instrumented, [createMockExecutionContext(), MOCK_ENV]); + + await entrypoint.scheduled(controllerFor('30 9 * * 1-5')); + + expect(captureCheckInSpy).toHaveBeenCalledTimes(2); + expect(captureCheckInSpy).toHaveBeenNthCalledWith( + 1, + { monitorSlug: 'cron-30-9-x-x-1to5', status: 'in_progress' }, + { schedule: { type: 'crontab', value: '30 9 * * SUN-THU' } }, + ); + expect(captureCheckInSpy).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ok' })); + }); + + test('flushes both check-ins before the invocation ends', async () => { + const sentItemTypes: string[] = []; + const handler = { + scheduled() {}, + } satisfies ExportedHandler; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + cacheClient: false, + integrations: [cronTriggersIntegration()], + transport: () => ({ + send: async envelope => { + sentItemTypes.push(...envelope[1].map(([itemHeader]) => itemHeader.type)); + return {}; + }, + flush: async () => true, + }), + }), + handler, + ); + + const waits: Promise[] = []; + await wrappedHandler.scheduled?.(controllerFor('30 9 * * 1-5'), MOCK_ENV, { + waitUntil: vi.fn(promise => waits.push(promise)), + passThroughOnException: vi.fn(), + } as unknown as ExecutionContext); + await Promise.all(waits); + + expect(sentItemTypes.filter(type => type === 'check_in')).toHaveLength(2); + }); + }); + test('flush must be called when all waitUntil are done', async () => { const flush = vi.spyOn(SentryCore.Client.prototype, 'flush'); vi.useFakeTimers(); diff --git a/packages/cloudflare/test/integrations/cronTriggers.test.ts b/packages/cloudflare/test/integrations/cronTriggers.test.ts new file mode 100644 index 000000000000..a8d8b6586066 --- /dev/null +++ b/packages/cloudflare/test/integrations/cronTriggers.test.ts @@ -0,0 +1,161 @@ +import * as SentryCore from '@sentry/core'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { CronTriggersIntegration, CronTriggersOptions } from '../../src/integrations/cronTriggers'; +import { cronTriggersIntegration } from '../../src/integrations/cronTriggers'; + +function startCheckIn( + cron: string, + options?: CronTriggersOptions, +): ReturnType { + return (cronTriggersIntegration(options) as unknown as CronTriggersIntegration).startCheckIn(cron); +} + +function getInProgressCheckIn(cron: string, options?: CronTriggersOptions): unknown[] | undefined { + const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn').mockReturnValue('check-in-id'); + startCheckIn(cron, options); + const call = captureCheckInSpy.mock.calls[0]; + captureCheckInSpy.mockRestore(); + return call; +} + +function getSlug(cron: string): string { + return (getInProgressCheckIn(cron)?.[0] as { monitorSlug: string }).monitorSlug; +} + +describe('cronTriggersIntegration', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + test('captures in_progress and then the final status with a duration', () => { + const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn').mockReturnValue('check-in-id'); + + const finish = startCheckIn('30 9 * * 1-5'); + finish?.('error'); + + expect(captureCheckInSpy).toHaveBeenCalledTimes(2); + expect(captureCheckInSpy).toHaveBeenNthCalledWith( + 1, + { monitorSlug: 'cron-30-9-x-x-1to5', status: 'in_progress' }, + { schedule: { type: 'crontab', value: '30 9 * * SUN-THU' } }, + ); + expect(captureCheckInSpy).toHaveBeenNthCalledWith(2, { + monitorSlug: 'cron-30-9-x-x-1to5', + status: 'error', + checkInId: 'check-in-id', + duration: expect.any(Number), + }); + }); + + test('uses the slug returned by the slug function', () => { + const slug = vi.fn().mockReturnValue('weekday-report'); + + expect(getInProgressCheckIn('30 9 * * MON-FRI', { slug })).toEqual([ + { monitorSlug: 'weekday-report', status: 'in_progress' }, + { schedule: { type: 'crontab', value: '30 9 * * MON-FRI' } }, + ]); + expect(slug).toHaveBeenCalledWith('30 9 * * MON-FRI'); + }); + + test('sends the monitor settings returned by the slug function', () => { + const [, monitorConfig] = getInProgressCheckIn('0 0 * * *', { + slug: () => ({ slug: 'nightly', checkinMargin: 5, maxRuntime: 30 }), + }) as unknown[]; + + expect(monitorConfig).toEqual({ + schedule: { type: 'crontab', value: '0 0 * * *' }, + checkinMargin: 5, + maxRuntime: 30, + }); + }); + + test('sends no check-ins when the slug function returns undefined', () => { + expect(getInProgressCheckIn('0 0 * * *', { slug: () => undefined })).toBeUndefined(); + expect(startCheckIn('0 0 * * *', { slug: () => undefined })).toBeUndefined(); + }); + + test('sends no check-ins and warns when the slug function throws', () => { + const warnSpy = vi.spyOn(SentryCore.debug, 'warn').mockImplementation(() => undefined); + const slug = (): string => { + throw new Error('slug error'); + }; + + expect(getInProgressCheckIn('0 0 * * *', { slug })).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('`slug` threw'), expect.any(Error)); + }); + + test('sends no check-ins for a run without a cron expression', () => { + expect(getInProgressCheckIn('')).toBeUndefined(); + }); + + test.each([ + ['1', 'SUN'], + ['7', 'SAT'], + ['2,4,6', 'MON,WED,FRI'], + ['2-6', 'MON-FRI'], + ['1-7/2', 'SUN-SAT/2'], + ['2/3', 'MON-SAT/3'], + ['7/1', 'SAT'], + ['3-3/2', 'TUE'], + ['*/2', '*/2'], + ['*', '*'], + ['mon-fri', 'MON-FRI'], + ['SAT,1', 'SAT,SUN'], + ])('converts the weekday field %s to %s', (weekdays, expected) => { + const [, monitorConfig] = getInProgressCheckIn(`0 9 * * ${weekdays}`) as unknown[]; + + expect(monitorConfig).toEqual({ schedule: { type: 'crontab', value: `0 9 * * ${expected}` } }); + }); + + test.each([['0'], ['8'], ['6-2'], ['L'], ['6L'], ['6#2'], ['?'], ['1-*'], ['2/0']])( + 'sends check-ins without a schedule for the weekday field %s', + weekdays => { + const warnSpy = vi.spyOn(SentryCore.debug, 'warn').mockImplementation(() => undefined); + const [checkIn, monitorConfig] = getInProgressCheckIn(`0 9 * * ${weekdays}`) as unknown[]; + + expect(checkIn).toEqual(expect.objectContaining({ status: 'in_progress' })); + expect(monitorConfig).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Can't convert")); + }, + ); + + test.each([['15W'], ['LW'], ['?']])('sends check-ins without a schedule for the day of month %s', dayOfMonth => { + const [, monitorConfig] = getInProgressCheckIn(`0 9 ${dayOfMonth} * *`) as unknown[]; + + expect(monitorConfig).toBeUndefined(); + }); + + test('sends check-ins without a schedule for an expression without five fields', () => { + const [, monitorConfig] = getInProgressCheckIn('0 0 9 * * *') as unknown[]; + + expect(monitorConfig).toBeUndefined(); + }); + + test.each([ + ['0 9 * * 1,5', 'cron-0-9-x-x-1_5'], + ['0 9 * * 1-5', 'cron-0-9-x-x-1to5'], + ['*/15 * * * *', 'cron-xby15-x-x-x-x'], + ['0 9 * * MON', 'cron-0-9-x-x-mon'], + ['30 9 * * MON-FRI', 'cron-30-9-x-x-montofri'], + ])('derives the slug for %s as %s', (cron, slug) => { + expect(getSlug(cron)).toBe(slug); + }); + + test('derives different slugs for expressions that differ only in separators', () => { + const crons = ['0 9 * * 1,5', '0 9 * * 1-5', '0 9 * * 1/5', '0 9 * * 1#5']; + const slugs = crons.map(getSlug); + + expect(new Set(slugs).size).toBe(crons.length); + expect(slugs[3]).toMatch(/^cron-0-9-x-x-1-5-[a-z0-9]{1,6}$/); + }); + + test('shortens long slugs and appends a hash', () => { + const minutes = Array.from({ length: 30 }, (_, i) => i).join(','); + const first = getSlug(`${minutes} * * * *`); + const second = getSlug(`${minutes},59 * * * *`); + + expect(first.length).toBeLessThanOrEqual(50); + expect(first).toMatch(/^cron-0_1_2_.*-[a-z0-9]{1,6}$/); + expect(second).not.toBe(first); + }); +});