Engineer’s Guide to Cursor AI: Mastering the AI-First IDE in 2026

A comprehensive technical deep-dive into Cursor’s architecture, capabilities, and how to wield it effectively.

1. How Cursor Started: From MIT Dorm to $10B Valuation

Cursor didn’t emerge from a traditional enterprise software company. It was born in 2022 when four MIT studentsโ€”Michael Truell, Sualeh Asif, Arvid Lunnemark, and Aman Sangerโ€”founded Anysphere with a contrarian thesis: instead of bolting AI onto existing editors (like GitHub Copilot’s extension approach), they would build an AI-first code editor from the ground up.

The key architectural bet was simple but profound: fork VS Code and embed AI into the core runtime rather than as an extension. This meant AI could access the full editor contextโ€”open files, project structure, terminal output, and git stateโ€”without the latency and permission boundaries that plague extension-based assistants.

By September 2023, Cursor had raised $8M from OpenAI (with $11M total), with the founders publicly stating their ambition: “a code editor that makes it nearly impossible to write bugs” and enables developers to “create thousands of lines of code with minimal input.”

They entered a market dominated by Copilot (launched 2021) and newcomers like Codeium and Tabnine. Their differentiator wasn’t just better modelsโ€”it was multi-file editing. Early AI models were too slow for this; by the time GPT-4 and Claude arrived, Cursor’s architecture was ready to exploit them.


2. The AI Era of Coding

We’re past the “AI-assisted coding” phase. We’re in the AI-augmented development era, where the question isn’t whether to use AI, but how to integrate it into your workflow without sacrificing quality, security, or control.

The shift has three dimensions:

  1. From autocomplete to agents: Tab completion (Copilot-style) is table stakes. The frontier is autonomous agents that plan, execute, and verify multi-step tasks across your codebase.
  2. From single-file to codebase-aware: Context windows have exploded (100K+ tokens). AI can now reason over entire repos, not just the file you’re editing.
  3. From chat to tool use: AI assistants now call APIs, run terminals, query databases, and control browsers via protocols like MCP (Model Context Protocol).

Developers who treat AI as “fancy autocomplete” are leaving 3โ€“5x productivity gains on the table. Those who learn to orchestrate agents, rules, and context effectively are redefining what “senior engineer” means.


3. Cursor in 2024 / 2025: The Acceleration

2024: Composer, Agents, and Context

  • Composer with Agent (Novโ€“Dec 2024): Sidebar UI with inline diffs; agents that pick their own context and use the terminal.
  • Yolo Mode: Agents auto-run terminal commands with exit code visibility.
  • @Context References: @docs, @git, @web, @folder, @Lint Errors for flexible context selection.
  • Commit message generation: Automatic git commit messages from diffs.

2025: Agent as Default, Rules, and Model Explosion

  • Agent as Default (v0.46, Feb 2025): Chat, Composer, and Agent unified into one interface. Agent is now the primary mode.
  • .cursor/rules & Project Rules: Repository-level rules in .cursor/rules that agents automatically apply. Visual indicators show when rules are active.
  • Web Search Integration: Agents automatically search the web for current information without explicit commands.
  • Deepseek Support: Deepseek R1 and v3 models, self-hosted in the US.
  • Fusion Tab Model: Cursor-trained model for code jumps and long contextโ€”~100ms faster completions, 30% reduced time-to-first-token.
  • Credit-based billing (June 2025): Shift from request-based to credit-based pricing.

4. How Cursor Works and the Models It Provides

Architecture Overview

Cursor is a fork of VS Code with AI baked into the runtime. Key components:

  • Tab (inline completions): Real-time suggestions as you type. Uses Cursor’s proprietary Fusion model and third-party models.
  • Agent / Composer: Multi-turn, multi-file editing. Can read files, run commands, apply edits, and iterate.
  • Chat: Conversational interface for questions, explanations, and quick edits.
  • Rules & Skills: Declarative rules (.cursor/rules, RULE.md) and procedural skills (SKILL.md) that shape agent behavior.

Model Lineup (2025โ€“2026)

ModelUse CaseNotes
GPT-4oGeneral coding, fast completionsOpenAI’s flagship
Claude Sonnet 3.5 / OpusComplex reasoning, long contextAnthropic
Gemini ProAlternative for completionsGoogle
Deepseek R1 / v3Cost-effective, strong reasoningSelf-hosted in US
Cursor ComposerMulti-file edits, agent tasksProprietary, fine-tuned
Cursor Fusion TabInline completionsProprietary, low latency
Codebase UnderstandingSemantic search, contextProprietary

Pricing Tiers (2026)

  • Hobby (Free): 2,000 completions, 50 slow premium requests/month.
  • Pro ($20/mo): 500 fast premium requests, unlimited slow, $20 API agent usage.
  • Pro Plus ($60/mo): 1,500 fast agent requests, extended context.
  • Ultra ($200/mo): 5,000 fast requests, unlimited Max Mode, experimental models.
  • Business ($40/user/mo): Privacy mode, SSO, admin dashboard.
  • Enterprise: Custom pricing, audit logs, dedicated support.

5. Using Cursor Efficiently: MCP, Agents, Code Reviews, and More

MCP (Model Context Protocol)

MCP is an open standard (Anthropic, late 2024) that acts as a “USB-C port for AI”โ€”letting Cursor connect to external tools and data sources. Instead of pasting API docs or database schemas into chat, you configure MCP servers that the agent can query directly.

Components:

  • Server: Bridge to tools (databases, APIs, browsers).
  • Client: Cursor’s engine deciding when to call tools.
  • Host: Cursor’s UI.

Transports: Stdio (local, low latency) and SSE (remote, team sharing).

High-leverage integrations:

  • Browser control: Navigate, click, fill forms for E2E testing.
  • Databases: Postgres, Supabaseโ€”query and reason over schema.
  • Linear, GitHub, Jira: Create tickets, PRs, link context.
  • Figma: Pull design context, screenshots for implementation.

Best practices:

  • Use .cursorrules to govern when agents use tools.
  • Never hardcode API keys; use env vars.
  • Avoid connecting too many toolsโ€”performance degrades.
  • MCP can reduce token consumption by 18โ€“37% by fetching only what’s needed.

Agents and Subagents

Agents are the primary interface in Cursor. They:

  • Plan multi-step tasks.
  • Read files, run terminal commands, apply edits.
  • Use subagents for parallel work (research, terminal, specialized tasks).

Subagents (v2.4, Jan 2026): Independent agents for discrete subtasks. Run in parallel, use their own context. Can spawn their own subagents (tree structure). Enables larger refactors and multi-file features.

Skills (SKILL.md): Procedural “how-to” instructions. Better for dynamic context than always-on rules. Invoke via slash commands when relevant.

Rules and Project Context

  • .cursor/rules: Directory of rule files. Agents automatically apply these. Use for conventions, architecture decisions, testing requirements.
  • RULE.md: File-specific or project-level rules.
  • CLAUDE.md: Documentation for AI (common in open source). Cursor respects these.

Example rule:

Always use .to_cents for legacy decimal money values.
Never add noLayout meta to admin pages.
Create specs for all new classes.

Code Reviews

  • Inline diffs: Agent shows changes in sidebar before applying.
  • Cursor Blame (Enterprise): AI attributionโ€”see what’s AI-generated vs human-written, with links to the conversation that produced each line.
  • Plan mode: Agent generates a plan; you approve before execution. Use /plan to revisit.

Practical Workflow Tips

  1. Start with rules: Add .cursor/rules before heavy agent use. Saves iterations.
  2. Use @-mentions: @file, @folder, @docs, @web to scope context.
  3. Agent for refactors, Tab for flow: Use agents for multi-file work; Tab for fast inline completion during focused coding.
  4. Lock browser before interactions: If using browser MCPโ€”navigate first, then lock, then interact, then unlock.
  5. Skills for domain logic: Create SKILL.md for project-specific workflows (e.g., “How we deploy,” “How we run migrations”).

6. Major Competitors

ToolPositioningStrengthsWeaknesses
GitHub CopilotExtension, ecosystem20M+ users, tight GitHub integration, multi-turn chat, agent modeLess codebase-aware than Cursor; extension limits
Windsurf (ex-Codeium)Budget-friendlyGenerous free tier, Cascade agent mode ($15/mo)Smaller ecosystem, less mature
Claude CodeTerminal + IDE integrationBest-in-class reasoning, SWE-bench 72โ€“79%, flexible architecturePay-per-use can spike; terminal-first
Amazon Q DeveloperAWS-centricFree tier, AWS integrationNarrow for non-AWS work
TabninePrivacy-focusedSelf-hosting, on-premLess capable agents
Sourcegraph CodyEnterprise codebaseOptimized for large reposSmaller feature set

Cursor’s niche: Full IDE with deepest codebase integration, multi-model support, and agent-first design. Best for teams that want one tool for daily coding and complex refactors.


7. Advantages Over Claude Code (Claude Co-Work)

DimensionCursorClaude Code
InterfaceFull IDE (VS Code fork)Terminal + IDE extension
SetupInstall and goAPI keys, extension setup
Cost predictabilityFlat $20/mo ProPay-per-use (~$3/M input tokens); can hit $300+ in hours
OfflineTab completions work offlineRequires API
ContextNative codebase indexingRelies on IDE extension context
WorkflowSingle environment for edit, run, reviewSplit between terminal and editor
MCPBuilt-in, rich ecosystemVia extension

Choose Cursor when: You want an all-in-one IDE, predictable costs, and minimal setup. Ideal for daily coding flow.


8. Drawbacks vs. Claude Code

DimensionCursorClaude Code
Reasoning qualityStrong, but Claude Code leads on complex tasksBest-in-class; SWE-bench 72โ€“79% vs Cursor ~62%
Refactoring scaleExcellentSlightly better for large-scale, multi-repo refactors
Editor choiceLocked to Cursor (VS Code fork)Use any editor; terminal-first
Model flexibilityCursor’s model selectionDirect Anthropic API; latest Claude first
Autonomous operationAgent + sandboxCheckpoints, background tasks, subagentsโ€”more mature
Cost at scale$20โ€“200/mo fixedCan be cheaper for light use; expensive for heavy

Choose Claude Code when: You need maximum reasoning quality, want to keep your current editor, or have variable usage (pay only when you use it).

Many senior engineers use both: Cursor for daily flow; Claude Code for major refactors and complex debugging.


9. Alternatives to Consider in 2026

  • GitHub Copilot Pro+ ($39/mo): If you’re deep in GitHub/GitHub Actions. Access to Claude Opus 4, GPT-5. Best ecosystem fit.
  • Windsurf (free / $15 Cascade): If budget is primary. Solid free tier, capable agent mode.
  • Claude Code + VS Code extension: If you prioritize reasoning and editor flexibility. Pay-per-use.
  • Amazon Q Developer: If you’re AWS-native. Free tier, good for AWS-specific tasks.
  • Tabnine: If you need self-hosting or strict data residency.
  • Continue.dev: Open-source, self-hostable. For teams that want full control.

Stack strategy: Cursor for primary IDE + Claude Code for hard problems + Copilot for GitHub-centric workflows is a common “power user” setup.


10. The Future of Cursor and How Developers Will Use It

Where Cursor Is Heading (2026+)

  • Cloud agents with computer use (Feb 2026): Agents run in isolated VMs, produce merge-ready PRs with videos/screenshots. Available on web, desktop, mobile, Slack, GitHub.
  • Long-running agents: Plan-first execution for larger tasks. Fewer follow-ups, more complete PRs.
  • Marketplace plugins: Pre-built integrations (Amplitude, AWS, Figma, Linear, Stripe) installable via /add-plugin.
  • Self-driving codebases: Multi-agent research harness in preview.
  • Composer 1.5: 20x scaling of reinforcement learning for reasoning.
  • Agent sandboxing: Granular network and filesystem controls for security.

