Monitoring Temporal Schedulers with Sentry Cron in Go
Temporal gives you a lot out of the box: retries, workflow history, durability, and a clean way to model long-running work.
What it does not automatically give you is confidence that your critical schedulers are actually running the way the business expects.
That gap matters more than people like to admit.
If a scheduler fails loudly, you usually notice. If it stops running, runs late, or gets stuck halfway through a critical recurring workflow, you can lose hours before anyone realizes something is wrong. That is exactly the kind of failure that hurts in production because nothing looks obviously broken until downstream numbers start drifting.
I ran into that problem while working on a backend system with Temporal schedulers that were important enough to deserve first-class visibility. We already had Sentry in place, so the obvious next step was to use Sentry Cron Monitoring for the workflows that mattered most.
That turned out to be one of those small observability changes that pays for itself quickly.
Why errors were not enough
A lot of teams think scheduler monitoring means “capture the exception if the job crashes.”
That is not enough.
Schedulers can fail in more boring ways:
- the workflow never starts
- the workflow starts late
- the workflow gets stuck and never reaches a terminal state
- a child workflow fails but the top-level schedule keeps looking alive
- the workflow completes inconsistently and no one notices until a report is wrong
If your only signal is error reporting, you will miss some of the most operationally annoying failure modes.
That is why cron-style monitoring is useful. It gives you a heartbeat for scheduled work, not just exception capture after things have already gone sideways.
What Sentry Cron Monitoring gives you
What I like about Sentry Cron Monitoring is that the model is simple.
You send a check-in when a scheduled run starts, and then you mark that same run as successful or failed when it finishes. Once you do that consistently, Sentry can tell you:
- whether the job started on time
- whether it completed
- whether it failed
- whether it missed an expected run
And once alerts are wired properly, the signal becomes immediately useful instead of just being another dashboard nobody checks.
For critical schedulers, that is exactly the kind of visibility you want.
The pattern I used
The implementation pattern was straightforward:
- Send an
in_progresscheck-in when the workflow starts. - Keep the returned check-in ID.
- Mark that same check-in as
okon success. - Mark that same check-in as
erroron failure.
That is the whole loop.
The important part is not the amount of code. The important part is being disciplined enough to send both the start and terminal states consistently.
Step 1: wrap the Sentry check-in in a small activity
I prefer putting the Sentry call behind a small activity instead of scattering CaptureCheckIn calls around workflow code directly.
That gives you a reusable abstraction and keeps the workflow code easier to read.
Here is a generalized version of the pattern:
package activities
import (
"context"
"github.com/getsentry/sentry-go"
)
type SentryCronMonitorActivity struct{}
type CheckInParams struct {
MonitorSlug string
Status sentry.CheckInStatus
CheckInID sentry.EventID
}
func (a *SentryCronMonitorActivity) CheckInCronMonitor(
ctx context.Context,
params *CheckInParams,
) (sentry.EventID, error) {
hub := sentry.GetHubFromContext(ctx)
if hub == nil {
hub = sentry.CurrentHub().Clone()
}
checkIn := &sentry.CheckIn{
MonitorSlug: params.MonitorSlug,
Status: params.Status,
}
if params.CheckInID != "" {
checkIn.ID = params.CheckInID
}
checkInID := hub.CaptureCheckIn(checkIn, nil)
if checkInID == nil {
return "", nil
}
return *checkInID, nil
}
There are two details here that matter:
MonitorSlugmust stay stable for the job you are monitoring- the terminal update must reuse the
CheckInIDreturned by the initialin_progressevent
If you skip the second part, you are not really updating the same run anymore.
Step 2: send an in_progress check-in as soon as the workflow starts
I like doing this at the top of the workflow, before the real work begins.
In Temporal, I usually send it with a local activity because the call is lightweight and I want it close to workflow startup.
func ReconcileBalances(ctx workflow.Context) error {
const monitorSlug = "reconcile-balances"
logger := workflow.GetLogger(ctx)
logger.Info("starting reconcile balances workflow")
var monitorActivity *activities.SentryCronMonitorActivity
localCtx := workflow.WithLocalActivityOptions(ctx, workflow.LocalActivityOptions{
StartToCloseTimeout: 30 * time.Second,
})
var checkInID sentry.EventID
_ = workflow.ExecuteLocalActivity(
localCtx,
monitorActivity.CheckInCronMonitor,
&activities.CheckInParams{
MonitorSlug: monitorSlug,
Status: sentry.CheckInStatusInProgress,
},
).Get(localCtx, &checkInID)
// main workflow logic continues here...
return nil
}
That first check-in buys you something important immediately: if the scheduler stops triggering or starts drifting badly, you now have a proper signal around that behavior.
Step 3: mark the run as failed when the workflow fails
This is where a lot of implementations get sloppy.
If you only send the start state and never close the run properly, you end up with noisy monitoring and confusing signals.
A failure path should explicitly update the same check-in:
if err != nil {
_ = workflow.ExecuteLocalActivity(
localCtx,
monitorActivity.CheckInCronMonitor,
&activities.CheckInParams{
MonitorSlug: monitorSlug,
Status: sentry.CheckInStatusError,
CheckInID: checkInID,
},
).Get(localCtx, nil)
return err
}
That makes the run state clear in Sentry and gives your alert rules something concrete to work with.
Step 4: mark the run as successful when the workflow completes
Success needs the same discipline as failure.
Do not assume “no exception” is enough. Send the terminal success state explicitly.
_ = workflow.ExecuteLocalActivity(
localCtx,
monitorActivity.CheckInCronMonitor,
&activities.CheckInParams{
MonitorSlug: monitorSlug,
Status: sentry.CheckInStatusOK,
CheckInID: checkInID,
},
).Get(localCtx, nil)
return nil
That closes the loop properly.
A fuller workflow example
Here is a more complete sketch that shows the start, failure, and success flow together:
func ReconcileBalances(ctx workflow.Context) error {
const monitorSlug = "reconcile-balances"
var monitorActivity *activities.SentryCronMonitorActivity
localCtx := workflow.WithLocalActivityOptions(ctx, workflow.LocalActivityOptions{
StartToCloseTimeout: 30 * time.Second,
})
var checkInID sentry.EventID
_ = workflow.ExecuteLocalActivity(
localCtx,
monitorActivity.CheckInCronMonitor,
&activities.CheckInParams{
MonitorSlug: monitorSlug,
Status: sentry.CheckInStatusInProgress,
},
).Get(localCtx, &checkInID)
if err := runCriticalSteps(ctx); err != nil {
_ = workflow.ExecuteLocalActivity(
localCtx,
monitorActivity.CheckInCronMonitor,
&activities.CheckInParams{
MonitorSlug: monitorSlug,
Status: sentry.CheckInStatusError,
CheckInID: checkInID,
},
).Get(localCtx, nil)
return err
}
_ = workflow.ExecuteLocalActivity(
localCtx,
monitorActivity.CheckInCronMonitor,
&activities.CheckInParams{
MonitorSlug: monitorSlug,
Status: sentry.CheckInStatusOK,
CheckInID: checkInID,
},
).Get(localCtx, nil)
return nil
}
This is intentionally boring code, and that is a good thing. Monitoring code should be boring.
A few implementation choices I would make again
After using this pattern, there are a few choices I would repeat without hesitation.
1. Start with critical schedulers only
Do not try to instrument every recurring workflow on day one.
Start with the jobs that actually matter:
- anything tied to customer balances
- anything that drives money movement
- anything that feeds reporting or downstream systems
- anything that will create painful cleanup if it quietly stops
You will get more value from five well-monitored critical schedulers than from fifty noisy low-value ones.
2. Use human-readable monitor slugs
Do not make the slug clever.
Use something obvious, stable, and searchable:
collect-daily-balances
sync-ledger-entries
reconcile-wallet-transactions
When an alert fires, the person reading it should understand what broke without decoding naming trivia.
3. Reuse the same check-in ID
This is easy to get wrong and worth repeating.
The first in_progress check-in gives you an ID. Keep it. Use it again when sending ok or error.
That is how Sentry knows all of those status changes belong to the same scheduled run.
4. Keep cron monitoring separate from business error handling
Cron monitoring tells you whether the scheduled run happened and how it ended.
It does not replace your normal error capture, structured logs, or domain-level alerts.
I still want:
- captured exceptions
- application logs
- traces
- domain-specific alerting where needed
Cron monitoring is one layer. A useful one, but still one layer.
5. Wire alerts somewhere people actually watch
Monitoring without response is decoration.
If the signal matters, route it to a place where the team will actually see it. Slack is the usual answer for many teams, and that was a big part of the value for us internally too.
Why I like this pattern
I like this pattern because it is small, explicit, and useful.
It does not pretend Temporal is insufficient. Temporal is great. It just accepts a reality that shows up in every production system eventually: durable execution and scheduler visibility are related, but they are not the same thing.
You still need a clear operational signal that says:
- this recurring job started
- it finished
- it failed
- or it never showed up when it was supposed to
That is the gap Sentry Cron Monitoring closes nicely.
Where this helps the most
I think this pattern is most useful in services where recurring workflows have business consequences beyond simple housekeeping.
For example:
- balance aggregation
- statement generation
- repayment schedules
- reconciliation jobs
- settlement pipelines
- compliance or reporting exports
In those cases, “we can inspect workflow history later” is not an operational strategy. You want to know quickly when the scheduler itself is unhealthy.
Final thoughts
I do not think every observability improvement needs to be dramatic to be valuable.
This one certainly was not dramatic. It was a small addition to an existing stack:
- Temporal for recurring workflows
- Sentry for visibility
- alert routing for fast response
But it solved a real production problem: critical schedulers should not fail quietly.
That is the standard I care about.
If you already run important Temporal schedulers in Go and you are only relying on errors or dashboard checks, I think Sentry Cron Monitoring is worth adding. The implementation is small, the signal is useful, and the operational payoff is immediate.
