tutorial

How to Monitor Hotwire with Vigilmon

Set up comprehensive uptime monitoring, heartbeat checks, and instant alerts for your Hotwire (Turbo + Stimulus) application with Vigilmon.

Hotwire is the HTML-over-the-wire approach pioneered by Basecamp that combines Turbo (fast page navigation and real-time server push) with Stimulus (lightweight JavaScript controllers) to build fast, interactive web apps with minimal custom JavaScript. Hotwire is the default frontend stack for Rails 7+ and is increasingly used with Django, Laravel, and other server-side frameworks. But no matter how elegantly your Stimulus controllers are structured, your users notice the moment your Hotwire app goes down. Vigilmon gives you the full observability layer your Hotwire stack needs: HTTP uptime monitors, WebSocket/Redis health checks, heartbeat monitors for background workers, and instant alerts to email or Slack.

What You'll Build

  • A /health endpoint covering your entire Hotwire stack (HTTP, database, Redis, Action Cable)
  • Vigilmon HTTP monitors for the app root and the health endpoint
  • A heartbeat monitor for Sidekiq/background workers that drive Turbo Streams
  • Stimulus-compatible uptime badge component
  • Alert channels (email and Slack)

Prerequisites

  • A Hotwire-powered app (Rails 7+, or another backend with @hotwired/turbo and @hotwired/stimulus)
  • A free Vigilmon account

Step 1: Add a Comprehensive Health Endpoint

Hotwire's real-time features rely on Redis and Action Cable in addition to the database. Your health check should validate the entire stack, not just HTTP.

Rails 7+ (canonical Hotwire stack)

# config/routes.rb
get "/health", to: "health#show"

# app/controllers/health_controller.rb
class HealthController < ApplicationController
  # Allow unauthenticated access for monitoring
  skip_before_action :authenticate_user!, raise: false

  def show
    checks = {}
    degraded = false

    # PostgreSQL / MySQL
    begin
      ActiveRecord::Base.connection.execute("SELECT 1")
      checks[:database] = "ok"
    rescue => e
      checks[:database] = "error: #{e.message}"
      degraded = true
    end

    # Redis (Turbo Streams + Action Cable)
    begin
      redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
      redis.ping
      checks[:redis] = "ok"
    rescue => e
      checks[:redis] = "error: #{e.message}"
      degraded = true
    end

    # Sidekiq queue depth (optional warning threshold)
    begin
      queue_size = Sidekiq::Queue.new.size
      checks[:sidekiq_queue] = queue_size < 1000 ? "ok" : "warning: #{queue_size} jobs"
    rescue => e
      checks[:sidekiq_queue] = "error: #{e.message}"
    end

    # Active Storage (if used for file uploads)
    begin
      ActiveStorage::Blob.count
      checks[:storage] = "ok"
    rescue => e
      checks[:storage] = "error: #{e.message}"
      degraded = true
    end if defined?(ActiveStorage)

    render json: {
      status: degraded ? "degraded" : "ok",
      timestamp: Time.current.iso8601,
      checks: checks,
      framework: "hotwire",
      turbo: Turbo::VERSION,
    }, status: degraded ? :service_unavailable : :ok
  end
end

Django + django-turbo-helper (Python)

# urls.py
from django.urls import path
from . import views

urlpatterns = [
    path("health/", views.health_check, name="health"),
]

# views.py
from django.http import JsonResponse
from django.db import connection
import redis as redis_lib
import os

def health_check(request):
    checks = {}
    degraded = False

    # Database
    try:
        with connection.cursor() as cursor:
            cursor.execute("SELECT 1")
        checks["database"] = "ok"
    except Exception as e:
        checks["database"] = f"error: {e}"
        degraded = True

    # Redis
    try:
        r = redis_lib.from_url(os.environ.get("REDIS_URL", "redis://localhost:6379/0"))
        r.ping()
        checks["redis"] = "ok"
    except Exception as e:
        checks["redis"] = f"error: {e}"
        degraded = True

    return JsonResponse(
        {
            "status": "degraded" if degraded else "ok",
            "framework": "hotwire",
            "checks": checks,
        },
        status=503 if degraded else 200,
    )

Test it:

curl http://localhost:3000/health | jq .
# {
#   "status": "ok",
#   "timestamp": "2026-07-03T...",
#   "checks": {"database": "ok", "redis": "ok", "sidekiq_queue": "ok"},
#   "framework": "hotwire",
#   "turbo": "7.x.x"
# }

Step 2: Configure Vigilmon HTTP Monitors

Log in to VigilmonMonitors → New Monitor.

Monitor 1: App Root

| Field | Value | |---|---| | URL | https://yourapp.com/ | | Method | GET | | Expected status | 200 | | Expected body | A stable string (e.g. data-controller or your app title) | | Check interval | 1 minute |

Monitor 2: Health Endpoint

| Field | Value | |---|---| | URL | https://yourapp.com/health | | Method | GET | | Expected status | 200 | | Expected body | "status":"ok" | | Check interval | 1 minute |

Two monitors, two failure domains. The root check catches Puma/Nginx failures; the health check catches database and Redis failures that would break Turbo Streams silently.