How Developers Will Use It

  1. Orchestration over authorship: Senior engineers will spend more time defining rules, skills, and MCP integrations than writing boilerplate. The agent writes; the human directs.
  2. Review as primary skill: With Cursor Blame and inline diffs, code review becomes the highest-leverage activity. Understanding why AI made a change matters more than writing the change.
  3. Domain-specific skills: Teams will maintain SKILL.md libraries for their stack (e.g., “How we do auth,” “How we deploy to Fly.io”).
  4. Hybrid local + cloud: Local agent for fast iteration; cloud agent for CI-like verification and demos.
  5. Cursor as platform: Plugins and MCP will turn Cursor into a hub for design, analytics, deploymentโ€”not just coding.

The Bottom Line

Cursor has reached $500M ARR and a $10B valuation by 2025. Enterprise adoption (Stripe, Box, NVIDIA) reports 30โ€“50% roadmap throughput gains. The question for 2026 isn’t whether to use AIโ€”it’s whether you’re using it as a junior engineer (tab completion only) or as a senior engineer (agents, rules, MCP, and strategic tool choice).

Master Cursor’s rules, MCP, and agent workflows. Pair it with Claude Code for hard problems. Keep your skills sharp on review and architecture. The developers who do this will define the next decade of software engineering.


Written from the perspective of a senior software engineer who has shipped production systems with Cursor, Claude Code, and Copilot.

Sidekiq & Redis Optimization: Reducing Overhead and Scaling Worker Jobs

When you run thousands of background jobs through Sidekiq, Redis becomes the bottleneck. Every job enqueue adds Redis writes, network round-trips, and memory pressure. This post covers a real-world optimization we applied and a broader toolkit for keeping Sidekiq lean.


The Problem: One Job Per Item

Imagine sending weekly emails to 10,000 users. The naive approach:

# โŒ Bad: 10,000 Redis writes, 10,000 scheduled entries
user_ids.each do |id|
WeeklyEmailWorker.perform_async(id)
end

Each perform_async does:

  • A Redis LPUSH (or ZADD for scheduled jobs)
  • Serialization of job payload
  • Network round-trip

At 10,000 users, that’s 10,000 Redis operations and 10,000 scheduled entries. At 1M users, that’s 1M scheduled jobs in Redis. That’s expensive and slow.


The Fix: Batch + Staggered Scheduling

Instead of one job per user, we batch users and schedule each batch with a small delay:

# โœ… Good: 100 Redis writes, 100 scheduled entries
BATCH_SIZE = 100
BATCH_DELAY = 0.2 # seconds
pending_user_ids.each_slice(BATCH_SIZE).with_index do |batch_ids, batch_index|
delay_seconds = batch_index * BATCH_DELAY
WeeklyEmailByWorker.perform_in(delay_seconds, batch_ids)
end

What this achieves:

MetricBefore (1 per user)After (batched)
Redis ops10,000100
Scheduled jobs10,000100
Scheduled jobs at 1M users1,000,00010,000

Each worker still processes one user at a time internally, but we only enqueue one job per batch. Redis overhead drops by roughly 100x.

Why perform_in instead of chaining?

  • perform_in(delay, batch_ids) โ€” all jobs are scheduled immediately with their future timestamps. Sidekiq moves them into the ready queue at the right time regardless of other queue traffic.
  • Chaining (each job enqueues the next) โ€” the next batch only enters the queue after the current one finishes. If other jobs are busy, your email chain sits behind them and can be delayed significantly.

For time-sensitive jobs like “send at 8:46 AM local time,” upfront scheduling is the right choice.


Other Sidekiq Optimization Strategies

1. Bulk Enqueue (Sidekiq Pro/Enterprise)

Sidekiq::Client.push_bulk pushes many jobs in one Redis call:

# Single Redis call instead of N
Sidekiq::Client.push_bulk(
'class' => WeeklyEmailWorker,
'args' => user_ids.map { |id| [id] }
)

Useful when you don’t need per-job delays and want to minimize Redis round-trips.

2. Adjust Concurrency

Default is 10 threads per process. More threads = more concurrency but more memory:

# config/sidekiq.yml
:concurrency: 25 # Tune based on CPU/memory

Higher concurrency helps if jobs are I/O-bound (HTTP, DB, email). For CPU-bound jobs, lower concurrency is usually better.

3. Use Dedicated Queues

Separate heavy jobs from light ones:

# config/sidekiq.yml
:queues:
- [critical, 3] # 3x weight
- [default, 2]
- [low, 1]

Critical jobs get more CPU time. Low-priority jobs don’t block the rest.

4. Rate Limiting (Sidekiq Enterprise)

Throttle jobs that hit external APIs:

class EmailWorker
include Sidekiq::Worker
sidekiq_options throttle: { threshold: 100, period: 1.minute }
end

Prevents hitting rate limits and keeps Redis usage predictable.

5. Unique Jobs (sidekiq-unique-jobs)

Avoid duplicate jobs for the same work:

sidekiq_options lock: :until_executed, on_conflict: :log

Reduces redundant work and Redis load when jobs are retried or triggered multiple times.

6. Dead Job Cleanup

Dead jobs accumulate in Redis. Set retention and cleanup:

# config/initializers/sidekiq.rb
Sidekiq.configure_server do |config|
config.death_handlers << ->(job, ex) {
# Log, alert, or move to DLQ
}
end

Use dead_max_jobs and periodic cleanup so Redis doesn’t grow unbounded.

7. Job Size Limits

Large payloads increase Redis memory and serialization cost:

# Keep payloads small; pass IDs, not full objects
WeeklyEmailWorker.perform_async(user_id) # โœ…
WeeklyEmailWorker.perform_async(user.to_json) # โŒ

8. Connection Pooling

Ensure each worker process has a bounded Redis connection pool:

# config/initializers/sidekiq.rb
Sidekiq.configure_server do |config|
config.redis = { url: ENV['REDIS_URL'], size: 25 }
end

Prevents connection exhaustion under load.

9. Scheduled Job Limits

Scheduled jobs live in Redis. If you schedule millions of jobs, you may need to cap or paginate:

# Avoid scheduling 1M jobs at once
# Use batch + perform_in with reasonable batch sizes

10. Redis Memory and Eviction

Configure Redis for Sidekiq:

maxmemory 2gb
maxmemory-policy noeviction # or volatile-lru for cache-only keys

Monitor memory and eviction to avoid unexpected data loss.


Summary

StrategyWhen to Use
Batch + perform_inMany similar jobs at a specific time; reduces Redis ops by ~100x
push_bulkLarge batches of jobs without per-job delays
Dedicated queuesDifferent priority levels for job types
Rate limitingExternal APIs or rate-limited services
Unique jobsIdempotent or duplicate-prone jobs
Small payloadsAlways; pass IDs instead of full objects
Connection poolingHigh concurrency or many processes

The batch + perform_in pattern is especially effective for time-sensitive jobs that must run in a narrow window while keeping Redis overhead low.

Happy Coding with Sidekiq!


Fixing Let’s Encrypt SSL Certificate Renewal on a Server: A Step-by-Step Guide

Background

We recently provisioned two new staging environments (staging1.mydomain.com and staging2.mydomain.com) mirroring our production Rails infrastructure. Production uses a Cloudflare load balancer fronting multiple origin servers running nginx, with a cron-driven script to renew Let’s Encrypt certificates across all origins.

When we added the staging environments, the existing renew_certificate.sh cron script wasn’t set up on them yet โ€” so the certificates expired. This post documents everything we encountered trying to fix it, every error we hit, and how we resolved each one.


The Renewal Architecture

Before diving in, it’s worth understanding how SSL renewal works in this setup:

Cloudflare (DNS + Load Balancer)
โ†“
nginx (origin server)
/apps/mydomain/current โ† Rails app lives here
/etc/letsencrypt/live/ โ† Certs live here

The renewal script (scripts/cron/renew_certificate.sh) does the following:

  1. Fetches the load balancer pool config from the Cloudflare API
  2. Disables all origin servers in the pool except the GCP instance (takes servers out of rotation)
  3. Turns off Cloudflare’s “Always Use HTTPS” setting (allows HTTP for the ACME challenge)
  4. Runs sudo certbot renew locally
  5. Copies the new cert to all other origin servers via SCP and SSH
  6. Re-enables all origin servers in the Cloudflare pool
  7. Re-enables “Always Use HTTPS”

The problem: this script was never added to the cron on staging1/staging2, so the certs expired.


First Attempt: Running the Renewal Script Manually

SSH’d into staging2 and ran:

bash /apps/mydomain/current/scripts/cron/renew_certificate.sh

Error #1: RSpec / webmock LoadError

An error occurred while loading spec_helper. - Did you mean?
rspec ./spec/helper.rb
Failure/Error: require 'webmock/rspec'
LoadError:
cannot load such file -- webmock/rspec

What happened: The script calls bundle exec rake google_chat:send_message[...] to send failure notifications to Google Chat. On staging, test gems like webmock aren’t installed in the bundle, so the rake task blew up loading the Rails environment.

Lesson: This is a notification side-effect, not the core renewal logic. But it masked the real error.

Error #2: certbot failing because port 80 was in use

After isolating the issue, running sudo certbot renew directly gave:

Renewing an existing certificate for staging2.mydomain.ca and www.staging2.mydomain.ca
Failed to renew certificate staging2.mydomain.ca with error: Could not bind TCP port 80
because it is already in use by another process on this system (such as a web server).
Please stop the program in question and then try again.

What happened: The original certificate was issued using certbot’s standalone authenticator, which spins up its own HTTP server on port 80 to answer the ACME challenge. Since nginx was already running on port 80, the renewal failed.

Meanwhile there was a second certificate (staging2.mydomain.ca-0001) that had been created earlier with sudo certbot --nginx -d staging2.mydomain.ca. This cert was valid โ€” but it created a mess.


Inspecting the Damage

sudo certbot certificates

Output:

Renewal configuration file /etc/letsencrypt/renewal/staging2.mydomain.ca.conf produced
an unexpected error: expected /etc/letsencrypt/live/staging2.mydomain.ca-0001/cert.pem
to be a symlink. Skipping.
The following renewal configurations were invalid:
/etc/letsencrypt/renewal/staging2.mydomain.ca.conf

The nginx config at /etc/nginx/sites-enabled/mydomain was also a mess โ€” certbot had injected its own server block for the HTTPโ†’HTTPS redirect, and the two 443 server blocks were pointing to different cert paths:

# Certbot-injected block (unwanted)
server {
if ($host = staging2.mydomain.ca) {
return 301 https://$host$request_uri;
} # managed by Certbot
...
}
# Redirect server pointing to -0001 certs (also unwanted)
server {
server_name staging2.mydomain.ca;
listen 443 ssl http2;
ssl_certificate /etc/letsencrypt/live/staging2.mydomain.ca-0001/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/staging2.mydomain.ca-0001/privkey.pem; # managed by Certbot
...
}
# Main www server pointing to original path
server {
server_name www.staging2.mydomain.ca;
listen 443 ssl http2;
ssl_certificate /etc/letsencrypt/live/staging2.mydomain.ca/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/staging2.mydomain.ca/privkey.pem;
...
}

The Fix

Step 1: Remove all broken certbot state

