Monitor Swift Scheduled Tasks and Background Jobs with CronPeek
Swift on the server is no longer experimental. Vapor powers production APIs. Hummingbird runs lightweight microservices. Swift ArgumentParser builds CLI tools that teams schedule via crontab and launchd. But every one of these scheduled tasks shares the same blind spot: when they fail silently, nobody notices until the damage is done.
A Vapor queue worker that throws an unhandled error stops processing jobs. A launchd agent on macOS that crashes on launch never runs again until someone manually restarts it. A CLI tool scheduled via crontab that segfaults produces no output — cron swallows the error and moves on. The server is up. The health check passes. The work is not happening.
CronPeek solves this with dead man's switch monitoring. Your Swift task pings an endpoint after every successful run. If the ping stops arriving, you get an alert. This guide covers every major way Swift runs scheduled work — Vapor Queues, ArgumentParser CLI tools, DispatchSourceTimer, launchd agents, and Hummingbird — with production-ready code examples.
Why Swift Background Jobs Fail Silently
Swift's type system catches many bugs at compile time, but scheduled tasks fail for reasons the compiler cannot prevent:
- Vapor queue workers crash mid-job — an unhandled
throwinside aQueuesJobkills that job silently. The worker process stays alive and picks up the next job, but the failed job is gone unless you have explicit retry logic. - Swift Package Manager CLI tools exit with code 0 on partial failure — your tool processes 10,000 records but silently skips 3,000 due to a decoding error caught in a
do/catchthat logs nothing. Exit code 0. Cron thinks it succeeded. - DispatchSourceTimer stops firing — if the dispatch queue is blocked or the timer's reference is deallocated, the timer simply stops. No crash, no log, no error.
- launchd agents fail to launch — a code signing issue, missing entitlement, or corrupted binary means launchd never starts your agent. The plist is loaded,
launchctl listshows it, but the process never runs. - Linux OOM kills — server-side Swift on Linux can hit memory limits in containers. The kernel kills the process, the container restarts, but the scheduled task's state is lost and the missed run is never retried.
None of these trigger traditional uptime monitors or HTTP health checks. You need a system that detects when something stops happening.
The Dead Man's Switch Pattern
A dead man's switch is a mechanism that triggers when it stops receiving a signal. In physical systems, a train operator holds a lever; if they let go, the brakes engage. In software, your scheduled task sends an HTTP ping after every successful run. If the ping stops arriving within an expected window, CronPeek triggers an alert.
The pattern works in three steps:
- Create a monitor on CronPeek with the expected interval (e.g., every 10 minutes). Set a grace period slightly longer than the interval to absorb jitter.
- Ping the monitor at the end of each successful run by sending an HTTP GET to
https://cronpeek.web.app/api/v1/ping/YOUR_MONITOR_ID. - Get alerted via email, Slack, or webhook when the ping stops arriving. Optionally, hit
/failfor immediate alerts on known errors.
This catches every failure mode — crashes, hangs, missed schedules, OOM kills, deployment mistakes — because it monitors the absence of success rather than the presence of failure.
CronPeek Ping Wrapper in Swift
Here is a reusable CronPeek client using Swift's modern URLSession async/await API. This works on both macOS and Linux with Swift 5.9+:
import Foundation
/// Lightweight CronPeek client for heartbeat pings.
/// Uses URLSession async/await — no third-party dependencies.
struct CronPeekClient {
let baseURL: String
let session: URLSession
let timeout: TimeInterval
init(
baseURL: String = "https://cronpeek.web.app/api/v1/ping",
timeout: TimeInterval = 5.0
) {
self.baseURL = baseURL
self.timeout = timeout
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = timeout
config.timeoutIntervalForResource = timeout
self.session = URLSession(configuration: config)
}
/// Ping CronPeek after a successful job run.
func ping(monitorID: String) async {
await send(url: "\(baseURL)/\(monitorID)")
}
/// Report a failure to CronPeek for an immediate alert.
func fail(monitorID: String, message: String? = nil) async {
var url = "\(baseURL)/\(monitorID)/fail"
if let message = message {
let encoded = message.addingPercentEncoding(
withAllowedCharacters: .urlQueryAllowed
) ?? message
url += "?msg=\(encoded)"
}
await send(url: url)
}
private func send(url: String) async {
guard let url = URL(string: url) else {
print("[CronPeek] Invalid URL, skipping ping")
return
}
do {
let (_, response) = try await session.data(from: url)
if let http = response as? HTTPURLResponse,
http.statusCode != 200 {
print("[CronPeek] Unexpected status: \(http.statusCode)")
}
} catch {
// Never crash on monitoring failure.
// CronPeek's grace period handles transient issues.
print("[CronPeek] Ping failed: \(error.localizedDescription)")
}
}
}
Key design decisions: the 5-second timeout prevents a CronPeek outage from blocking your service. Errors are printed but never thrown — a monitoring failure must never cause a production outage. The fail method accepts an optional message that appears in your CronPeek dashboard for faster debugging.
Usage is straightforward:
let cronpeek = CronPeekClient()
// After a successful job
await cronpeek.ping(monitorID: "mon_billing_swift_001")
// After a caught error
await cronpeek.fail(
monitorID: "mon_billing_swift_001",
message: "Stripe API returned 503"
)
Integration with Vapor's Queues Framework
Vapor's Queues package provides background job processing with Redis or database-backed drivers. The framework supports lifecycle hooks through JobEventDelegate, which is the clean way to integrate CronPeek without modifying every job handler.
import Vapor
import Queues
/// Monitors all Vapor queue jobs via CronPeek heartbeats.
struct CronPeekJobDelegate: JobEventDelegate {
let cronpeek: CronPeekClient
let monitorPrefix: String
init(monitorPrefix: String = "mon_vapor") {
self.cronpeek = CronPeekClient()
self.monitorPrefix = monitorPrefix
}
func dispatched(job: JobEventData, eventLoop: EventLoop) -> EventLoopFuture<Void> {
// No ping on dispatch — only on completion
eventLoop.makeSucceededVoidFuture()
}
func didDequeue(jobId: String, eventLoop: EventLoop) -> EventLoopFuture<Void> {
eventLoop.makeSucceededVoidFuture()
}
func success(jobId: String, eventLoop: EventLoop) -> EventLoopFuture<Void> {
let monitorID = "\(monitorPrefix)_\(jobId)"
return eventLoop.performWithTask {
await cronpeek.ping(monitorID: monitorID)
}
}
func error(jobId: String, error: Error, eventLoop: EventLoop) -> EventLoopFuture<Void> {
let monitorID = "\(monitorPrefix)_\(jobId)"
return eventLoop.performWithTask {
await cronpeek.fail(
monitorID: monitorID,
message: error.localizedDescription
)
}
}
}
Register the delegate in your Vapor configuration:
// In configure.swift
func configure(_ app: Application) throws {
// ... Redis, database, routes setup ...
// Register CronPeek monitoring for all queue jobs
app.queues.configuration.addJobEventDelegate(
CronPeekJobDelegate(monitorPrefix: "mon_vapor")
)
// Register your jobs as usual
app.queues.add(SendWeeklyReportJob())
app.queues.add(CleanExpiredTokensJob())
app.queues.add(SyncInventoryJob())
try app.queues.startInProcessJobs()
}
Now every Vapor queue job automatically reports success or failure to CronPeek. If a job type-specific monitor makes more sense for your setup, override the monitor ID based on the job name instead of the job ID.
For recurring scheduled jobs in Vapor, add the ping directly:
import Vapor
import Queues
struct DailyReportJob: AsyncScheduledJob {
let cronpeek = CronPeekClient()
func run(context: QueueContext) async throws {
let db = context.application.db
// Generate the report
let users = try await User.query(on: db)
.filter(\.$isActive == true)
.all()
let revenue = try await Order.query(on: db)
.filter(\.$createdAt >= Calendar.current.startOfDay(for: Date()))
.sum(\.$total)
try await EmailService.sendDailyReport(
userCount: users.count,
revenue: revenue ?? 0
)
// Ping CronPeek on success
await cronpeek.ping(monitorID: "mon_daily_report_001")
}
}
Swift ArgumentParser CLI Tools with CronPeek
Swift ArgumentParser builds excellent CLI tools. When you schedule them via crontab or launchd, CronPeek ensures they actually run. Here is a complete example of a data export tool with monitoring:
import ArgumentParser
import Foundation
@main
struct ExportAnalytics: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Export analytics data to S3, monitored by CronPeek"
)
@Option(help: "CronPeek monitor ID")
var monitorID: String = "mon_export_analytics_001"
@Option(help: "Number of days to export")
var days: Int = 1
@Option(help: "S3 bucket name")
var bucket: String
func run() async throws {
let cronpeek = CronPeekClient()
let startDate = Calendar.current.date(
byAdding: .day,
value: -days,
to: Date()
)!
do {
// Step 1: Query the database
print("Querying analytics from \(startDate)...")
let records = try await AnalyticsDB.shared.query(since: startDate)
print("Found \(records.count) records")
guard !records.isEmpty else {
print("No records to export, pinging CronPeek anyway")
await cronpeek.ping(monitorID: monitorID)
return
}
// Step 2: Serialize to Parquet
let parquetData = try ParquetEncoder.encode(records)
let filename = "analytics_\(ISO8601DateFormatter().string(from: startDate)).parquet"
// Step 3: Upload to S3
print("Uploading \(filename) to s3://\(bucket)/...")
try await S3Client.shared.upload(
data: parquetData,
bucket: bucket,
key: "exports/\(filename)"
)
print("Export complete: \(records.count) records uploaded")
await cronpeek.ping(monitorID: monitorID)
} catch {
print("Export failed: \(error)")
await cronpeek.fail(
monitorID: monitorID,
message: "\(error)"
)
// Re-throw so the process exits with non-zero code
throw error
}
}
}
Schedule it via crontab on Linux:
# Run analytics export every 6 hours
0 */6 * * * /usr/local/bin/export-analytics --bucket my-analytics-bucket 2>&1 | logger -t export-analytics
Or via launchd on macOS, create ~/Library/LaunchAgents/com.mycompany.export-analytics.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.mycompany.export-analytics</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/export-analytics</string>
<string>--bucket</string>
<string>my-analytics-bucket</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>6</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>StandardOutPath</key>
<string>/tmp/export-analytics.log</string>
<key>StandardErrorPath</key>
<string>/tmp/export-analytics.err</string>
</dict>
</plist>
If launchd fails to start the process (code signing issue, binary not found, permission denied), CronPeek detects the missing ping and alerts you. This is something launchctl list alone cannot tell you — a loaded agent is not the same as a running agent.
Using DispatchSourceTimer with CronPeek Heartbeats
DispatchSourceTimer is the low-level GCD primitive for recurring work. It runs on a dispatch queue and fires a closure at a set interval. The danger: if the timer's reference is released, or the queue is blocked, the timer stops silently. CronPeek catches this.
import Foundation
class HeartbeatScheduler {
private var timer: DispatchSourceTimer?
private let queue = DispatchQueue(
label: "com.myapp.scheduler",
qos: .utility
)
private let cronpeek = CronPeekClient()
private let monitorID: String
init(monitorID: String) {
self.monitorID = monitorID
}
/// Start a recurring task with CronPeek heartbeat monitoring.
func start(interval: TimeInterval, task: @escaping () async throws -> Void) {
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(
deadline: .now(),
repeating: interval,
leeway: .seconds(1)
)
timer.setEventHandler { [weak self] in
guard let self = self else { return }
Task {
do {
try await task()
await self.cronpeek.ping(monitorID: self.monitorID)
} catch {
await self.cronpeek.fail(
monitorID: self.monitorID,
message: "\(error)"
)
}
}
}
timer.setCancelHandler {
print("[Scheduler] Timer cancelled for \(self.monitorID)")
}
timer.resume()
self.timer = timer
}
func stop() {
timer?.cancel()
timer = nil
}
deinit {
stop()
}
}
// Usage
let scheduler = HeartbeatScheduler(monitorID: "mon_cache_warm_001")
scheduler.start(interval: 300) { // Every 5 minutes
try await CacheManager.shared.warmProductCache()
}
// Keep the process alive
RunLoop.current.run()
The [weak self] capture is critical. If the scheduler is deallocated, the timer cancels cleanly and CronPeek detects the missing heartbeat. Without this pattern, a strong reference cycle keeps the timer alive but orphaned — it fires into a deallocated context and crashes.
Server-Side Swift on Linux: Vapor and Hummingbird Scheduled Tasks
Server-side Swift typically runs on Linux in Docker containers. Both Vapor and Hummingbird support background tasks, and both need monitoring.
Hummingbird Scheduled Tasks
Hummingbird 2 uses Swift's structured concurrency for background work. Here is how to add CronPeek monitoring to a Hummingbird service with periodic tasks:
import Hummingbird
import Foundation
struct PeriodicTaskRunner {
let cronpeek = CronPeekClient()
func runMetricsAggregation(app: HBApplication) {
Task {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(600)) // 10 minutes
do {
let metrics = try await MetricsService.aggregate(
since: Date().addingTimeInterval(-600)
)
try await MetricsService.store(metrics)
print("Aggregated \(metrics.count) metric points")
await cronpeek.ping(monitorID: "mon_hb_metrics_001")
} catch {
print("Metrics aggregation failed: \(error)")
await cronpeek.fail(
monitorID: "mon_hb_metrics_001",
message: "\(error)"
)
}
}
}
}
func runStaleSessionCleanup(app: HBApplication) {
Task {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(3600)) // 1 hour
do {
let deleted = try await SessionStore.shared.deleteExpired()
print("Cleaned \(deleted) expired sessions")
await cronpeek.ping(monitorID: "mon_hb_sessions_001")
} catch {
print("Session cleanup failed: \(error)")
await cronpeek.fail(
monitorID: "mon_hb_sessions_001",
message: "\(error)"
)
}
}
}
}
}
Vapor Lifecycle Handler
For Vapor, you can wrap periodic tasks in a LifecycleHandler that starts and stops cleanly with the application:
import Vapor
struct CronPeekLifecycleHandler: LifecycleHandler {
let cronpeek = CronPeekClient()
let tasks: [(monitorID: String, interval: TimeInterval, work: @Sendable () async throws -> Void)]
func didBoot(_ app: Application) throws {
for task in tasks {
let cronpeek = self.cronpeek
let monitorID = task.monitorID
let interval = task.interval
let work = task.work
app.eventLoopGroup.next().scheduleRepeatedTask(
initialDelay: .seconds(0),
delay: .seconds(Int64(interval))
) { repeatedTask in
Task {
do {
try await work()
await cronpeek.ping(monitorID: monitorID)
} catch {
await cronpeek.fail(
monitorID: monitorID,
message: "\(error)"
)
}
}
}
}
}
}
// In configure.swift
app.lifecycle.use(CronPeekLifecycleHandler(tasks: [
(
monitorID: "mon_vapor_cleanup_001",
interval: 600,
work: { try await TokenService.cleanExpired() }
),
(
monitorID: "mon_vapor_digest_001",
interval: 86400,
work: { try await EmailService.sendDailyDigest() }
),
]))
Error Reporting with the /fail Endpoint
The difference between a missing ping and an explicit failure report matters for incident response. A missing ping means something stopped — could be a crash, a hang, a deployment issue. An explicit /fail call means the code ran, hit an error, and reported it. CronPeek treats these differently:
- Missing ping — alert fires after the grace period expires. Useful for hard crashes and missed schedules.
- /fail endpoint — alert fires immediately. The optional message appears in your dashboard, so the on-call engineer knows the error before opening a terminal.
Best practice: always call /fail in your catch block, then re-throw or exit with a non-zero code. This gives you both the immediate CronPeek alert and the standard error handling:
func runDatabaseMigration() async throws {
let cronpeek = CronPeekClient()
let monitorID = "mon_migration_001"
do {
try await MigrationRunner.run(migrations: [
AddUserPreferencesTable.self,
BackfillDefaultPreferences.self,
])
await cronpeek.ping(monitorID: monitorID)
} catch let error as DatabaseError where error.isConstraintViolation {
// Known error type: report and exit gracefully
await cronpeek.fail(
monitorID: monitorID,
message: "Constraint violation: \(error.column ?? "unknown")"
)
throw error
} catch {
// Unknown error: report everything
await cronpeek.fail(
monitorID: monitorID,
message: String(describing: error).prefix(200).description
)
throw error
}
}
Truncating the error message to 200 characters prevents excessively long stack traces from bloating the ping URL. CronPeek accepts messages up to 1,000 characters.
Kubernetes Deployment with Swift Docker Images
Server-side Swift runs in Docker containers using Apple's official Swift images. Here is a multi-stage Dockerfile for a Swift CLI tool with CronPeek monitoring baked in:
# Build stage
FROM swift:5.10-jammy AS builder
WORKDIR /app
COPY Package.swift Package.resolved ./
RUN swift package resolve
COPY Sources/ Sources/
RUN swift build -c release --static-swift-stdlib
# Runtime stage
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/.build/release/export-analytics /usr/local/bin/
ENTRYPOINT ["export-analytics"]
Deploy it as a Kubernetes CronJob:
apiVersion: batch/v1
kind: CronJob
metadata:
name: swift-analytics-export
labels:
app: analytics-export
runtime: swift
spec:
schedule: "0 */6 * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 5
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 1800
template:
spec:
restartPolicy: Never
containers:
- name: export
image: your-registry/swift-analytics-export:latest
args:
- "--bucket"
- "my-analytics-bucket"
- "--monitor-id"
- "mon_k8s_export_001"
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
env:
- name: AWS_REGION
value: "us-east-1"
- name: AWS_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: aws-credentials
key: access-key-id
- name: AWS_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: aws-credentials
key: secret-access-key
The CronPeek ping is built into the Swift binary itself (via the ArgumentParser run() method shown earlier), so no sidecar container is needed. If you cannot modify the binary, use the curl-based sidecar approach:
containers:
- name: export
image: your-registry/swift-export:latest
command:
- /bin/sh
- -c
- |
/usr/local/bin/export-analytics --bucket my-bucket && \
curl -sf https://cronpeek.web.app/api/v1/ping/mon_k8s_export_001 || \
curl -sf https://cronpeek.web.app/api/v1/ping/mon_k8s_export_001/fail
The shell && ensures the ping only fires after a successful exit. The || branch hits /fail if the main command returns a non-zero exit code.
Pricing: CronPeek vs Cronitor for Swift Teams
Swift teams running server-side applications typically have 10–50 scheduled tasks across Vapor workers, CLI tools, and background jobs. Here is how the costs compare:
- Cronitor — starts at $20/month for 10 monitors. 50 monitors costs roughly $100/month. Enterprise pricing for teams that need Slack and PagerDuty integrations.
- CronPeek Starter — $9/month for 50 monitors. Includes email, Slack, and webhook alerts. No per-monitor upsell.
- CronPeek Pro — $29/month for unlimited monitors. Team dashboards, audit logs, and priority support.
- CronPeek Free — 5 monitors, no credit card required. Enough to monitor your most critical Vapor jobs while evaluating.
For a typical server-side Swift deployment with 30 scheduled tasks, CronPeek saves over $70/month compared to Cronitor. That is $840/year redirected to actual infrastructure.
Start monitoring your Swift jobs
Free for up to 5 monitors. No credit card required. Set up a dead man's switch for your Vapor queue workers, ArgumentParser CLI tools, and Hummingbird background tasks in under 60 seconds.
Start Free MonitoringBest Practices for Swift Cron Monitoring
- One monitor per task — never share a monitor ID across different scheduled tasks. When the session cleanup fails but the report generation keeps running, you need to know which one stopped.
- Reuse URLSession — create a single
CronPeekClientinstance and pass it to all tasks. Creating a new URLSession per ping wastes connections. - Never throw on ping failure — the
CronPeekClientabove catches all errors. A monitoring outage must never cause a production outage. - Report failures explicitly — do not just skip the ping on error. Hit
/failwith a message for immediate, actionable alerts. - Set grace periods wider than your interval — if your task runs every 10 minutes, set CronPeek to expect a ping every 13–15 minutes. This absorbs one missed ping without triggering a false alert.
- Use structured concurrency — prefer Swift's
async/awaitandTaskover raw GCD for new code. It is easier to reason about cancellation, and CronPeek pings integrate naturally into the async flow. - Test with the /fail endpoint — during initial setup, call
/failto verify your alert routing works before relying on it in production.
FAQ
How do I monitor a Swift cron job or scheduled task for silent failures?
After your Swift scheduled task completes, send an HTTP GET request to your CronPeek ping URL using URLSession and async/await. If CronPeek stops receiving pings within the expected interval, it triggers an alert via email, Slack, or webhook. Use a fire-and-forget pattern by wrapping errors in a do/catch so the ping never crashes your service.
Does CronPeek work with Vapor's Queues framework?
Yes. Vapor Queues supports lifecycle hooks through the JobEventDelegate protocol. Implement success and error methods to ping CronPeek after each job finishes. The ping is a single HTTP GET using URLSession that takes milliseconds. You can also add pings directly inside AsyncScheduledJob implementations for recurring scheduled tasks.
Can I use CronPeek with Swift ArgumentParser CLI tools scheduled via crontab?
Yes. Swift ArgumentParser supports async run() methods. After your CLI tool completes its work, call the CronPeekClient.ping() method before exiting. If the process crashes, hangs, or is never launched by cron, CronPeek detects the missing ping and sends an alert. This works with both Linux crontab and macOS launchd scheduling.
How do I monitor launchd agents written in Swift on macOS?
Add a CronPeek ping at the end of your Swift tool's execution. If launchd fails to start the agent due to code signing issues, missing binaries, or permission errors, CronPeek detects the missing heartbeat and alerts you. This catches problems that launchctl list cannot — a loaded agent is not the same as a running agent.
How much does Swift cron job monitoring cost with CronPeek?
CronPeek's free tier includes 5 monitors with no credit card required. The Starter plan at $9/month covers 50 monitors, and Pro at $29/month gives unlimited monitors. Compared to Cronitor at roughly $2 per monitor per month ($100/month for 50 monitors), CronPeek is over 10x cheaper for teams running multiple Swift scheduled tasks across Vapor, Hummingbird, and CLI tools.
The Peek Suite
CronPeek is part of a family of developer tools: