host-app-safety
Skill93 starsChanged 8 days agoMITView on GitHub
Host app safety rules and performance budgets for rails_error_dashboard
In this repository11 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- add-breadcrumb-pageSkill
- chaosSkill
- cqrs-patternsSkill
- demo-updateSkill
- new-featureSkill
- release-rulesSkill
- releaseSkill
- roadmap-statusSkill
- safety-checkSkill
- testSkill
The file
We split this skill into the rules it gives an agent
4 rules. About 0 are common, 2 are distinctive, and 2 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
2
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 59
Default pool config didn't account for polling and heartbeat connections.
- Distinctive: line 72
Every major error-tracking SDK has had an incident where it damaged the host app.
- Very specific: line 96
When reviewing changes to the capture path (middleware/, error_reporter.rb, commands/log_error.rb, commands/find_or_increment_error.rb):
- Distinctive: line 125
When adding any query that could touch an unbounded number of rows, pick one of these three shapes.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Host App Safety Knowledge Base
This gem runs INSIDE the host Rails app's process. Every line of code we write can break the host app. These rules are non-negotiable.
The 11 Safety Rules
- Never raise in the error capture path — rescue at every layer. If our error tracker raises while capturing an error, we've made the situation worse.
- Never block the request path — all heavy work (notifications, analytics) must be async/background. The request thread must return ASAP.
- Budget every operation — breadcrumb callback <10µs, health snapshot <1.5ms, storm check <15µs, capture <5ms before the response. Measure with
Process.clock_gettime(Process::CLOCK_MONOTONIC). - Clean up Thread.current — always use
ensureblocks. Puma reuses threads across keepalive connections (Puma #823). - Always re-raise original exceptions — Sentry #1173: swallowed Sidekiq exceptions prevented retries. After capture, always
raisethe original. - Feature-detect before calling —
defined?(Puma),respond_to?(:stats). Never assume a server/library exists. - Make everything disableable — every request-path feature needs a config flag. Users must be able to turn off anything that causes problems.
- Never use ObjectSpace.each_object — freezes all threads and grows the heap, and the cost compounds across later GC cycles. Use
GC.statfor memory info.ObjectSpace.count_objectsis allowed: it returns O(1) type counts without walking the heap, andDiagnosticDumpGeneratoruses it deliberately. - Never use Signal.trap — breaks Puma/Sidekiq signal handling (USR1/USR2 reserved). Use rake tasks for diagnostics.
- Never store Binding objects — prevents GC of entire call stack. Extract local variables immediately into plain data.
- Never call
Thread#backtraceacrossThread.list— collecting backtraces for every thread holds the GVL while it walks each stack, stalling the whole process. Capture thread name/status/alive only.DiagnosticDumpGeneratornotes this explicitly.
Real-World Incidents
Sentry #1173 — Swallowed Sidekiq Retries
Middleware captured exception but forgot to re-raise. Sidekiq treated failed jobs as successful. Retries never happened. Production data loss. sentry-ruby#1173
Puma #823 — Thread-Local Leak Across Keepalive
Thread.current values leaked between requests on the same keepalive connection. Puma only cleaned thread-locals before new work assignment, not between requests. puma#823
Coverband — TracePoint :line Was 2.5x Slower
Abandoned TracePoint :line in favor of Coverage API (1.08x vs 2.5x overhead). Never use :line in production by default. coverage_rails_benchmark
Ruby #18264 — TracePoint Memory Leak
rb_tp_t struct allocated with RUBY_TYPED_NEVER_FREE in Ruby 2.6-3.0. Repeatedly creating TracePoints caused unbounded RSS growth. Gate on Ruby >= 3.2.
bugs.ruby-lang.org#18264
Rails AS::Notifications — Subscribers CAN Crash Requests
iterate_guarding_exceptions collects subscriber exceptions and RE-RAISES them. A buggy subscriber on sql.active_record crashes the SQL query's caller. Breadcrumb subscriber MUST have its own rescue.
rails fanout.rb
Rack Middleware Constant Mutation
Shared mutable headers hash in a constant grew unbounded across requests. Never use mutable constants for per-request data. bernardoamc.com/rails-middleware-leak
Signal.trap — Last Writer Wins
Puma uses USR1 (phased restart), USR2 (full restart). Sidekiq Enterprise uses USR2. Signal.trap completely replaces previous handler — no chaining. sidekiq#3803
Sentry #1246 — Transport Swallowed Network Errors
Sentry::Transport.send_data caught all network errors silently; transport_failure_callback never fired on 4xx/5xx. Transport failures were completely invisible. Rescuing is not enough — always log what failed and why. A silent rescue in our notification dispatch would fail the same way.
sentry-ruby#1246
SolidQueue #271 — Connection Pool Exhaustion
Default pool config didn't account for polling and heartbeat connections. Each worker thread needs its own connection, plus 2 extra: pool >= (threads * processes) + dispatcher_threads + 2. If we run background jobs, document the pool requirement — our separate-database option is the mitigation.
solid_queue#271
Mailboxer #480 — Index Name Too Long
An engine migration generated index_mailboxer_notifications_on_notified_object_type_and_notified_object_id, exceeding PostgreSQL's 63-character limit. The migration fails on the host app's database, during their deploy. Always name compound indexes explicitly in our migrations; never rely on auto-generated names.
mailboxer#480
Puma #2767 — Signal Handler Coexistence Is Unsolved
Puma attempted "non-overriding" signal handlers that coexist with handlers registered before it, and had to override its own previous traps while preserving third-party ones. The PR demonstrates the problem is fundamentally hard. This is the evidence behind Rule 9 — don't try to be a good citizen with signals, just don't use them. puma#2767
Lessons from Competitors
Every major error-tracking SDK has had an incident where it damaged the host app. The pattern repeats: an edge case where the SDK's own error handling fails, and that failure propagates into the application.
Sentry Ruby — background worker thread pool for async transport, so network failures never touch the request thread. Transport defines HTTP_ERRORS (Timeout::Error, SocketError, Errno constants), all caught and logged, never propagated. Sentry::Rack::CaptureExceptions captures, reports, then re-raises. Stated design principle: "Any exception that happens after Sentry.capture_* is called shouldn't crash the user's app."
Honeybadger — single-threaded worker processing one Notice at a time, isolated from request threads. Queue capped at 100; when full, new notices are dropped. Adaptive throttling multiplies delay by 1.05x on throttle responses. Middleware is rescue Exception => raised; notify; raise — bare raise preserves the original. Synchronous fallback at exit skips the worker queue when the process is already crashing.
Bugsnag — skip_bugsnag property on exceptions prevents duplicate notifications when re-raised through multiple handlers. Middleware is not allowed to change critical handledState properties. Explicit protection against exception-unwrapping infinite loops via the original_exception pattern. Built-in middleware can be force-disabled if it misbehaves.
Key takeaway: the only defense is defense in depth — rescue at every layer, always re-raise the original, and prefer data loss over application damage. Honeybadger dropping notices at a full queue is the model: our storm protection (circuit breaker + adaptive sampling, v0.8.2) is the same trade made deliberately.
Performance Budgets
| Operation | Budget | Where |
| Breadcrumb callback | <10µs | AS::Notifications subscriber |
| Health snapshot | <1.5ms on every CPU | GC.stat + connection_pool + Thread.list |
| Storm check | <15µs on the CI benchmark's runner | StormProtection::Gate.admit!, on every error |
| Error capture, before the response | <5ms | Middleware + subscriber path. Async capture's enqueue runs after the response where the env has rack.response_finished (decision 0009), and bin/benchmark reports that part with no budget |
| Notification dispatch | 0ms sync | Always async via ActiveJob |
| Pattern detection | <10ms | Background job only |
| Analytics queries | <100ms | Dashboard request, not capture path |
Code Review Checklist
When reviewing changes to the capture path (middleware/, error_reporter.rb, commands/log_error.rb, commands/find_or_increment_error.rb):
- Every public method has a rescue clause
- No
raisestatements (except re-raising the original) - No blocking I/O (HTTP calls, file reads, external services)
- Thread.current cleaned in
ensure - No
ObjectSpace.each_object,Signal.trap,Thread#backtrace, orBindingstorage (ObjectSpace.count_objectsis O(1) and allowed — see Rule 8) - Feature-gated with config check
- Performance measured and within budget
Memory-Bounding Patterns (all three original issues now fixed)
The three unbounded-memory issues this file used to list as open are fixed. They are kept here as
worked examples, because the same mistake is easy to reintroduce. All three live under
lib/rails_error_dashboard/, not app/ — the paths in older notes are stale.
queries/dashboard_stats.rb—average_resolution_timeloaded every resolved record to average them in Ruby. Now aggregates in SQL viascope.pick(Arel.sql(avg_seconds_sql)), with a per-adapter expression. Pattern: aggregate in the database, not in Ruby.services/pattern_detector.rb— iterated ActiveRecord objects to build an hourly distribution. Now a pure function taking an array of plucked timestamps, so the caller controls what is loaded. Pattern: push data loading out to the caller; keep analysis pure.services/cascade_detector.rb— nested query loops with no LIMIT. Nowplucks(error_log_id, occurred_at)and runs an O(N + pairs) two-pointer sweep over the time-sorted rows. Its own comment records why: ~16 bytes/row instead of ~5KB/row, because the host app schedules this job and the lookback window may be large. Pattern: pluck the two columns you need, never load the row.
When adding any query that could touch an unbounded number of rows, pick one of these three shapes.
Risk Ratings — Shipped Surfaces
These were written as pre-implementation risk ratings; all of them have since shipped. They now say where the danger already lives, so treat them as a map of what to re-check when touching these areas.
- CRITICAL: AS::Notifications subscribers (breadcrumbs) — must self-rescue or crash the
caller's SQL query. TracePoint
:line(detailed mode) — 2.5x overhead, opt-in only. Signal.trap was rated critical and consequently never built: the diagnostic dump usesat_exitand an explicit call instead (see Rule 9). - HIGH: TracePoint
:raise, swallowed-exception detection, retention DELETE (batch it —in_batches(of: 1000), never a single unbounded DELETE). - MEDIUM: system health snapshot, auto-reopen race condition, background job health.
- LOW: flexible auth, BRIN index migration, issue-tracker creation.
Since these ratings were written, three further request-path surfaces shipped and belong on the
map: storm protection (circuit breaker + adaptive sampling, v0.8.2) and Rack::Attack event
tracking are both HIGH — they run on live traffic, and Rack::Attack has already needed four
correctness fixes. Outbound OTel export (v0.8.0) is MEDIUM: Integrations::Tracer.emit? gates every span on
the master enable_otel_export flag, the OTel API actually being loaded, and the span kind
appearing in config.otel_spans — which defaults to all four kinds, so it is opt-out per
kind once export is on. When any gate fails it yields a NOOP_SPAN and preserves the block's
return value, which is the pattern to copy for any future façade.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