sudo rm -f /etc/letsencrypt/renewal/staging2.mydomain.ca.conf
sudo rm -f /etc/letsencrypt/renewal/staging2.mydomain.ca-0001.conf
sudo rm -rf /etc/letsencrypt/live/staging2.mydomain.ca
sudo rm -rf /etc/letsencrypt/live/staging2.mydomain.ca-0001
sudo rm -rf /etc/letsencrypt/archive/staging2.mydomain.ca
sudo rm -rf /etc/letsencrypt/archive/staging2.mydomain.ca-0001

Step 2: Stop nginx and get a fresh cert with standalone authenticator

sudo service nginx stop
sudo certbot certonly --standalone -d staging2.mydomain.ca -d www.staging2.mydomain.ca
sudo service nginx start

This gave us a clean, single certificate at /etc/letsencrypt/live/staging2.mydomain.ca/.

Step 3: Clean up the nginx config

Removed the certbot-injected if ($host = ...) server block, and updated both 443 server blocks to point to the same cert path:

ssl_certificate /etc/letsencrypt/live/staging2.mydomain.ca/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/staging2.mydomain.ca/privkey.pem;

Reloaded nginx:

sudo service nginx reload

The site was live again with a valid cert.


Making Future Renewals Work Without Stopping nginx

The next problem: the cert renewal config was still using standalone authenticator. Future automated renewals would fail again the moment nginx was running.

The fix is to switch to the webroot authenticator. Our nginx config already had an ACME challenge location block:

location ^~ /.well-known/acme-challenge/ {
root /apps/certbot;
default_type "text/plain";
allow all;
}

This means certbot can write a challenge file to /apps/certbot and nginx will serve it over HTTP โ€” no need to stop nginx.

Attempt 1: Manually edit the renewal config

Edited /etc/letsencrypt/renewal/staging2.mydomain.ca.conf:

[renewalparams]
authenticator = webroot
server = https://acme-v02.api.letsencrypt.org/directory
key_type = ecdsa
[[webroot]]
staging2.mydomain.ca = /apps/certbot
www.staging2.mydomain.ca = /apps/certbot

Dry run:

sudo certbot renew --dry-run

Error #3: webroot mapping not found

Failed to renew certificate staging2.mydomain.ca with error: Missing command line flag or
config entry for this setting:
Input the webroot for staging2.mydomain.ca:

The config looked correct but certbot was still asking interactively. This is a known certbot quirk โ€” manually converting a standalone config to webroot doesn’t always work reliably because of how certbot parses its internal config format.

Attempt 2: Delete and re-issue with webroot from the start (this worked)

sudo certbot delete --cert-name staging2.mydomain.ca
sudo mkdir -p /apps/certbot
sudo certbot certonly --webroot -w /apps/certbot \
-d staging2.mydomain.ca \
-d www.staging2.mydomain.ca

This time certbot generated the renewal config correctly itself. Dry run:

sudo certbot renew --dry-run
Simulating renewal of an existing certificate for staging2.mydomain.ca and www.staging2.mydomain.ca
Congratulations, all simulated renewals succeeded:
/etc/letsencrypt/live/staging2.mydomain.ca/fullchain.pem (success)

Key Lessons

  1. Never run certbot --nginx on a server where you manage the nginx config manually. It injects its own server blocks and creates confusing duplicate certs with -0001 suffixes.
  2. Standalone vs webroot authenticator: Standalone is simpler to set up initially but requires stopping nginx. Webroot is the right choice for servers where nginx runs continuously โ€” as long as you have the ACME challenge location block configured.
  3. Manually editing certbot renewal configs is fragile. Let certbot generate the renewal config by passing the correct authenticator flags at issuance time.
  4. certbot renew --dry-run is your best friend. Always confirm future renewals will work before leaving the server. Discovering a broken renewal config 2 days before expiry is stressful.
  5. Let’s Encrypt ACME server outages are real but brief. If dry-run fails with “The service is down for maintenance”, check https://letsencrypt.status.io/ and retry in a few hours.

A Clean Auto-Renewal Script for nginx + webroot

Here’s a standalone script you can drop into any server using this stack. It handles renewal, nginx reload, and sends a notification if anything fails. It assumes the webroot authenticator is already configured in the certbot renewal config.

#!/bin/bash
# /etc/cron.d/certbot-renew or called via crontab
# Requires: certbot, nginx, curl (for Slack/Google Chat webhook)
set -euo pipefail
CERT_NAME="${CERT_NAME:-}" # e.g. staging2.mydomain.ca
NOTIFY_WEBHOOK="${NOTIFY_WEBHOOK:-}" # Slack or Google Chat webhook URL
ACME_WEBROOT="${ACME_WEBROOT:-/apps/certbot}"
LOG_FILE="/var/log/certbot-renew.log"
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
log() {
echo "[$TIMESTAMP] $*" | tee -a "$LOG_FILE"
}
notify() {
local message="$1"
log "NOTIFY: $message"
if [[ -n "$NOTIFY_WEBHOOK" ]]; then
curl -s -X POST "$NOTIFY_WEBHOOK" \
-H "Content-Type: application/json" \
-d "{\"text\": \"$message\"}" \
>> "$LOG_FILE" 2>&1 || true
fi
}
# Ensure webroot directory exists
mkdir -p "$ACME_WEBROOT"
log "Starting certificate renewal..."
# Attempt renewal
RENEW_OUTPUT=$(sudo certbot renew \
--quiet \
--non-interactive \
${CERT_NAME:+--cert-name "$CERT_NAME"} \
2>&1) || {
notify "SSL RENEWAL FAILED on $(hostname): $RENEW_OUTPUT"
log "ERROR: $RENEW_OUTPUT"
exit 1
}
# Check if any cert was actually renewed (certbot exits 0 even if nothing renewed)
if echo "$RENEW_OUTPUT" | grep -q "Congratulations"; then
log "Certificate renewed. Reloading nginx..."
sudo service nginx reload || {
notify "SSL RENEWAL WARNING on $(hostname): cert renewed but nginx reload failed!"
exit 1
}
notify "SSL cert successfully renewed on $(hostname)"
log "Done."
else
log "No certificates due for renewal. Nothing to do."
fi

Usage

# Set executable
chmod +x /usr/local/bin/certbot-renew.sh
# Set environment variables and run
CERT_NAME=staging2.mydomain.ca \
NOTIFY_WEBHOOK=https://chat.googleapis.com/v1/spaces/.../messages?key=... \
/usr/local/bin/certbot-renew.sh

Crontab entry (runs twice daily โ€” Let’s Encrypt recommendation)

0 3,15 * * * deployer CERT_NAME=staging2.mydomain.ca NOTIFY_WEBHOOK=https://... /usr/local/bin/certbot-renew.sh

Running twice daily ensures that if one attempt fails due to a transient ACME server issue, the next attempt 12 hours later will succeed โ€” giving you plenty of time before expiry.


Summary

ProblemRoot CauseFix
certbot failed to bind port 80standalone authenticator conflicted with nginxSwitch to webroot authenticator
Duplicate -0001 cert createdRan certbot --nginx after standalone cert existedDelete all cert state, re-issue cleanly
nginx serving expired certMixed cert paths after certbot injected its own configManually fix nginx config to consistent paths
Manual webroot config edit didn’t workCertbot’s conf format is fragile when converted manuallyDelete and re-issue with --webroot flag from scratch

Happy Debugging!


How to Integrate Datadog and PagerDuty into an Enterprise Rails Application – Part 2

Stack: Ruby 3+, Rails 7+
Audience: Backend engineers building or maintaining production-grade Rails services
Goal: Add real-time observability and on-call alerting to a critical business process

Part 3: Hooking It All Together โ€” Rake Task + Cron

3.1 Rake Task

Create lib/tasks/billing.rake:

namespace :billing do
desc "Run billing health check: emit Datadog metrics and alert if unhealthy"
task health_check: :environment do
Monitoring::BillingHealthCheck.new(
billing_week: BillingWeek.current
).run
end
end

Run it manually:

bundle exec rake billing:health_check

3.2 Cron Script

Create scripts/cron/billing_health_check.sh:

#!/bin/bash
source /apps/myapp/current/scripts/env.sh
bundle exec rake billing:health_check

Using Healthchecks.io (or similar) to wrap the cron gives you a second layer of alerting: if the cron doesn’t ping within the expected window, you get an alert – even if the app never starts.

3.3 Crontab Entry

# Run billing health check every Thursday at 5:30 AM
30 5 * * 4 . /apps/myapp/current/scripts/cron/billing_monitoring.sh

โš ๏ธ Important for managed deployments: If your crontab is version-controlled but not auto-deployed (e.g., Capistrano without cron management), changes to the file in your repo do not automatically update the server. Always verify with crontab -l after deploying.


Part 4: Building the Datadog Dashboard

Once metrics are flowing, set up a dashboard for at-a-glance visibility.

4.1 Create the Dashboard

  1. Datadog โ†’ Dashboards โ†’ New Dashboard
  2. Name it: “Billing Health Monitor”
  3. Click + Add Widgets

4.2 Add Timeseries Widgets

For each metric, add a Timeseries widget:

Widget titleMetricVisualization
Unbilled Ordersbilling.unbilled_ordersLine chart
Missing Billing Recordsbilling.missing_billing_recordsLine chart
Failed Chargesbilling.failed_chargesLine chart

Widget configuration:

  • Graph: select metric โ†’ billing.unbilled_orders
  • Display as: Line
  • Timeframe: Set to “Past 1 Week” or “Past 1 Month” after data starts flowing (not “Past 1 Hour” which shows nothing between weekly runs)

4.3 Add Reference Lines (Optional but Useful)

For the unbilled orders widget, add a constant line at your alert threshold:

  • In the widget editor โ†’ Markers โ†’ Add marker at y = 10 (your BILLING_UNBILLED_THRESHOLD)
  • Color it red to make the threshold visually obvious

4.4 Where to Find Your Custom Metrics


Part 5: Testing the Integration End-to-End

5.1 Test Datadog Metrics (no alerts, safe in any env)

# Rails console
require 'datadog/statsd'
host = ENV.fetch('DD_AGENT_HOST', '127.0.0.1')
statsd = Datadog::Statsd.new(host, 8125)
statsd.gauge('billing.unbilled_orders', 0)
statsd.gauge('billing.missing_billing_records', 0)
statsd.gauge('billing.failed_charges', 0)
statsd.close
puts "Sent โ€” check /metric/explorer in Datadog in ~2-3 minutes"

5.2 Test PagerDuty (staging)

# Rails console โ€” staging
# First, verify the key exists:
Rails.application.credentials[:staging][:pagerduty_billing_integration_key].present?
# Then trigger a test incident:
svc = Monitoring::BillingHealthCheck.new(billing_week: BillingWeek.current)
svc.send(:trigger_pagerduty, "TEST: Billing health check โ€” staging validation #{Time.current}")
# Remember to resolve the incident in PagerDuty UI immediately after!

5.3 Test PagerDuty (production) โ€” Preferred Method

Use PagerDuty’s built-in test instead of triggering from code:

  1. PagerDuty โ†’ Services โ†’ Billing Pipeline โ†’ Integrations
  2. Find the integration โ†’ click “Send Test Event”

This fires through the same pipeline without touching your app or risking a real alert.

5.4 Test PagerDuty (production) โ€” via Rails Console

If you must test via code in production, use a unique dedup key so it doesn’t collide with real billing alerts, and coordinate with your on-call engineer first:

svc = Monitoring::BillingHealthCheck.new(billing_week: BillingWeek.current)
Pagerduty::Wrapper.new(
integration_key: svc.send(:pagerduty_integration_key)
).client.incident("billing-health-test-#{Time.current.to_i}").trigger(
summary: "TEST ONLY โ€” please ignore โ€” integration validation",
source: "rails-console",
severity: "critical"
)