Step 3: Heartbeat for Background Workers

Turbo Streams and real-time Hotwire features typically rely on Sidekiq (or another job processor) broadcasting updates. A Vigilmon heartbeat catches silent worker failures before your users notice frozen live feeds.

Sidekiq recurring job

# app/jobs/hotwire_sync_job.rb
class HotwireSyncJob < ApplicationJob
  queue_as :default

  def perform
    # Your actual sync/broadcast logic
    LiveFeedService.broadcast_updates!

    # Ping heartbeat only on success
    heartbeat_url = ENV["VIGILMON_HEARTBEAT_URL"]
    if heartbeat_url.present?
      uri = URI(heartbeat_url)
      Net::HTTP.get(uri)
    end
  rescue => e
    Rails.logger.error "[HotwireSyncJob] Failed: #{e.message}"
    # No heartbeat ping → Vigilmon alerts after the grace window expires
    raise
  end
end

Configure a recurring schedule (using sidekiq-cron or good_job):

# config/schedule.yml (sidekiq-cron)
hotwire_sync:
  cron: "*/5 * * * *"
  class: "HotwireSyncJob"
  queue: default

Set up the heartbeat in Vigilmon:

  1. Monitors → New Heartbeat Monitor
  2. Expected interval: 10 minutes (2× the 5-minute cron)
  3. Copy the ping URL and add to environment:
VIGILMON_HEARTBEAT_URL=https://vigilmon.online/api/heartbeat/xxxxxxxx

Step 4: Add an Uptime Badge with a Stimulus Controller

Use a lightweight Stimulus controller to lazy-load the Vigilmon badge — in the Hotwire spirit of progressive enhancement.

// app/javascript/controllers/uptime_badge_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = {
    badgeUrl: String,
    statusUrl: String
  }

  connect() {
    const img = document.createElement("img")
    img.src = this.badgeUrlValue
    img.alt = "Uptime"
    img.width = 120
    img.height = 20
    img.style.opacity = "0"
    img.style.transition = "opacity 0.2s"
    img.addEventListener("load", () => { img.style.opacity = "1" })

    const link = document.createElement("a")
    link.href = this.statusUrlValue
    link.target = "_blank"
    link.rel = "noopener noreferrer"
    link.setAttribute("aria-label", "Service uptime status")
    link.appendChild(img)

    this.element.appendChild(link)
  }
}

Register the controller (Stimulus 3 with importmap or bundler):

// app/javascript/controllers/index.js
import { application } from "./application"
import UptimeBadgeController from "./uptime_badge_controller"

application.register("uptime-badge", UptimeBadgeController)

Add the element to your layout:

<!-- app/views/layouts/application.html.erb -->
<footer>
  <div data-controller="uptime-badge"
       data-uptime-badge-badge-url-value="https://vigilmon.online/api/badge/your-monitor-id.svg"
       data-uptime-badge-status-url-value="https://vigilmon.online/status/your-monitor-slug">
  </div>
</footer>

Because Turbo Drive preserves the layout between navigations, the badge loads once and persists for the user's entire session.


Step 5: Set Up Alert Channels

In Vigilmon → Alert Channels:

Email

  1. Alert Channels → Email → add your on-call address
  2. Attach to both HTTP monitors and the heartbeat

Slack

  1. Create a Slack Incoming Webhook for #alerts
  2. Alert Channels → Webhook → paste the URL
  3. Payload template:
{
  "text": "🚨 *{{ monitor.name }}* is DOWN\n{{ monitor.url }}\nStatus: {{ event.status }}\n<https://vigilmon.online|Open Vigilmon>"
}

Step 6: Verify the Entire Hotwire Stack

# 1. Health endpoint returns 200 with all checks green
curl -s https://yourapp.com/health | jq .

# 2. Stop Redis → confirm health returns 503 with redis error
# Expected: Vigilmon alerts within 2 minutes (catches silent Turbo Stream failures)

# 3. Stop Sidekiq → wait for heartbeat window to expire
# Expected: heartbeat alert fires before users notice frozen live updates

# 4. Vigilmon → "Test Alert" → verify email and Slack delivery

# 5. Navigate between pages in your Hotwire app
# Expected: badge remains visible (Turbo Drive preserves layout)

Summary

| Monitor | What It Catches | |---|---| | HTTP root check | Puma/Nginx/server failures, broken deploys | | HTTP health endpoint | Database, Redis, and Action Cable failures | | Heartbeat | Silent Sidekiq failures, frozen Turbo Stream broadcasts |


Next Steps

  • Add a dedicated Sidekiq Web UI monitor for job queue depth visibility
  • Use Vigilmon's response time graphs to correlate Stimulus controller latency with Action Cable broadcast timing
  • Configure separate monitors for your Action Cable WebSocket endpoint
  • Set up multi-channel alerting: immediate Slack for Redis failures, email digest for queue depth warnings

Ready to get started? Sign up for Vigilmon free — have your first monitor running in under 5 minutes.

Monitor your app with Vigilmon

Free plan — 5 monitors, no credit card required. Up and running in 60 seconds.

Start free →