Repository navigation
feat(nestjs): Add an option to monitor every @Cron method #25471
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
5833c8f
20a2edd
2bd91a3
3c1da94
6413911
8cd5e98
258a3fc
b253af4
34f02f1
a191b86
47086d7
9173773
cb47dab
0c123c6
0c5e61c
0576af0
a9a89d7
7a3b942
481a052
f23a26f
c319f9f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| import type { Client, MonitorConfig } from '@sentry/core'; | ||
| import { debug, getClient, stringMatchesSomePattern } from '@sentry/core'; | ||
| import { DEBUG_BUILD } from '../debug-build'; | ||
| import { nestCronOptionsToMonitorConfig } from '../decorators'; | ||
| import type { AnyFn } from './helpers'; | ||
| import { isSentryCronMethod } from './helpers'; | ||
|
|
||
| export interface NestCronMonitoringOptions { | ||
| /** | ||
| * Send check-ins for every `@Cron()` method of `@nestjs/schedule`. The monitor slug is the `name` | ||
| * option of `@Cron()`, or `ClassName.methodName`. Methods with `@SentryCron()` keep their own slug. | ||
| * Methods whose schedule Sentry can't represent, like every 10 seconds, are not monitored. | ||
| * | ||
| * Defaults to `false`. | ||
| */ | ||
| monitorCronJobs?: boolean; | ||
|
|
||
| /** | ||
| * Monitor slugs not to monitor when `monitorCronJobs` is enabled. Strings must match the slug exactly. | ||
| */ | ||
| excludeCronJobs?: Array<string | RegExp>; | ||
| } | ||
|
|
||
| interface CronMonitor { | ||
| slug: string; | ||
| config: MonitorConfig; | ||
| } | ||
|
|
||
| const MONITORING_OPTIONS = new WeakMap<Client, NestCronMonitoringOptions>(); | ||
|
|
||
| /** Enables `@Cron()` monitoring for the client. */ | ||
| export function setCronMonitoringOptions(client: Client, options: NestCronMonitoringOptions): void { | ||
| if (options.monitorCronJobs) { | ||
| MONITORING_OPTIONS.set(client, options); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Returns a function that resolves the monitor for a `@Cron()` method on each run, | ||
| * or `undefined` if the run should not be monitored. | ||
| */ | ||
| export function makeCronMonitorResolver( | ||
| cronArgs: unknown[], | ||
| target: unknown, | ||
| propertyKey: string | symbol | undefined, | ||
| scheduleVersion?: string, | ||
| ): (handler: AnyFn, wrapped: AnyFn) => CronMonitor | undefined { | ||
| let monitor: CronMonitor | null | undefined; | ||
|
|
||
| return (handler, wrapped) => { | ||
| const client = getClient(); | ||
| const options = client && MONITORING_OPTIONS.get(client); | ||
| if (!options || isSentryCronMethod(handler) || isSentryCronMethod(wrapped)) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rather than calling the |
||
| return undefined; | ||
| } | ||
|
wedamija marked this conversation as resolved.
Resolved
|
||
|
|
||
| if (monitor === undefined) { | ||
| try { | ||
| monitor = getCronMonitor(cronArgs, target, propertyKey, scheduleVersion); | ||
| } catch (e) { | ||
| DEBUG_BUILD && debug.error('[Nest] Failed to set up the cron monitor:', e); | ||
| monitor = null; | ||
| } | ||
| } | ||
|
|
||
| if (!monitor || stringMatchesSomePattern(monitor.slug, options.excludeCronJobs, true)) { | ||
| return undefined; | ||
| } | ||
| return monitor; | ||
| }; | ||
| } | ||
|
|
||
| function getCronMonitor( | ||
| [cronTime, cronOptions]: unknown[], | ||
| target: unknown, | ||
| propertyKey: string | symbol | undefined, | ||
| scheduleVersion: string | undefined, | ||
| ): CronMonitor | null { | ||
| // A `Date` runs once, so there is nothing to monitor. | ||
| if (typeof cronTime !== 'string') { | ||
| return null; | ||
| } | ||
|
|
||
| const options = (cronOptions && typeof cronOptions === 'object' ? cronOptions : {}) as Record<string, unknown>; | ||
| const className = (target as { constructor?: { name?: unknown } } | undefined)?.constructor?.name; | ||
| const slug = | ||
| typeof options.name === 'string' && options.name | ||
| ? options.name | ||
| : typeof className === 'string' && className && propertyKey !== undefined | ||
| ? `${className}.${String(propertyKey)}` | ||
| : undefined; | ||
|
Comment on lines
+86
to
+91
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hm, relay truncates these to 50 chars, so we should probably at least document that users should pass an explicit |
||
|
|
||
| if (!slug) { | ||
| DEBUG_BUILD && debug.warn('[Nest] Not monitoring a @Cron() method without a name.'); | ||
| return null; | ||
| } | ||
|
|
||
| const config = nestCronOptionsToMonitorConfig({ ...options, cronTime }, scheduleVersion); | ||
| if (!config) { | ||
| DEBUG_BUILD && debug.log(`[Nest] Not monitoring ${slug}: Sentry can't represent its schedule.`); | ||
| return null; | ||
| } | ||
|
|
||
| return { slug, config }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,7 +9,13 @@ import { | |
| } from '@sentry/conventions/attributes'; | ||
| import { FUNCTION, MIDDLEWARE, QUEUE_PROCESS } from '@sentry/conventions/op'; | ||
| import type { Span } from '@sentry/core'; | ||
| import { addNonEnumerableProperty, getClient, hasSpanStreamingEnabled, withActiveSpan } from '@sentry/core'; | ||
| import { | ||
| addNonEnumerableProperty, | ||
| getClient, | ||
| hasSpanStreamingEnabled, | ||
| isObjectLike, | ||
| withActiveSpan, | ||
| } from '@sentry/core'; | ||
| import type { CatchTarget, InjectableTarget, NextFunction, Observable, Subscription } from './types'; | ||
|
|
||
| /** A function of unknown signature, matching the methods/handlers we wrap. */ | ||
|
|
@@ -33,17 +39,54 @@ export function markWrapped(fn: AnyFn): void { | |
|
|
||
| // `@nestjs/schedule`'s metadata key for the `@Cron()` options. | ||
| const SCHEDULE_CRON_OPTIONS = 'SCHEDULE_CRON_OPTIONS'; | ||
| const SENTRY_CRON_METADATA = 'sentry:nestjs:sentry_cron'; | ||
|
|
||
| const SENTRY_CRON_METHODS = new WeakSet<object>(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's three overlapping ways to mark
Suggestion: drop the function |
||
|
|
||
| /** The options `@Cron()` stored as metadata on `fn`, if any. */ | ||
| export function getCronOptions(fn: object): object | undefined { | ||
| const R = Reflect as ReflectWithMetadata; | ||
| const options = typeof R.getMetadata === 'function' ? R.getMetadata(SCHEDULE_CRON_OPTIONS, fn) : undefined; | ||
| return isObjectLike(options) ? options : undefined; | ||
| } | ||
|
|
||
| /** | ||
| * Marks a method that `@SentryCron()` monitors, so `@Cron()` monitoring skips it. Other decorators may wrap the | ||
| * method on either side of `@SentryCron()`, so this also sets metadata they copy outward and marks the `@Cron()` | ||
| * options that are already there. | ||
| */ | ||
| export function markSentryCronMethod(fn: object): void { | ||
| SENTRY_CRON_METHODS.add(fn); | ||
| const R = Reflect as ReflectWithMetadata; | ||
| if (typeof R.defineMetadata === 'function') { | ||
| R.defineMetadata(SENTRY_CRON_METADATA, true, fn); | ||
| } | ||
| const cronOptions = getCronOptions(fn); | ||
| if (cronOptions) { | ||
| SENTRY_CRON_METHODS.add(cronOptions); | ||
| } | ||
| } | ||
|
|
||
| /** Whether `fn` is monitored by `@SentryCron()` (see {@link markSentryCronMethod}). */ | ||
| export function isSentryCronMethod(fn: object): boolean { | ||
| const R = Reflect as ReflectWithMetadata; | ||
| const cronOptions = getCronOptions(fn); | ||
| return ( | ||
| SENTRY_CRON_METHODS.has(fn) || | ||
| (typeof R.getMetadata === 'function' && R.getMetadata(SENTRY_CRON_METADATA, fn) === true) || | ||
| (!!cronOptions && SENTRY_CRON_METHODS.has(cronOptions)) | ||
| ); | ||
| } | ||
|
|
||
| const NEST_SCHEDULE_VERSIONS = new WeakMap<object, string>(); | ||
|
|
||
| /** Remembers the `@nestjs/schedule` version whose `@Cron()` stored the options on `method`. */ | ||
| export function setNestScheduleVersion(method: unknown, version: string | undefined): void { | ||
| const R = Reflect as ReflectWithMetadata; | ||
| if (typeof method !== 'function' || !version || typeof R.getMetadata !== 'function') { | ||
| if (typeof method !== 'function' || !version) { | ||
| return; | ||
| } | ||
| const cronOptions = R.getMetadata(SCHEDULE_CRON_OPTIONS, method); | ||
| if (cronOptions && typeof cronOptions === 'object') { | ||
| const cronOptions = getCronOptions(method); | ||
| if (cronOptions) { | ||
| NEST_SCHEDULE_VERSIONS.set(cronOptions, version); | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,16 +1,23 @@ | ||
| import { defineIntegration } from '@sentry/core'; | ||
| import type { NestCronMonitoringOptions } from './cron-monitoring'; | ||
| import { setCronMonitoringOptions } from './cron-monitoring'; | ||
| import { subscribeToNestChannels } from './orchestrion-subscriber'; | ||
|
|
||
| const INTEGRATION_NAME = 'Nest' as const; | ||
|
|
||
| export type NestIntegrationOptions = NestCronMonitoringOptions; | ||
|
|
||
| /** | ||
| * Integration capturing tracing data for NestJS. | ||
| */ | ||
| export const nestIntegration = defineIntegration(() => { | ||
| export const nestIntegration = defineIntegration((options: NestIntegrationOptions = {}) => { | ||
| return { | ||
| name: INTEGRATION_NAME, | ||
| setupOnce() { | ||
| subscribeToNestChannels(); | ||
| }, | ||
| setup(client) { | ||
| setCronMonitoringOptions(client, options); | ||
| }, | ||
| }; | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,5 +1,5 @@ | ||||||||||||||||||||||||||||||
| import * as diagnosticsChannel from 'node:diagnostics_channel'; | ||||||||||||||||||||||||||||||
| import { debug, startInactiveSpan, waitForTracingChannelBinding } from '@sentry/core'; | ||||||||||||||||||||||||||||||
| import { addNonEnumerableProperty, debug, startInactiveSpan, waitForTracingChannelBinding } from '@sentry/core'; | ||||||||||||||||||||||||||||||
| import type { OrchestrionChannelContext } from '@sentry/server-utils'; | ||||||||||||||||||||||||||||||
| import { bindTracingChannelToSpan } from '@sentry/server-utils'; | ||||||||||||||||||||||||||||||
| import { nestjsChannels as CHANNELS } from '@sentry/server-utils/orchestrion/config'; | ||||||||||||||||||||||||||||||
|
|
@@ -10,11 +10,13 @@ import type { CatchTarget, InjectableTarget, MinimalNestJsExecutionContext } fro | |||||||||||||||||||||||||||||
| import { patchCatchTarget, patchInjectableTarget } from './wrap-components'; | ||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||
| extractQueueName, | ||||||||||||||||||||||||||||||
| MECHANISM_CRON, | ||||||||||||||||||||||||||||||
| linkCronOptionsToMonitor, | ||||||||||||||||||||||||||||||
| MECHANISM_INTERVAL, | ||||||||||||||||||||||||||||||
| MECHANISM_TIMEOUT, | ||||||||||||||||||||||||||||||
| patchMethodDescriptor, | ||||||||||||||||||||||||||||||
| patchProcessorTarget, | ||||||||||||||||||||||||||||||
| sendsCronCheckIns, | ||||||||||||||||||||||||||||||
| wrapCronHandler, | ||||||||||||||||||||||||||||||
| wrapEventHandler, | ||||||||||||||||||||||||||||||
| wrapScheduleHandler, | ||||||||||||||||||||||||||||||
| } from './wrap-handlers'; | ||||||||||||||||||||||||||||||
|
|
@@ -50,7 +52,10 @@ function subscribeDecoratorChannel<T>(channelName: string, patch: (target: T) => | |||||||||||||||||||||||||||||
| * `descriptor.value` with a wrapped handler before delegating to the | ||||||||||||||||||||||||||||||
| * original decorator. | ||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||
| function makeMethodDecorator(original: AnyFn, wrapHandler: (handler: AnyFn) => AnyFn): AnyFn { | ||||||||||||||||||||||||||||||
| function makeMethodDecorator( | ||||||||||||||||||||||||||||||
| original: AnyFn, | ||||||||||||||||||||||||||||||
| wrapHandler: (handler: AnyFn, target: unknown, propertyKey: string | symbol | undefined) => AnyFn, | ||||||||||||||||||||||||||||||
| ): AnyFn { | ||||||||||||||||||||||||||||||
| return function (this: unknown, ...args: unknown[]): unknown { | ||||||||||||||||||||||||||||||
| patchMethodDescriptor( | ||||||||||||||||||||||||||||||
| args[0] as { __SENTRY_INTERNAL__?: boolean } | undefined, | ||||||||||||||||||||||||||||||
|
|
@@ -185,13 +190,30 @@ export function subscribeToNestChannels(): void { | |||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // @Cron/@Interval/@Timeout (schedule), @OnEvent (event), @Processor (bullmq) | ||||||||||||||||||||||||||||||
| subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_CRON, (decorator, data) => { | ||||||||||||||||||||||||||||||
| const cronDecorator = makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_CRON)); | ||||||||||||||||||||||||||||||
| const cronDecorator = makeMethodDecorator(decorator, (handler, target, propertyKey) => | ||||||||||||||||||||||||||||||
| wrapCronHandler(handler, [...(data.arguments || [])], target, propertyKey, data.moduleVersion), | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| return function (this: unknown, ...args: unknown[]): unknown { | ||||||||||||||||||||||||||||||
| const result = cronDecorator.apply(this, args); | ||||||||||||||||||||||||||||||
| setNestScheduleVersion((args[2] as PropertyDescriptor | undefined)?.value, data.moduleVersion); | ||||||||||||||||||||||||||||||
| const method = (args[2] as PropertyDescriptor | undefined)?.value; | ||||||||||||||||||||||||||||||
| setNestScheduleVersion(method, data.moduleVersion); | ||||||||||||||||||||||||||||||
| linkCronOptionsToMonitor(method); | ||||||||||||||||||||||||||||||
| return result; | ||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
| // `@nestjs/schedule` passes this function to `CronJob` as `onTick`. Mark it when the method already | ||||||||||||||||||||||||||||||
| // sends check-ins, so `cronJobIntegration` doesn't send a second set. | ||||||||||||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh, is this supposed to be stacked on #25477? It seems like this comment is maybe premature, since the Whether that's the case, or if #25477 is intended to stack on top of this, probably we should make these a PR stack so that we can land them together and not have develop in a halfway state? (It's low severity, I mean, if we land both, it's fine, and I wouldn't sweat the ordering over a premature comment, as long as the code works at all points along the |
||||||||||||||||||||||||||||||
| diagnosticsChannel.tracingChannel<OrchestrionChannelContext>(CHANNELS.NESTJS_SCHEDULE_EXPLORER_WRAP).subscribe({ | ||||||||||||||||||||||||||||||
| start: NOOP, | ||||||||||||||||||||||||||||||
| end(data) { | ||||||||||||||||||||||||||||||
| if (typeof data.result === 'function' && sendsCronCheckIns(data.arguments?.[0])) { | ||||||||||||||||||||||||||||||
| addNonEnumerableProperty(data.result, Symbol.for('sentry.cron.monitored'), true); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
cursor[bot] marked this conversation as resolved.
Resolved
|
||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
|
Comment on lines
+208
to
+212
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The risk is pretty low, but this code runs once for every scheduled method at bootstrap, inside the user's app.
Suggested change
|
||||||||||||||||||||||||||||||
| asyncStart: NOOP, | ||||||||||||||||||||||||||||||
| asyncEnd: NOOP, | ||||||||||||||||||||||||||||||
| error: NOOP, | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
| subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_INTERVAL, decorator => | ||||||||||||||||||||||||||||||
| makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_INTERVAL)), | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.