5.5 Test the Full Service Class (production, after billing has run)

Once billing has completed successfully for the week, all counts will be 0 and no PagerDuty alert will fire:

result = Monitoring::BillingHealthCheck.new(billing_week: BillingWeek.current).run
puts result
# => { unbilled_orders_count: 0, missing_billing_records_count: 0, failed_charges_count: 0, ... }

Common Gotchas

1. StatsD is Fire-and-Forget

UDP has no acknowledgment. If the agent isn’t running, your statsd.gauge() calls return normally with no error. Always verify the agent is reachable by checking for your metric in the Datadog UI after sending โ€” don’t rely on exception-free code as proof of delivery.

2. Metric Volume vs Metric Explorer

  • Metric Volume (/metric/volume): Confirms Datadog received the metric. Good for first-time setup verification.
  • Metric Explorer (/metric/explorer): Lets you actually graph and analyze the metric over time. This is where you do your monitoring work.

3. Rescue Around Everything

Both emit_datadog_metrics and trigger_pagerduty should have rescue blocks. Your monitoring code must never crash your main business process. The job that failed to alert is better than the job that crashed silently because the alert raised an exception.

def emit_datadog_metrics(results)
# ... emit metrics
rescue => e
Rails.logger.error("Failed to emit Datadog metrics: #{e.message}")
# Do NOT re-raise โ€” monitoring failure is never a reason to abort the job
end

4. Environment Parity for the Datadog Agent

In production the agent runs as a sidecar or daemon. In local development and staging, it often doesn’t. This is fine โ€” just make sure your code uses ENV.fetch('DD_AGENT_HOST', '127.0.0.1') so the host is configurable per environment, and don’t be alarmed when staging metrics don’t appear in Datadog.

5. PagerDuty Dedup Keys Prevent Double-Paging

If your cron job or health check can run more than once for the same underlying issue (retry logic, manual reruns), always use a stable dedup_key tied to the resource and time period โ€” not a timestamp. A timestamp-based key creates a new PagerDuty incident on every run.


Summary

ConcernToolHow
Custom business metricsDatadog StatsDDatadog::Statsd#gauge via local agent (UDP)
APM / request tracingDatadog ddtraceDatadog.configure initializer
Metric visualizationDatadog DashboardsTimeseries widgets per metric
Critical alert on failurePagerDuty Events API v2Pagerduty::Wrapper + dedup key
Secondary notificationGoogle Chat / Slack webhookHTTP POST to webhook URL
Scheduled executionCron + RakeShell script wrapping bundle exec rake
Cron liveness monitoringHealthchecks.ioPing before/after cron run

Both integrations together give you a complete observability loop: your scheduled jobs run on time, emit metrics to Datadog for trending and analysis, and page the right engineer via PagerDuty the moment something goes wrong โ€” before any customer notices.


Further Reading

Happy Integration!

How to Integrate Datadog and PagerDuty into an Enterprise Rails Application – Part 1

Stack: Ruby 3+, Rails 7+
Audience: Backend engineers building or maintaining production-grade Rails services
Goal: Add real-time observability and on-call alerting to a critical business process


Introduction

When you’re running an enterprise web application, two questions keep engineering teams up at night:

  1. “Is our system healthy right now?”
  2. “If something breaks at 3 AM, will we know before our customers do?”

Datadog and PagerDuty together answer both. Datadog gives you the metrics, dashboards, and visibility. PagerDuty turns critical metrics into actionable alerts that reach the right person at the right time. This post walks you through integrating both into a Rails 7+ application โ€” from gem installation to a live production dashboard โ€” using a real-world billing health monitor as the example.

What is Datadog?

Datadog is a cloud-based observability and monitoring platform. It collects metrics, traces, and logs from your infrastructure and applications and surfaces them in a unified UI.

Core capabilities relevant to Rails apps:

FeatureWhat it does
APM (Application Performance Monitoring)Traces every Rails request, shows latency, errors, and bottlenecks
StatsD / DogStatsDAccepts custom business metrics (gauges, counters, histograms) via UDP
DashboardsVisualize any metric over time โ€” single chart or full ops dashboard
Monitors & AlertsTrigger notifications when a metric crosses a threshold
Log ManagementCentralized log search and correlation with traces
Infrastructure MonitoringCPU, memory, disk โ€” the full host/container picture

For this guide, we focus on custom business metrics via DogStatsD โ€” the most powerful and underused feature for application teams.


What is PagerDuty?

PagerDuty is an incident management platform. When something breaks in production, PagerDuty decides who gets notified, how (phone call, SMS, push notification, Slack), and when to escalate if the alert isn’t acknowledged.

Key concepts:

ConceptDescription
ServiceA logical grouping of alerts (e.g., “Billing Service”)
Integration KeyThe secret key your app uses to send events to a PagerDuty service
IncidentA triggered alert that requires human acknowledgment
Dedup KeyA unique string that prevents duplicate incidents for the same root cause
Escalation PolicyDefines who gets paged and in what order if the incident isn’t acknowledged
Severitycritical, error, warning, or info

PagerDuty integrates with Datadog (you can alert from DD monitors), but for critical business logic alerts โ€” like a billing pipeline failing โ€” it’s often better to trigger PagerDuty directly from your application code, giving you full control over deduplication and context.


Why These Are Must-Have Integrations for Enterprise Apps

If you’re running any of the following, you need both:

  • Scheduled jobs / cron tasks that process money, orders, or user data
  • Background workers (Sidekiq, Delayed Job) that can silently fail
  • Third-party payment or fulfillment pipelines with no built-in alerting
  • SLAs that require uptime or processing guarantees
  • On-call rotations where the right person needs to be paged โ€” not just an email inbox

The core problem both solve: Rails applications fail silently. A rescue clause that logs an error to Rails.logger does nothing at 2 AM. A Sidekiq deadlock on your billing job won’t send you an email. Without Datadog and PagerDuty:

  • You find out about failures from customers, not dashboards
  • You can’t tell when a metric degraded or how long it’s been broken
  • There’s no escalation path โ€” the alert that fires at 3 AM goes nowhere

With both integrated, you get: visibility (Datadog) + accountability (PagerDuty).


Architecture Overview

Rails App / Cron Job
โ”‚
โ”œโ”€โ”€โ–บ Datadog Agent (UDP :8125)
โ”‚ โ””โ”€โ”€โ–บ Datadog Cloud โ”€โ”€โ–บ Dashboard / Monitor
โ”‚
โ””โ”€โ”€โ–บ PagerDuty Events API (HTTPS)
โ””โ”€โ”€โ–บ On-call Engineer โ”€โ”€โ–บ Slack / Phone / SMS

The Datadog Agent runs as a daemon on your server or as a sidecar container. Your app sends lightweight UDP packets to it (fire-and-forget). The agent batches and forwards them to Datadog’s cloud.

PagerDuty receives events over HTTPS directly from your app โ€” no local agent needed.


Part 1: Datadog Integration

1.1 Install the Gems

# Gemfile
gem 'ddtrace', '~> 2.0' # APM tracing
gem 'dogstatsd-ruby', '~> 5.0' # Custom metrics via StatsD
bundle install

1.2 Configure the Datadog Initializer

Create config/initializers/datadog.rb:

require 'datadog/statsd'
require 'datadog'
enabled = Rails.application.credentials[Rails.env.to_sym][:datadog_integration_enabled]
service_name = "myapp-#{Rails.env}"
Datadog.configure do |c|
c.tracing.enabled = enabled
c.runtime_metrics.enabled = enabled
c.tracing.instrument :rails, service_name: service_name
c.tracing.instrument :rake, enabled: false # avoid tracing long-running tasks
# Consolidate HTTP client spans under one service name to reduce noise
c.tracing.instrument :faraday, service_name: service_name
c.tracing.instrument :httpclient, service_name: service_name
c.tracing.instrument :http, service_name: service_name
c.tracing.instrument :rest_client, service_name: service_name
end

Store the flag in Rails credentials:

rails credentials:edit --environment production
# config/credentials/production.yml.enc
datadog_integration_enabled: true

Important: The datadog_integration_enabled flag controls APM tracing only. Custom StatsD metrics (gauges, counters) are sent by Datadog::Statsd regardless of this flag โ€” as long as the Datadog Agent is running.

1.3 Install and Configure the Datadog Agent

The Datadog Agent must be running on the host where your app runs. It listens for UDP packets on port 8125 and forwards them to Datadog’s cloud.

Docker Compose (recommended for containerized apps):

# docker-compose.yml
services:
app:
environment:
DD_AGENT_HOST: datadog-agent
DD_DOGSTATSD_PORT: 8125
datadog-agent:
image: datadog/agent:latest
environment:
DD_API_KEY: ${DATADOG_API_KEY}
DD_DOGSTATSD_NON_LOCAL_TRAFFIC: "true"
ports:
- "8125:8125/udp"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- /proc/:/host/proc/:ro
- /sys/fs/cgroup/:/host/sys/fs/cgroup:ro

Bare metal / VM:

DD_API_KEY=your_api_key bash -c "$(curl -L https://s3.amazonaws.com/dd-agent/scripts/install_script.sh)"

1.4 Emit Custom Business Metrics

Now the interesting part โ€” emitting metrics from your business logic.

Create a service class for a billing health check at app/lib/monitoring/billing_health_check.rb:

# frozen_string_literal: true
class Monitoring::BillingHealthCheck
UNBILLED_THRESHOLD = ENV.fetch('BILLING_UNBILLED_THRESHOLD', 10).to_i
def initialize(date:)
@date = date
end
def run
results = collect_metrics
fire_datadog_metrics(results)
alert_if_unhealthy(results)
results
end
private
def collect_metrics
billed_ids = BillingRecord.where(date: @date).pluck(:order_id)
missing_order_ids = billed_ids - Order.where(date: @date).ids
unbilled_count = Order.active.where(week: @date, billed: false).count
failed_charges = Order.joins(:bills)
.where(date: @date, billed: false, bills: { success: false })
.distinct
.count
{
missing_order_ids: missing_order_ids,
missing_order_records_count: missing_order_ids.size,
unbilled_orders_count: unbilled_count,
failed_charges_count: failed_charges
}
end
def fire_datadog_metrics(results)
host = ENV.fetch('DD_AGENT_HOST', '127.0.0.1')
port = ENV.fetch('DD_DOGSTATSD_PORT', 8125).to_i
statsd = Datadog::Statsd.new(host, port)
statsd.gauge('billing.unbilled_orders', results[:unbilled_orders_count])
statsd.gauge('billing.missing_billing_records', results[:missing_billing_records_count])
statsd.gauge('billing.failed_charges', results[:failed_charges_count])
statsd.close
rescue => e
Rails.logger.error("Failed to emit Datadog metrics: #{e.message}")
end
# ... alerting covered in Part 2
end

Why Datadog::Statsd.new(host, port) instead of Datadog::Statsd.new?

The no-argument form defaults to 127.0.0.1:8125. In containerized environments, the Datadog Agent runs as a separate container/service with a different hostname. Always read the host from an environment variable so the code works in every environment without changes.

1.5 Choosing the Right Metric Type

TypeMethodUse when
Gaugestatsd.gauge('name', value)Current snapshot value (queue depth, count at a point in time)
Counterstatsd.increment('name')Counting occurrences (requests, errors)
Histogramstatsd.histogram('name', value)Distribution of values (response times, batch sizes)
Timingstatsd.timing('name', ms)Duration in milliseconds

For billing health metrics โ€” unbilled orders, failed charges โ€” gauge is correct because you want the current count, not a running total.

1.6 Debugging: Why Aren’t My Metrics Appearing?

This is the most common issue. Because StatsD uses UDP, failures are completely silent.

Checklist:

# 1. Is the Datadog Agent reachable from your app container/host?
# Run in Rails console:
require 'socket'
UDPSocket.new.send("test:1|g", 0, ENV.fetch('DD_AGENT_HOST', '127.0.0.1'), 8125)
# 2. Send a test gauge and wait 2-3 minutes
statsd = Datadog::Statsd.new(ENV.fetch('DD_AGENT_HOST', '127.0.0.1'), 8125)
statsd.gauge('debug.connectivity_test', 1)
statsd.close
puts "Sent โ€” check Datadog metric/explorer in 2-3 minutes"
# 3. Check if the integration flag is blocking APM (not metrics, but worth knowing)
Rails.application.credentials[Rails.env.to_sym][:datadog_integration_enabled]

Then in the Datadog UI:

  • Go to Metrics โ†’ Explorer
  • Type your metric name (e.g., billing.) in the graph field โ€” it should autocomplete
  • If it doesn’t autocomplete after 5 minutes, the agent is not receiving the packets

Common root causes in staging/dev environments:

SymptomLikely cause
No metrics in any envAgent not running or wrong host
Metrics in production onlyDD_AGENT_HOST not set, defaults to 127.0.0.1 but agent is on a different host in staging
Intermittent metricsUDP packet loss (rare, but can happen under high load)

Part 2: PagerDuty Integration

2.1 Install the Gem

# Gemfile
gem 'pagerduty', '~> 3.0'
bundle install

2.2 Create a PagerDuty Service

  1. Log in to PagerDuty โ†’ Services โ†’ Service Directory โ†’ + New Service
  2. Name it (e.g., “Billing Pipeline”)
  3. Under Integrations, select “Use our API directly” โ†’ choose Events API v2
  4. Copy the Integration Key โ€” you’ll need this in credentials

2.3 Store Credentials Securely

rails credentials:edit --environment production
# config/credentials/production.yml.enc
pagerduty_billing_integration_key: your_integration_key_here
google_chat_monitoring_webhook: https://chat.googleapis.com/v1/spaces/...

2.4 Create a PagerDuty Wrapper

Create a lightweight wrapper at app/lib/pagerduty/wrapper.rb:

# frozen_string_literal: true
class Pagerduty::Wrapper
def initialize(integration_key:, api_version: 2)
@integration_key = integration_key
@api_version = api_version
end
def client
@client ||= Pagerduty.build(
integration_key: @integration_key,
api_version: @api_version
)
end
end

2.5 Wire Up Alerting in Your Service Class

Continuing the billing health check class:

def alert_if_unhealthy(results)
issues = []
if results[:missing_billing_records_count] > 0
missing_names = results[:missing_regions].map(&:name).join(', ')
issues << "Missing billing records for regions: #{missing_names}"
end
if results[:unbilled_orders_count] > UNBILLED_THRESHOLD
issues << "#{results[:unbilled_orders_count]} unbilled orders (threshold: #{UNBILLED_THRESHOLD})"
end
return if issues.empty?
summary = build_alert_summary(results, issues)
trigger_pagerduty(summary)
send_google_chat_notification(summary)
end
private
def build_alert_summary(results, issues)
[
"Billing Health Check FAILED at #{Time.zone.now.strftime('%Y-%m-%d %H:%M:%S %Z')}",
"Week: #{@billing_week}",
*issues,
"Failed charges: #{results[:failed_charges_count]}"
].join(" | ")
end
def trigger_pagerduty(summary)
dedup_key = "billing-health-#{@billing_week}"
Pagerduty::Wrapper.new(
integration_key: pagerduty_integration_key
).client.incident(dedup_key).trigger(
summary: summary,
source: Rails.application.routes.default_url_options[:host],
severity: "critical"
)
rescue => e
Rails.logger.error("Failed to trigger PagerDuty: #{e.message}")
end
def send_google_chat_notification(message)
# Post to your team's Google Chat / Slack webhook
HTTParty.post(
google_chat_webhook,
body: { text: message }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
rescue => e
Rails.logger.error("Failed to send Google Chat notification: #{e.message}")
end
def pagerduty_integration_key
Rails.application.credentials[Rails.env.to_sym][:pagerduty_billing_integration_key]
end
def google_chat_webhook
Rails.application.credentials[Rails.env.to_sym][:google_chat_monitoring_webhook]
end

2.6 The Dedup Key โ€” Why It Matters

dedup_key = "billing-health-#{@billing_week}"

PagerDuty uses the dedup_key to group events about the same incident. If your billing check runs at 8:30 AM and again at 9:00 AM (e.g., after a retry), PagerDuty will update the existing incident instead of creating a second one and paging your on-call engineer twice.

Best practices for dedup keys:

  • Make them specific to the root cause, not the timestamp
  • Include the resource identifier (week date, job ID, etc.)
  • Use a format like {service}-{resource}-{date} for easy filtering in PagerDuty

Happy Integration!

๐Ÿ—„๏ธ Browser Storage Mechanisms Explained (with Vue.js Examples)

Modern web applications often need to store data on the client side – whether it’s user preferences, form progress, or temporary UI state.
Browsers provide built-in storage mechanisms that help developers do exactly that, without hitting the backend every time.

In this post, we’ll cover:

  • What browser storage is
  • localStorage vs sessionStorage
  • Basic JavaScript examples
  • Vue.js usage patterns
  • When to use (and not use) each
  • Why developers rely on browser storage
  • Comparison with React and Angular

๐ŸŒ What Is Browser Storage?

Browser storage allows web applications to store keyโ€“value data directly in the userโ€™s browser.

Key characteristics:

  • Data is stored client-side
  • Data is stored as strings
  • Accessible via JavaScript
  • Faster than server round-trips

The most common browser storage mechanisms are:

  • localStorage
  • sessionStorage

(Both are part of the Web Storage API)


๐Ÿ“ฆ localStorage

What is localStorage?

localStorage stores data persistently in the browser.

  • Survives page reloads
  • Survives browser restarts
  • Shared across all tabs of the same origin
  • Cleared only manually or via code

Basic JavaScript Example

// Save data
localStorage.setItem('theme', 'dark')
// Read data
const theme = localStorage.getItem('theme')
// Remove data
localStorage.removeItem('theme')

Vue.js Example

export default {
data() {
return {
theme: localStorage.getItem('theme') || 'light'
}
},
watch: {
theme(newValue) {
localStorage.setItem('theme', newValue)
}
}
}

When to Use localStorage

  • User preferences (theme, language)
  • Remembered UI settings
  • Non-sensitive data that should persist
  • Cross-tab shared state

When NOT to Use localStorage

  • Authentication tokens (use httpOnly cookies)
  • Sensitive personal data
  • Temporary flows (signup, checkout steps)

โณ sessionStorage

What is sessionStorage?

sessionStorage stores data only for the lifetime of a browser tab.

  • Cleared when the tab is closed
  • Not shared across tabs
  • Survives page refresh
  • Scoped per tab/window

Basic JavaScript Example

// Save data
sessionStorage.setItem('step', '2')
// Read data
const step = sessionStorage.getItem('step')
// Remove data
sessionStorage.removeItem('step')

Vue.js Example (Signup Flow)

export default {
data() {
return {
postalCode: sessionStorage.getItem('postalCode') || ''
}
},
methods: {
savePostalCode() {
sessionStorage.setItem('postalCode', this.postalCode)
}
}
}

When to Use sessionStorage

  • Multi-step forms
  • Signup / onboarding flows
  • Temporary UI state
  • One-time user journeys

When NOT to Use sessionStorage

  • Long-term preferences
  • Data needed across tabs
  • Anything that must survive browser close

โš–๏ธ localStorage vs sessionStorage

FeaturelocalStoragesessionStorage
LifetimePersistentUntil tab closes
ScopeAll tabsSingle tab
Shared across tabsโœ… YesโŒ No
Survives reloadโœ… Yesโœ… Yes
Use casePreferencesTemporary flows
Storage size~5โ€“10MB~5MB

๐Ÿค” Why Web Developers Use Browser Storage

Developers use browser storage because it:

  • Improves performance
  • Reduces API calls
  • Remembers user intent
  • Simplifies UI state management
  • Works instantly (no async fetch)

Browser storage is often used as a support layer, not a replacement for backend storage.


โš ๏ธ Security Considerations

Important points to remember:

  • โŒ Data is NOT encrypted
  • โŒ Accessible via JavaScript
  • โŒ Vulnerable to XSS attacks

Never store:

  • Passwords
  • JWTs
  • Payment data
  • Sensitive personal information

โš›๏ธ Comparison with React and Angular

Browser storage usage is framework-agnostic.

Vue.js

sessionStorage.setItem('key', value)

React

useEffect(() => {
localStorage.setItem('key', value)
}, [value])

Angular

localStorage.setItem('key', value)

All frameworks rely on the same Web Storage API
The difference lies in state management patterns, not storage itself.


Best Practices

  • Store only strings (use JSON.stringify)
  • Always handle null values
  • Clean up storage after flows
  • Wrap storage logic in utilities or composables
  • Never trust browser-stored data blindly

Final Thoughts

  • localStorage โ†’ persistent, shared, preference-based
  • sessionStorage โ†’ temporary, per-tab, flow-based
  • Vue, React, and Angular all use the same browser API
  • Use browser storage wisely โ€” as a helper, not a database

Used correctly, browser storage can make your frontend faster, smarter, and more user-friendly.


Happy web development!

The Evolution of Stripe’s Payment APIs: From Charges to Payment Intents

A developer’s guide to understanding Stripe’s API transformation and avoiding common migration pitfalls


The payment processing landscape has evolved dramatically over the past decade, and Stripe has been at the forefront of this transformation. One of the most significant changes in Stripe’s ecosystem was the transition from the Charges API to the Payment Intents API. This shift wasn’t just a cosmetic update – it represented a fundamental reimagining of how online payments should work in an increasingly complex global marketplace.

The Old World: Charges API (2011-2019)

The Simple Days

When Stripe first launched, online payments were relatively straightforward. The Charges API reflected this simplicity:

# The old way - direct charge creation
charge = Stripe::Charge.create({
  amount: 2000,
  currency: 'usd',
  source: 'tok_visa',  # Token from Stripe.js
  description: 'Example charge'
})

if charge.paid
  # Payment succeeded, fulfill order
  fulfill_order(charge.id)
else
  # Payment failed, show error
  handle_error(charge.failure_message)
end

This approach was beautifully simple: create a charge, check if it succeeded, done. The API returned a charge object with an ID like ch_1234567890, and that was your payment.

What Made It Work

The Charges API thrived in an era when:

  • Card payments dominated – Most transactions were simple credit/debit cards
  • 3D Secure was optional – Strong customer authentication wasn’t mandated
  • Regulations were simpler – PCI DSS was the main compliance concern
  • Payment methods were limited – Mostly cards, with PayPal as the main alternative
  • Mobile payments were nascent – Most transactions happened on desktop browsers

The Cracks Begin to Show

As the payments ecosystem evolved, the limitations of the Charges API became apparent:

Authentication Challenges: When 3D Secure authentication was required, the simple charge-and-done model broke down. Developers had to handle redirects, callbacks, and asynchronous completion manually.

Mobile Payment Integration: Apple Pay and Google Pay required more complex flows that didn’t map well to direct charge creation.

Regulatory Compliance: European PSD2 regulations introduced Strong Customer Authentication (SCA) requirements that the Charges API couldn’t elegantly handle.

Webhook Reliability: With complex payment flows, relying on synchronous responses became insufficient. Webhooks were critical, but the Charges API didn’t provide a cohesive event model.

The Catalyst: PSD2 and Strong Customer Authentication

The European Union’s Revised Payment Services Directive (PSD2), which came into effect in 2019, was the final nail in the coffin for simple payment flows. PSD2 mandated Strong Customer Authentication (SCA) for most online transactions, requiring:

  • Two-factor authentication for customers
  • Dynamic linking between payment and authentication
  • Exemption handling for low-risk transactions

The Charges API, with its synchronous create-and-complete model, simply couldn’t handle these requirements elegantly.

The New Era: Payment Intents API (2019-Present)

A Paradigm Shift

Stripe’s response was revolutionary: instead of treating payments as simple charge operations, they reconceptualized them as intents that could evolve through multiple states:

# The modern way - intent-based payments
payment_intent = Stripe::PaymentIntent.create({
  amount: 2000,
  currency: 'usd',
  payment_method: 'pm_card_visa',
  confirmation_method: 'manual',
  capture_method: 'automatic'
})

case payment_intent.status
when 'requires_confirmation'
  # Confirm the payment intent
  payment_intent.confirm
when 'requires_action'
  # Handle 3D Secure or other authentication
  handle_authentication(payment_intent.client_secret)
when 'succeeded'
  # Payment completed, fulfill order
  fulfill_order(payment_intent.id)
when 'requires_payment_method'
  # Payment failed, request new payment method
  handle_payment_failure
end

The Intent Lifecycle

Payment Intents introduced a state machine that could handle complex payment flows:

requires_payment_method โ†’ requires_confirmation โ†’ requires_action โ†’ succeeded
                       โ†“                      โ†“                 โ†“
                   canceled              canceled          requires_capture
                                                               โ†“
                                                           succeeded

This model elegantly handles scenarios that would break the Charges API:

3D Secure Authentication:

# Payment requires additional authentication
if payment_intent.status == 'requires_action'
  # Frontend handles 3D Secure challenge
  # Webhook confirms completion asynchronously
end

Delayed Capture:

# Authorize now, capture later
payment_intent = Stripe::PaymentIntent.create({
  amount: 2000,
  currency: 'usd',
  payment_method: 'pm_card_visa',
  capture_method: 'manual'  # Authorize only
})

# Later, when ready to fulfill
payment_intent.capture({ amount_to_capture: 1500 })

Key Architectural Changes

1. Separation of Concerns

Payment Intents represent the intent to collect payment and track the payment lifecycle.

Charges become implementation detailsโ€”the actual movement of money that happens within a Payment Intent.

# A successful Payment Intent contains charges
payment_intent = Stripe::PaymentIntent.retrieve('pi_1234567890')
puts payment_intent.charges.data.first.id  # => "ch_0987654321"

2. Enhanced Webhook Events

Payment Intents provide richer webhook events that track the entire payment lifecycle:

# webhook_endpoints.rb
case event.type
when 'payment_intent.succeeded'
  handle_successful_payment(event.data.object)
when 'payment_intent.payment_failed'
  handle_failed_payment(event.data.object)
when 'payment_intent.requires_action'
  notify_customer_action_required(event.data.object)
end

3. Client-Side Integration

The Payment Intents API encouraged better client-side integration through Stripe Elements and mobile SDKs:

// Modern client-side payment confirmation
const {error} = await stripe.confirmCardPayment(clientSecret, {
  payment_method: {
    card: cardElement,
    billing_details: {name: 'Jenny Rosen'}
  }
});

if (error) {
  // Handle error
} else {
  // Payment succeeded, redirect to success page
}

Migration Challenges and Solutions

The ID Problem: A Real-World Example

One of the most common migration issues developers face is the ID confusion between Payment Intents and Charges. Here’s a real scenario:

# Legacy refund code expecting charge IDs
def process_refund(charge_id, amount)
  Stripe::Refund.create({
    charge: charge_id,  # Expects ch_xxx
    amount: amount
  })
end

# But Payment Intents return pi_xxx IDs
payment_intent = create_payment_intent(...)
process_refund(payment_intent.id, 500)  # โŒ Fails!

The Solution: Extract the actual charge ID from successful Payment Intents:

def get_charge_id_for_refund(payment_intent)
  if payment_intent.status == 'succeeded'
    payment_intent.charges.data.first.id  # Returns ch_xxx
  else
    raise "Cannot refund unsuccessful payment"
  end
end

# Correct usage
payment_intent = Stripe::PaymentIntent.retrieve('pi_1234567890')
charge_id = get_charge_id_for_refund(payment_intent)
process_refund(charge_id, 500)  # โœ… Works!

Database Schema Evolution

Many applications need to update their database schemas to accommodate both old and new payment types:

# Migration to support both charge and payment intent IDs
class AddPaymentIntentSupport < ActiveRecord::Migration[6.0]
  def change
    add_column :payments, :stripe_payment_intent_id, :string
    add_column :payments, :payment_type, :string, default: 'charge'

    add_index :payments, :stripe_payment_intent_id
    add_index :payments, :payment_type
  end
end

# Updated model to handle both
class Payment < ApplicationRecord
  def stripe_id
    case payment_type
    when 'payment_intent'
      stripe_payment_intent_id
    when 'charge'
      stripe_charge_id
    end
  end

  def refundable_charge_id
    if payment_type == 'payment_intent'
      # Fetch the actual charge ID from the payment intent
      pi = Stripe::PaymentIntent.retrieve(stripe_payment_intent_id)
      pi.charges.data.first.id
    else
      stripe_charge_id
    end
  end
end

Webhook Handler Updates

Webhook handling becomes more sophisticated with Payment Intents:

# Legacy charge webhook handling
def handle_charge_webhook(event)
  charge = event.data.object

  case event.type
  when 'charge.succeeded'
    mark_payment_successful(charge.id)
  when 'charge.failed'
    mark_payment_failed(charge.id)
  end
end

# Modern payment intent webhook handling
def handle_payment_intent_webhook(event)
  payment_intent = event.data.object

  case event.type
  when 'payment_intent.succeeded'
    # Payment completed successfully
    complete_order(payment_intent.id)

  when 'payment_intent.payment_failed'
    # All payment attempts have failed
    cancel_order(payment_intent.id)

  when 'payment_intent.requires_action'
    # Customer needs to complete authentication
    notify_action_required(payment_intent.id, payment_intent.client_secret)

  when 'payment_intent.amount_capturable_updated'
    # Partial capture scenarios
    handle_partial_authorization(payment_intent.id)
  end
end

Best Practices for Modern Stripe Integration

1. Embrace Asynchronous Patterns

With Payment Intents, assume payments are asynchronous:

class PaymentProcessor
  def create_payment(amount, customer_id, payment_method_id)
    payment_intent = Stripe::PaymentIntent.create({
      amount: amount,
      currency: 'usd',
      customer: customer_id,
      payment_method: payment_method_id,
      confirmation_method: 'automatic',
      return_url: success_url
    })

    # Don't assume immediate success
    case payment_intent.status
    when 'succeeded'
      complete_payment_immediately(payment_intent)
    when 'requires_action'
      # Send client_secret to frontend for authentication
      { status: 'requires_action', client_secret: payment_intent.client_secret }
    when 'requires_payment_method'
      { status: 'failed', error: 'Payment method declined' }
    else
      # Wait for webhook confirmation
      { status: 'processing', payment_intent_id: payment_intent.id }
    end
  end
end

2. Implement Robust Webhook Handling

Webhooks are critical for Payment Intentsโ€”implement them defensively:

class StripeWebhookController < ApplicationController
  protect_from_forgery except: :handle

  def handle
    payload = request.body.read
    sig_header = request.env['HTTP_STRIPE_SIGNATURE']

    begin
      event = Stripe::Webhook.construct_event(
        payload, sig_header, ENV['STRIPE_WEBHOOK_SECRET']
      )
    rescue JSON::ParserError, Stripe::SignatureVerificationError
      head :bad_request and return
    end

    # Handle idempotently
    return head :ok if processed_event?(event.id)

    case event.type
    when 'payment_intent.succeeded'
      PaymentSuccessJob.perform_later(event.data.object.id)
    when 'payment_intent.payment_failed'
      PaymentFailureJob.perform_later(event.data.object.id)
    end

    mark_event_processed(event.id)
    head :ok
  end

  private

  def processed_event?(event_id)
    Rails.cache.exist?("stripe_event_#{event_id}")
  end

  def mark_event_processed(event_id)
    Rails.cache.write("stripe_event_#{event_id}", true, expires_in: 24.hours)
  end
end

3. Handle Multiple Payment Methods Gracefully

Payment Intents excel at handling diverse payment methods:

def create_flexible_payment(amount, payment_method_types = ['card'])
  Stripe::PaymentIntent.create({
    amount: amount,
    currency: 'usd',
    payment_method_types: payment_method_types,
    metadata: {
      order_id: @order.id,
      customer_email: @customer.email
    }
  })
end

# Support multiple payment methods
payment_intent = create_flexible_payment(2000, ['card', 'klarna', 'afterpay_clearpay'])

4. Implement Proper Error Handling

Payment Intents provide detailed error information:

def handle_payment_error(payment_intent)
  last_payment_error = payment_intent.last_payment_error

  case last_payment_error&.code
  when 'authentication_required'
    # Redirect to 3D Secure
    redirect_to_authentication(payment_intent.client_secret)

  when 'card_declined'
    decline_code = last_payment_error.decline_code
    case decline_code
    when 'insufficient_funds'
      show_error("Insufficient funds on your card")
    when 'expired_card'
      show_error("Your card has expired")
    else
      show_error("Your card was declined")
    end

  when 'processing_error'
    show_error("A processing error occurred. Please try again.")

  else
    show_error("An unexpected error occurred")
  end
end

The Future: What’s Next?

1. Embedded Payments

Stripe continues to innovate with embedded payment solutions that make Payment Intents even more powerful:

# Embedded checkout with Payment Intents
payment_intent = Stripe::PaymentIntent.create({
  amount: 2000,
  currency: 'usd',
  automatic_payment_methods: { enabled: true },
  metadata: { integration_check: 'accept_a_payment' }
})

2. Real-Time Payments

As real-time payment networks like FedNow and Open Banking expand, Payment Intents provide the flexibility to support these new methods seamlessly.

3. Cross-Border Optimization

Payment Intents are evolving to better handle multi-currency and cross-border transactions with improved routing and local payment method support.

Key Takeaways for Developers

  1. Payment Intents are the future: If you’re building new payment functionality, start with Payment Intents, not Charges.
  2. Embrace asynchronous patterns: Don’t expect payments to complete immediately. Design your system around webhooks and state management.
  3. Handle the ID confusion: Remember that Payment Intents (pi_) contain Charges (ch_). Refunds and some other operations still work on charge IDs.
  4. Implement robust webhook handling: With complex payment flows, webhooks become critical infrastructure, not nice-to-have features.
  5. Test thoroughly: The increased complexity of Payment Intents requires more comprehensive testing, especially around authentication flows and edge cases.
  6. Monitor proactively: Use Stripe’s dashboard and logs extensively during development and deployment to understand payment flow behavior.

Conclusion

The evolution from Stripe’s Charges API to Payment Intents represents more than just a technical upgradeโ€”it’s a fundamental shift toward a more flexible, regulation-compliant, and globally-aware payment processing model. While the migration requires thoughtful planning and careful implementation, the benefits in terms of supported payment methods, authentication handling, and regulatory compliance make it essential for any serious payment processing application.

The key is to approach the migration systematically: understand the differences, plan for the ID confusion, implement robust webhook handling, and test extensively. With these foundations in place, Payment Intents unlock capabilities that simply weren’t possible with the older Charges API.

As global payment regulations continue to evolve and new payment methods emerge, Payment Intents provide the architectural flexibility to adapt and grow. The initial complexity investment pays dividends in long-term maintainability and feature capability.

For developers still using the Charges API, the writing is on the wall: it’s time to embrace the future of payment processing with Payment Intents.


Have you encountered similar challenges migrating from Charges to Payment Intents? What patterns have worked best in your applications? Share your experiences in the comments below.

๐Ÿ” Understanding Rubyโ€™s Singleton Class: Why We Open the Eigenclass at the Class Level – Advanced

Ruby is one of the few languages where classes are objects, capable of holding both instance behavior and class-level behavior. This flexibility comes from a powerful internal structure: the singleton class, also known as the eigenclass. Every Ruby object has one โ€” including classes themselves.

When developers write class << self, they are opening a special, hidden class that Ruby uses to store methods that belong to the class object, not its instances. This technique is the backbone of Ruby’s expressive meta-programming features and is used heavily in Rails, Sidekiq, ActiveRecord, RSpec, and nearly every major Ruby framework.

This article explains why Ruby has singleton classes, what they enable, and when you should use class << self instead of def self.method for defining class-level behavior.


In Ruby, writing:

class Payment; end

creates an object:

Payment.instance_of?(Class)  # => true

Since Payment is an object, it can have:

  • Its own methods
  • Its own attributes
  • Its own included modules

Just like any other object.

Ruby stores these class-specific methods in a special internal structure: the singleton class of Payment.

When you define a class method:

def self.process
end

Ruby is actually doing this under the hood:

  • Open the singleton class of Payment
  • Define process inside it

So:

class << Payment
  def process; end
end

and:

def Payment.process; end

and:

def self.process; end

All do the same thing.

But class << self unlocks far more power.


Each Ruby object has:

[ Object ] ---> [ Singleton Class ] ---> [ Its Class ]

For a class object like Payment:

[ Payment ] ---> [ Payment's Eigenclass ] ---> [ Class ]

Instance methods live in Payment.
Class methods live in Payment's eigenclass.

The eigenclass is where Ruby stores:

  • Class methods
  • Per-object overrides
  • Class-specific attributes
  • DSL behaviors
class << self
  def load; end
  def export; end
  def sync; end
end

Cleaner than:

def self.load; end
def self.export; end
def self.sync; end

This is a huge advantage.

class << self
  private

  def connection_pool
    @pool ||= ConnectionPool.new
  end
end

Using def self.method cannot make the method private โ€” Ruby doesn’t allow it.

class << self
  include CacheHelpers
end

This modifies class-level behavior, not instance behavior.

Rails uses this technique everywhere.

You must open the eigenclass:

class << self
  def new(*args)
    puts "Creating a new Payment!"
    super
  end
end

This cannot be done properly with def self.new.

class << self
  attr_accessor :config
end

Usage:

Payment.config = { currency: "USD" }

This config belongs to the class itself.

Example from ActiveRecord:

class << self
  def has_many(name)
    # defines association
  end
end

Or RSpec:

class << self
  def describe(text, &block)
    # builds DSL structure
  end
end


When you write:

class Order < ApplicationRecord
  has_many :line_items
end

Internally Rails does:

class Order
  class << self
    def has_many(name)
      # logic here
    end
  end
end

This is how Rails builds its elegant DSL.

class << self
  def before_save(method_name)
    set_callback(:save, :before, method_name)
  end
end

Again, these DSL methods live in the singleton class.

โœ… Use def self.method_name when:

  • Only defining 1โ€“2 methods
  • Simpler readability is preferred

โœ… Use class << self when:

  • You have many class methods
  • You require private class methods
  • You need to include modules at class level
  • You are building DSLs or metaprogramming-heavy components
  • You need to override class-level behavior (new, allocate)

Opening a class’s singleton class (class << self) is not just a stylistic choice โ€” it is a powerful meta-programming technique that lets you modify the behavior of the class object itself. Because Ruby treats classes as first-class objects, their singleton classes hold the key to defining class methods, private class-level utilities, DSLs, and dynamic meta-behavior.

Understanding how and why Ruby uses the eigenclass gives you deeper insight into the design of Rails, Sidekiq, ActiveRecord, and virtually all major Ruby libraries.

Itโ€™s one of the most elegant aspects of Ruby’s object model โ€” and one of its most powerful once mastered.


Happy Ruby coding!

๐Ÿ” Understanding Why Ruby Opens the Singleton (Eigenclass) at the Class Level

In Ruby, everything is an object – and that includes classes themselves. A class like Payment is actually an instance of Class, meaning it can have its own methods, attributes, and behavior just like any other object. Because every object in Ruby has a special hidden class called a singleton class (or eigenclass), Ruby uses this mechanism to store methods that belong specifically to the class object, rather than to its instances.

When developers open a class’s eigenclass using class << self, they are directly modifying this singleton class, gaining access to unique meta-programming abilities not available through normal def self.method definitions. This approach lets you define private class methods, include modules into a class’s singleton behavior, override internal methods like new or allocate, group multiple class methods cleanly, and create flexible DSLs. Ultimately, opening the eigenclass enables fine-grained control over a Ruby class’s meta-level behavior, a powerful tool when writing expressive, maintainable frameworks and advanced Ruby code.


? Why Ruby Needs a Singleton Class for the Class Object

Ruby separates instance behavior from class behavior:

  • Instance methods live in the class (Payment)
  • Class methods live in the classโ€™s singleton class (Payment.singleton_class)

This means:

def self.process
end

and:

class << self
  def process
  end
end

are doing the same thing – defining a method on the class’s eigenclass.

But class << self gives you more control.


What You Can Do With class << self That You Can’t Do With def self.method

1. Group multiple class methods without repeating self.

class << self
  def load_data; end
  def generate_stats; end
  def export; end
end

Cleaner and more readable when many class methods exist.

2. Make class methods private

This is a BIG reason to open the eigenclass.

class << self
  private

  def secret_config
    "hidden!"
  end
end

With def self.secret_config, you cannot make it private.

3. Add modules to the class’s singleton behavior

This modifies the class itself, not its instances.

class << self
  include SomeClassMethods
end

Equivalent to:

extend SomeClassMethods

But allows mixing visibility (public/private/protected).

4. Override class-level behavior (new, allocate, etc.)

You must use the eigenclass for these methods:

class << self
  def allocate
    puts "custom allocation"
    super
  end
end

This cannot be done correctly with def self.allocate.

5. Implement DSLs and class-level configuration

Rails, RSpec, Sidekiq, and ActiveRecord all use this.

class << self
  attr_accessor :config
end

Now the class has its own state:

Payment.config = { mode: :test }


Understanding the Bigger Picture โ€” Ruby’s Meta-Object Model

Ruby treats classes as objects, and every object has:

  • A class where instance methods live
  • A singleton class where methods specific to that object live

So:

  • Instance methods โ†’ stored in the class (Payment)
  • Class methods โ†’ stored in the singleton class (Payment.singleton_class)

Opening the eigenclass means directly modifying that second structure.


When Should You Use class << self?

Use class << self when:

โœ” You have several class methods to define
โœ” You need private/protected class methods
โœ” You want to include or extend modules into the class’s behavior
โœ” You need to override class-level built-ins (new, allocate)
โœ” You’re implementing DSLs or framework-level code

Use def self.method when:

โœ” You’re defining one or two simple class methods
โœ” You want the simplest, most readable syntax


๐ŸŽฏ Final Takeaway

Opening the singleton class at the class level isn’t just stylistic โ€” it unlocks capabilities that normal class method definitions cannot provide. It’s a powerful tool for clean organization, encapsulation, and meta-programming. Frameworks like Rails rely heavily on this pattern because it allows precise control over how classes behave at a meta-level.

Understanding this distinction helps you write cleaner, more flexible Ruby code โ€” and it deepens your appreciation of Ruby’s elegant object model.

In the next article, we can check more examples in detail.


Happy Coding!

Building Robust Stripe Payment Tracking in Rails: Rspec Testing, Advanced Implementation Patterns, Reporting – part 2

How we transformed fragmented payment tracking into a comprehensive admin interface that gives business teams complete visibility into every payment attempt.

This post follows the part 1 of stripe implementation we have seen. Stripe Payment – Part 1

Testing Strategy: Comprehensive Payment Testing

Payment systems are mission-critical components that directly impact revenue and customer trust, making comprehensive testing absolutely essential. A robust testing strategy must cover three distinct layers: isolated unit tests that verify individual payment service behaviours, integration tests that ensure proper webhook handling and external API interactions, and feature tests that validate the complete user experience from payment initiation to admin dashboard visibility. This multi-layered approach ensures that payment failures are caught early in development, edge cases are properly handled, and business stakeholders can rely on accurate payment data for decision-making.

Unit Testing Payment Service

Unit tests form the foundation of payment system reliability by isolating and verifying the core payment processing logic without external dependencies, ensuring that different payment scenarios (success, card declined, network errors) are handled correctly and consistently.

# spec/services/payment_service_spec.rb
RSpec.describe PaymentService do
  let(:customer) { create(:customer, :with_payment_method) }

  describe '.charge' do
    context 'successful payment' do
      before do
        allow(Stripe::PaymentIntent).to receive(:create)
          .and_return(double(status: 'succeeded', id: 'pi_success_123', to_hash: {}))
      end

      it 'creates successful transaction' do
        transaction = PaymentService.charge(2999, 'Test charge', customer)

        expect(transaction).to be_persisted
        expect(transaction.success?).to be true
        expect(transaction.amount_cents).to eq(2999)
      end

      it 'creates payment record association' do
        expect {
          transaction = PaymentService.charge(2999, 'Test charge', customer)
          customer.payment_records.create!(transaction: transaction)
        }.to change { customer.payment_records.count }.by(1)
      end
    end

    context 'card declined' do
      let(:declined_error) do
        Stripe::CardError.new('Card declined', 'card_declined', 
                             json_body: { 'error' => { 'code' => 'card_declined', 
                                                       'message' => 'Your card was declined.' } })
      end

      before do
        allow(Stripe::PaymentIntent).to receive(:create).and_raise(declined_error)
      end

      it 'creates failed transaction with error details' do
        transaction = PaymentService.charge(2999, 'Test charge', customer)

        expect(transaction).to be_persisted
        expect(transaction.success?).to be false
        expect(transaction.error_code).to eq('card_declined')
        expect(transaction.error_message).to eq('Your card was declined.')
      end
    end

    context 'network error' do
      before do
        allow(Stripe::PaymentIntent).to receive(:create)
          .and_raise(Stripe::APIConnectionError.new('Network error'))
      end

      it 'creates failed transaction with network error' do
        transaction = PaymentService.charge(2999, 'Test charge', customer)

        expect(transaction).to be_persisted  
        expect(transaction.success?).to be false
        expect(transaction.error_message).to eq('Network error')
      end
    end

    context 'zero amount' do
      it 'creates successful zero-amount transaction' do
        transaction = PaymentService.charge(0, 'Free item', customer)

        expect(transaction).to be_persisted
        expect(transaction.success?).to be true
        expect(transaction.amount_cents).to eq(0)
      end
    end
  end
end

Integration Testing with Webhooks

Integration tests validate the critical communication pathways between your application and Stripe’s web-hook system, ensuring that payment status updates are properly received, parsed, and stored even when network conditions or timing issues occur.

# spec/controllers/webhooks/stripe_controller_spec.rb
RSpec.describe Webhooks::StripeController do
  let(:customer) { create(:customer) }

  describe 'payment_intent.payment_failed webhook' do
    let(:webhook_payload) do
      {
        type: 'payment_intent.payment_failed',
        data: {
          object: {
            id: 'pi_failed_123',
            amount: 2999,
            currency: 'usd',
            customer: customer.stripe_customer_id,
            last_payment_error: {
              code: 'card_declined',
              message: 'Your card was declined.'
            }
          }
        }
      }
    end

    it 'creates failed transaction record' do
      expect {
        post :handle_webhook, params: webhook_payload
      }.to change { Transaction.count }.by(1)

      transaction = Transaction.last
      expect(transaction.success?).to be false
      expect(transaction.error_code).to eq('card_declined')
    end

    it 'associates transaction with customer' do
      expect {
        post :handle_webhook, params: webhook_payload  
      }.to change { customer.payment_records.count }.by(1)
    end
  end
end

Feature Testing Admin Interface

Feature tests provide end-to-end validation of the admin dashboard experience, verifying that business users can access complete payment information, understand transaction statuses at a glance, and take appropriate actions based on payment data.

# spec/features/admin/customer_payments_spec.rb
RSpec.describe 'Customer Payment Admin', type: :feature do
  let(:admin_user) { create(:admin_user) }
  let(:customer) { create(:customer) }

  before { login_as(admin_user) }

  scenario 'viewing customer payment history' do
    # Create test transactions
    successful_transaction = create(:transaction, :successful, amount_cents: 2999)
    failed_transaction = create(:transaction, :failed, amount_cents: 4999)

    customer.payment_records.create!(transaction: successful_transaction)
    customer.payment_records.create!(transaction: failed_transaction)

    visit admin_customer_path(customer)

    within('#payment-history') do
      expect(page).to have_content('$29.99')
      expect(page).to have_content('SUCCESS')
      expect(page).to have_content('$49.99') 
      expect(page).to have_content('FAILED')
      expect(page).to have_link('View Stripe Dashboard')
      expect(page).to have_link('Retry Payment')
    end
  end
end

Advanced Implementation Patterns

Beyond basic payment processing, production payment systems require sophisticated patterns to handle complex business scenarios like multi-payment methods per customer, subscription lifecycle events, and intelligent error recovery. These advanced patterns separate robust enterprise systems from simple payment integrations by providing the flexibility and resilience needed for real-world business operations. Implementing these patterns proactively prevents technical debt and ensures your payment system can evolve with changing business requirements.

1. Payment Method Management System

A comprehensive payment method management system allows customers to store multiple payment methods securely while giving businesses the flexibility to handle payment method updates, expirations, and customer preferences without disrupting service continuity.

# app/services/payment_method_manager.rb
class PaymentMethodManager
  def initialize(customer)
    @customer = customer
  end

  def add_payment_method(payment_method_id)
    begin
      # Attach to customer
      Stripe::PaymentMethod.attach(payment_method_id, {
        customer: @customer.stripe_customer_id
      })

      # Store locally
      @customer.customer_payment_methods.create!(
        stripe_payment_method_id: payment_method_id,
        is_default: @customer.customer_payment_methods.empty?
      )

      { success: true }

    rescue Stripe::InvalidRequestError => e
      { success: false, error: e.message }
    end
  end

  def set_default_payment_method(payment_method_id)
    # Update Stripe customer
    Stripe::Customer.update(@customer.stripe_customer_id, {
      invoice_settings: { default_payment_method: payment_method_id }
    })

    # Update local records
    @customer.customer_payment_methods.update_all(is_default: false)
    @customer.customer_payment_methods
             .find_by(stripe_payment_method_id: payment_method_id)
             &.update!(is_default: true)
  end

  def remove_payment_method(payment_method_id)
    # Detach from Stripe
    Stripe::PaymentMethod.detach(payment_method_id)

    # Remove local record
    @customer.customer_payment_methods
             .find_by(stripe_payment_method_id: payment_method_id)
             &.destroy!
  end
end

2. Subscription Lifecycle Management

Subscription lifecycle management encompasses the complete journey from trial creation through renewal, pause, and cancellation, ensuring that billing events are properly tracked and business logic is consistently applied across all subscription state changes.

# app/services/subscription_manager.rb
class SubscriptionManager
  def initialize(customer)
    @customer = customer
  end

  def create_subscription(price_id, trial_days = nil)
    subscription_params = {
      customer: @customer.stripe_customer_id,
      items: [{ price: price_id }],
      payment_behavior: 'default_incomplete',
      payment_settings: { save_default_payment_method: 'on_subscription' },
      expand: ['latest_invoice.payment_intent']
    }

    subscription_params[:trial_period_days] = trial_days if trial_days

    stripe_subscription = Stripe::Subscription.create(subscription_params)

    # Create local subscription record
    subscription = @customer.subscriptions.create!(
      stripe_subscription_id: stripe_subscription.id,
      status: stripe_subscription.status,
      current_period_start: Time.at(stripe_subscription.current_period_start),
      current_period_end: Time.at(stripe_subscription.current_period_end),
      trial_end: stripe_subscription.trial_end ? Time.at(stripe_subscription.trial_end) : nil
    )

    # Track the creation attempt
    if stripe_subscription.latest_invoice.payment_intent
      track_subscription_payment(stripe_subscription, subscription)
    end

    subscription
  end

  private

  def track_subscription_payment(stripe_subscription, local_subscription)
    payment_intent = stripe_subscription.latest_invoice.payment_intent

    transaction = Transaction.create!(
      amount_cents: payment_intent.amount,
      success: payment_intent.status == 'succeeded',
      stripe_data: payment_intent.to_hash,
      stripe_payment_id: payment_intent.id,
      transaction_type: 'subscription_payment'
    )

    local_subscription.payment_records.create!(transaction: transaction)
  end
end

3. Comprehensive Error Handling and Notifications

Advanced error handling goes beyond simple retry logic to include intelligent categorization of payment failures, automated customer communication workflows, and escalation procedures that help recover revenue while maintaining positive customer relationships.

# app/jobs/payment_failure_handler_job.rb
class PaymentFailureHandlerJob < ApplicationJob
  def perform(transaction_id)
    transaction = Transaction.find(transaction_id)
    return if transaction.success?

    # Find associated customer
    customer = find_customer_for_transaction(transaction)
    return unless customer

    case transaction.error_code
    when 'card_declined', 'insufficient_funds'
      handle_declined_card(customer, transaction)
    when 'expired_card'
      handle_expired_card(customer, transaction)
    when 'authentication_required'
      handle_3ds_required(customer, transaction)
    else
      handle_generic_failure(customer, transaction)
    end
  end

  private

  def handle_declined_card(customer, transaction)
    # Send customer notification
    PaymentFailureMailer.card_declined(customer, transaction).deliver_now

    # Update customer status
    customer.update!(payment_status: 'payment_failed', last_payment_failure_at: Time.current)

    # Schedule retry in 3 days
    PaymentRetryJob.set(wait: 3.days).perform_later(customer.id, transaction.id)
  end

  def handle_expired_card(customer, transaction)
    PaymentFailureMailer.card_expired(customer, transaction).deliver_now
    customer.update!(payment_status: 'card_expired')
  end

  def find_customer_for_transaction(transaction)
    payment_record = PaymentRecord.find_by(transaction: transaction)
    return nil unless payment_record&.payable_type == 'Customer'

    payment_record.payable
  end
end

Business Intelligence and Reporting

Raw payment data becomes truly valuable when transformed into actionable business insights that drive strategic decisions and operational improvements. Business intelligence for payment systems encompasses both real-time monitoring capabilities that help identify and resolve issues quickly, and analytical reporting that reveals patterns in customer behaviour, payment success rates, and revenue optimization opportunities. These capabilities transform payment systems from cost centers into strategic business assets that actively contribute to growth and customer satisfaction.

1. Payment Analytics Dashboard

A comprehensive analytics dashboard transforms scattered payment data into clear, actionable insights that help business teams identify trends, optimize conversion rates, and proactively address payment issues before they impact revenue or customer experience.

# app/services/payment_analytics_service.rb
class PaymentAnalyticsService
  def self.daily_payment_metrics(date = Date.current)
    transactions = Transaction.where(created_at: date.beginning_of_day..date.end_of_day)

    {
      total_attempts: transactions.count,
      successful_payments: transactions.successful.count,
      failed_payments: transactions.failed.count,
      success_rate: calculate_success_rate(transactions),
      total_volume: transactions.successful.sum(:amount_cents),
      average_transaction: calculate_average_amount(transactions.successful),
      top_failure_reasons: top_failure_reasons(transactions.failed),
      decline_by_card_type: decline_breakdown_by_card(transactions.failed)
    }
  end

  def self.customer_payment_health(customer)
    recent_transactions = customer.transactions
                                 .where(created_at: 30.days.ago..Time.current)
                                 .order(created_at: :desc)

    {
      total_transactions: recent_transactions.count,
      success_rate: calculate_success_rate(recent_transactions),
      consecutive_failures: calculate_consecutive_failures(recent_transactions),
      days_since_last_success: days_since_last_success(recent_transactions),
      risk_score: calculate_risk_score(recent_transactions)
    }
  end

  private

  def self.calculate_success_rate(transactions)
    return 0 if transactions.empty?
    (transactions.successful.count.to_f / transactions.count * 100).round(2)
  end

  def self.top_failure_reasons(failed_transactions)
    failed_transactions.group(:error_code)
                      .count
                      .sort_by { |_, count| -count }
                      .first(5)
                      .to_h
  end
end

2. Automated Payment Recovery

Automated payment recovery systems intelligently retry failed payments based on error type and customer history, implementing business rules that maximize revenue recovery while respecting customer preferences and avoiding negative experiences that could damage relationships.

# app/services/payment_recovery_service.rb
class PaymentRecoveryService
  def self.process_failed_payments
    # Find customers with recent payment failures
    failed_payment_records = PaymentRecord.joins(:transaction)
                                         .where(transactions: { success: false })
                                         .where(created_at: 1.day.ago..Time.current)
                                         .includes(:payable, :transaction)

    failed_payment_records.each do |payment_record|
      next unless payment_record.payable_type == 'Customer'

      customer = payment_record.payable
      retry_payment_for_customer(customer, payment_record.transaction)
    end
  end

  private

  def self.retry_payment_for_customer(customer, original_transaction)
    # Only retry certain error types
    return unless retryable_error?(original_transaction.error_code)

    # Don't retry if customer has been marked as do-not-retry
    return if customer.payment_retry_disabled?

    # Attempt payment with same amount
    new_transaction = PaymentService.charge(
      original_transaction.amount_cents,
      "Retry: #{original_transaction.stripe_data['description']}",
      customer
    )

    customer.payment_records.create!(
      transaction: new_transaction,
      retry_of_transaction_id: original_transaction.id
    )

    if new_transaction.success?
      PaymentRecoveryMailer.payment_recovered(customer, new_transaction).deliver_later
    else
      # Mark for manual review after multiple failures
      customer.update!(requires_manual_payment_review: true)
    end
  end

  def self.retryable_error?(error_code)
    %w[api_connection_error rate_limit_error temporary_failure].include?(error_code)
  end
end

Conclusion

The key principles to remember:

  • Track Everything: Every payment attempt, successful or failed, tells part of your business story
  • Design for Non-Technical Users: Transform complex payment data into actionable business intelligence
  • Plan for Scale: Use caching, efficient queries, and smart data structures
  • Test Thoroughly: Payment systems require comprehensive testing of both happy and sad paths
  • Monitor Continuously: Build dashboards and alerts that help you catch issues before they impact customers

Ready to implement robust payment tracking in your Rails application? Start with the foundational data models, then build up your service layer and admin interfaces systematically. Remember: comprehensive payment visibility is not just a technical requirementโ€”it’s a business advantage.