rubyn-code 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +620 -0
- data/db/migrations/000_create_schema_migrations.sql +4 -0
- data/db/migrations/001_create_sessions.sql +16 -0
- data/db/migrations/002_create_messages.sql +16 -0
- data/db/migrations/003_create_tasks.sql +17 -0
- data/db/migrations/004_create_task_dependencies.sql +8 -0
- data/db/migrations/005_create_memories.sql +44 -0
- data/db/migrations/006_create_cost_records.sql +16 -0
- data/db/migrations/007_create_hooks.sql +12 -0
- data/db/migrations/008_create_skills_cache.sql +8 -0
- data/db/migrations/009_create_teams.sql +27 -0
- data/db/migrations/010_create_instincts.sql +15 -0
- data/exe/rubyn-code +6 -0
- data/lib/rubyn_code/agent/conversation.rb +193 -0
- data/lib/rubyn_code/agent/loop.rb +517 -0
- data/lib/rubyn_code/agent/loop_detector.rb +78 -0
- data/lib/rubyn_code/auth/oauth.rb +174 -0
- data/lib/rubyn_code/auth/server.rb +126 -0
- data/lib/rubyn_code/auth/token_store.rb +153 -0
- data/lib/rubyn_code/autonomous/daemon.rb +233 -0
- data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
- data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
- data/lib/rubyn_code/background/job.rb +19 -0
- data/lib/rubyn_code/background/notifier.rb +44 -0
- data/lib/rubyn_code/background/worker.rb +146 -0
- data/lib/rubyn_code/cli/app.rb +118 -0
- data/lib/rubyn_code/cli/input_handler.rb +79 -0
- data/lib/rubyn_code/cli/renderer.rb +205 -0
- data/lib/rubyn_code/cli/repl.rb +519 -0
- data/lib/rubyn_code/cli/spinner.rb +100 -0
- data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
- data/lib/rubyn_code/config/defaults.rb +43 -0
- data/lib/rubyn_code/config/project_config.rb +120 -0
- data/lib/rubyn_code/config/settings.rb +127 -0
- data/lib/rubyn_code/context/auto_compact.rb +81 -0
- data/lib/rubyn_code/context/compactor.rb +89 -0
- data/lib/rubyn_code/context/manager.rb +91 -0
- data/lib/rubyn_code/context/manual_compact.rb +87 -0
- data/lib/rubyn_code/context/micro_compact.rb +135 -0
- data/lib/rubyn_code/db/connection.rb +176 -0
- data/lib/rubyn_code/db/migrator.rb +146 -0
- data/lib/rubyn_code/db/schema.rb +106 -0
- data/lib/rubyn_code/hooks/built_in.rb +124 -0
- data/lib/rubyn_code/hooks/registry.rb +99 -0
- data/lib/rubyn_code/hooks/runner.rb +88 -0
- data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
- data/lib/rubyn_code/learning/extractor.rb +191 -0
- data/lib/rubyn_code/learning/injector.rb +138 -0
- data/lib/rubyn_code/learning/instinct.rb +172 -0
- data/lib/rubyn_code/llm/client.rb +218 -0
- data/lib/rubyn_code/llm/message_builder.rb +116 -0
- data/lib/rubyn_code/llm/streaming.rb +203 -0
- data/lib/rubyn_code/mcp/client.rb +139 -0
- data/lib/rubyn_code/mcp/config.rb +83 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
- data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
- data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
- data/lib/rubyn_code/memory/models.rb +62 -0
- data/lib/rubyn_code/memory/search.rb +181 -0
- data/lib/rubyn_code/memory/session_persistence.rb +194 -0
- data/lib/rubyn_code/memory/store.rb +199 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
- data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
- data/lib/rubyn_code/observability/models.rb +29 -0
- data/lib/rubyn_code/observability/token_counter.rb +42 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
- data/lib/rubyn_code/output/diff_renderer.rb +212 -0
- data/lib/rubyn_code/output/formatter.rb +120 -0
- data/lib/rubyn_code/permissions/deny_list.rb +49 -0
- data/lib/rubyn_code/permissions/policy.rb +59 -0
- data/lib/rubyn_code/permissions/prompter.rb +80 -0
- data/lib/rubyn_code/permissions/tier.rb +22 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
- data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
- data/lib/rubyn_code/skills/catalog.rb +70 -0
- data/lib/rubyn_code/skills/document.rb +80 -0
- data/lib/rubyn_code/skills/loader.rb +57 -0
- data/lib/rubyn_code/sub_agents/runner.rb +168 -0
- data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
- data/lib/rubyn_code/tasks/dag.rb +208 -0
- data/lib/rubyn_code/tasks/manager.rb +212 -0
- data/lib/rubyn_code/tasks/models.rb +31 -0
- data/lib/rubyn_code/teams/mailbox.rb +128 -0
- data/lib/rubyn_code/teams/manager.rb +175 -0
- data/lib/rubyn_code/teams/teammate.rb +38 -0
- data/lib/rubyn_code/tools/background_run.rb +41 -0
- data/lib/rubyn_code/tools/base.rb +84 -0
- data/lib/rubyn_code/tools/bash.rb +81 -0
- data/lib/rubyn_code/tools/bundle_add.rb +53 -0
- data/lib/rubyn_code/tools/bundle_install.rb +41 -0
- data/lib/rubyn_code/tools/compact.rb +57 -0
- data/lib/rubyn_code/tools/db_migrate.rb +52 -0
- data/lib/rubyn_code/tools/edit_file.rb +49 -0
- data/lib/rubyn_code/tools/executor.rb +62 -0
- data/lib/rubyn_code/tools/git_commit.rb +97 -0
- data/lib/rubyn_code/tools/git_diff.rb +61 -0
- data/lib/rubyn_code/tools/git_log.rb +59 -0
- data/lib/rubyn_code/tools/git_status.rb +59 -0
- data/lib/rubyn_code/tools/glob.rb +44 -0
- data/lib/rubyn_code/tools/grep.rb +81 -0
- data/lib/rubyn_code/tools/load_skill.rb +41 -0
- data/lib/rubyn_code/tools/memory_search.rb +77 -0
- data/lib/rubyn_code/tools/memory_write.rb +52 -0
- data/lib/rubyn_code/tools/rails_generate.rb +54 -0
- data/lib/rubyn_code/tools/read_file.rb +38 -0
- data/lib/rubyn_code/tools/read_inbox.rb +64 -0
- data/lib/rubyn_code/tools/registry.rb +48 -0
- data/lib/rubyn_code/tools/review_pr.rb +145 -0
- data/lib/rubyn_code/tools/run_specs.rb +75 -0
- data/lib/rubyn_code/tools/schema.rb +59 -0
- data/lib/rubyn_code/tools/send_message.rb +53 -0
- data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
- data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
- data/lib/rubyn_code/tools/task.rb +148 -0
- data/lib/rubyn_code/tools/web_fetch.rb +108 -0
- data/lib/rubyn_code/tools/web_search.rb +196 -0
- data/lib/rubyn_code/tools/write_file.rb +30 -0
- data/lib/rubyn_code/version.rb +5 -0
- data/lib/rubyn_code.rb +203 -0
- data/skills/code_quality/fits_in_your_head.md +189 -0
- data/skills/code_quality/naming_conventions.md +213 -0
- data/skills/code_quality/null_object.md +205 -0
- data/skills/code_quality/technical_debt.md +135 -0
- data/skills/code_quality/value_objects.md +216 -0
- data/skills/code_quality/yagni.md +176 -0
- data/skills/design_patterns/adapter.md +191 -0
- data/skills/design_patterns/bridge_memento_visitor.md +254 -0
- data/skills/design_patterns/builder.md +158 -0
- data/skills/design_patterns/command.md +126 -0
- data/skills/design_patterns/composite.md +147 -0
- data/skills/design_patterns/decorator.md +204 -0
- data/skills/design_patterns/facade.md +133 -0
- data/skills/design_patterns/factory_method.md +169 -0
- data/skills/design_patterns/iterator.md +116 -0
- data/skills/design_patterns/mediator.md +133 -0
- data/skills/design_patterns/observer.md +177 -0
- data/skills/design_patterns/proxy.md +140 -0
- data/skills/design_patterns/singleton.md +124 -0
- data/skills/design_patterns/state.md +207 -0
- data/skills/design_patterns/strategy.md +127 -0
- data/skills/design_patterns/template_method.md +173 -0
- data/skills/gems/devise.md +365 -0
- data/skills/gems/dry_rb.md +186 -0
- data/skills/gems/factory_bot.md +268 -0
- data/skills/gems/faraday.md +263 -0
- data/skills/gems/graphql_ruby.md +514 -0
- data/skills/gems/pundit.md +446 -0
- data/skills/gems/redis.md +219 -0
- data/skills/gems/rubocop.md +257 -0
- data/skills/gems/sidekiq.md +360 -0
- data/skills/gems/stripe.md +224 -0
- data/skills/minitest/assertions.md +185 -0
- data/skills/minitest/fixtures.md +238 -0
- data/skills/minitest/integration_tests.md +210 -0
- data/skills/minitest/mailers_and_jobs.md +218 -0
- data/skills/minitest/mocking_stubbing.md +202 -0
- data/skills/minitest/service_tests_and_performance.md +246 -0
- data/skills/minitest/structure_and_conventions.md +169 -0
- data/skills/minitest/system_tests.md +237 -0
- data/skills/rails/action_cable.md +160 -0
- data/skills/rails/active_record_basics.md +174 -0
- data/skills/rails/active_storage.md +242 -0
- data/skills/rails/api_design.md +212 -0
- data/skills/rails/associations.md +182 -0
- data/skills/rails/background_jobs.md +212 -0
- data/skills/rails/caching.md +158 -0
- data/skills/rails/callbacks.md +135 -0
- data/skills/rails/concerns_controllers.md +218 -0
- data/skills/rails/concerns_models.md +280 -0
- data/skills/rails/controllers.md +190 -0
- data/skills/rails/engines.md +201 -0
- data/skills/rails/form_objects.md +168 -0
- data/skills/rails/hotwire.md +229 -0
- data/skills/rails/internationalization.md +192 -0
- data/skills/rails/logging.md +198 -0
- data/skills/rails/mailers.md +180 -0
- data/skills/rails/migrations.md +200 -0
- data/skills/rails/multitenancy.md +207 -0
- data/skills/rails/n_plus_one.md +151 -0
- data/skills/rails/presenters.md +244 -0
- data/skills/rails/query_objects.md +177 -0
- data/skills/rails/routing.md +194 -0
- data/skills/rails/scopes.md +187 -0
- data/skills/rails/security.md +233 -0
- data/skills/rails/serializers.md +243 -0
- data/skills/rails/service_objects.md +184 -0
- data/skills/rails/testing_strategy.md +258 -0
- data/skills/rails/validations.md +206 -0
- data/skills/refactoring/code_smells.md +251 -0
- data/skills/refactoring/command_query_separation.md +166 -0
- data/skills/refactoring/encapsulate_collection.md +125 -0
- data/skills/refactoring/extract_class.md +138 -0
- data/skills/refactoring/extract_method.md +185 -0
- data/skills/refactoring/replace_conditional.md +211 -0
- data/skills/refactoring/value_objects.md +246 -0
- data/skills/rspec/build_stubbed.md +199 -0
- data/skills/rspec/factory_design.md +206 -0
- data/skills/rspec/let_vs_let_bang.md +161 -0
- data/skills/rspec/mocking_stubbing.md +209 -0
- data/skills/rspec/request_specs.md +212 -0
- data/skills/rspec/service_specs.md +262 -0
- data/skills/rspec/shared_examples.md +244 -0
- data/skills/rspec/system_specs.md +286 -0
- data/skills/rspec/test_performance.md +215 -0
- data/skills/ruby/blocks_procs_lambdas.md +204 -0
- data/skills/ruby/classes.md +155 -0
- data/skills/ruby/concurrency.md +194 -0
- data/skills/ruby/data_struct_openstruct.md +158 -0
- data/skills/ruby/debugging_profiling.md +204 -0
- data/skills/ruby/enumerable_patterns.md +168 -0
- data/skills/ruby/exception_handling.md +199 -0
- data/skills/ruby/file_io.md +217 -0
- data/skills/ruby/hashes.md +195 -0
- data/skills/ruby/metaprogramming.md +170 -0
- data/skills/ruby/modules.md +210 -0
- data/skills/ruby/pattern_matching.md +177 -0
- data/skills/ruby/regular_expressions.md +166 -0
- data/skills/ruby/result_objects.md +200 -0
- data/skills/ruby/strings.md +177 -0
- data/skills/ruby_project/bundler_dependencies.md +181 -0
- data/skills/ruby_project/cli_tools.md +224 -0
- data/skills/ruby_project/rake_tasks.md +146 -0
- data/skills/ruby_project/structure.md +261 -0
- data/skills/sinatra/application_structure.md +241 -0
- data/skills/sinatra/middleware_and_deployment.md +221 -0
- data/skills/sinatra/testing.md +233 -0
- data/skills/solid/dependency_inversion.md +195 -0
- data/skills/solid/interface_segregation.md +237 -0
- data/skills/solid/liskov_substitution.md +263 -0
- data/skills/solid/open_closed.md +212 -0
- data/skills/solid/single_responsibility.md +183 -0
- metadata +397 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# Rails: Logging and Instrumentation
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Use structured logging for production observability, `ActiveSupport::Notifications` for custom instrumentation, and tagged logging for request-scoped context. Logs should answer: what happened, when, to whom, and how long it took.
|
|
6
|
+
|
|
7
|
+
### Structured Logging with Lograge
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# Gemfile
|
|
11
|
+
gem "lograge"
|
|
12
|
+
|
|
13
|
+
# config/environments/production.rb
|
|
14
|
+
config.lograge.enabled = true
|
|
15
|
+
config.lograge.formatter = Lograge::Formatters::Json.new
|
|
16
|
+
|
|
17
|
+
config.lograge.custom_options = lambda do |event|
|
|
18
|
+
{
|
|
19
|
+
user_id: event.payload[:user_id],
|
|
20
|
+
request_id: event.payload[:request_id],
|
|
21
|
+
ip: event.payload[:ip],
|
|
22
|
+
credits_used: event.payload[:credits_used]
|
|
23
|
+
}.compact
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
config.lograge.custom_payload do |controller|
|
|
27
|
+
{
|
|
28
|
+
user_id: controller.current_user&.id,
|
|
29
|
+
request_id: controller.request.request_id,
|
|
30
|
+
ip: controller.request.remote_ip
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Output per request:
|
|
35
|
+
# {"method":"POST","path":"/api/v1/ai/refactor","format":"json","controller":"Api::V1::Ai::RefactorController",
|
|
36
|
+
# "action":"create","status":200,"duration":1245.3,"user_id":42,"request_id":"abc-123","credits_used":3}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Tagged Logging
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# config/application.rb
|
|
43
|
+
config.log_tags = [:request_id] # Adds request ID to every log line
|
|
44
|
+
|
|
45
|
+
# Custom tags
|
|
46
|
+
config.log_tags = [
|
|
47
|
+
:request_id,
|
|
48
|
+
->(request) { "user:#{request.cookie_jar.signed[:user_id]}" }
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
# Manual tagging in services
|
|
52
|
+
Rails.logger.tagged("OrderService", "user:#{user.id}") do
|
|
53
|
+
Rails.logger.info("Creating order")
|
|
54
|
+
Rails.logger.info("Order created: #{order.id}")
|
|
55
|
+
end
|
|
56
|
+
# [abc-123] [OrderService] [user:42] Creating order
|
|
57
|
+
# [abc-123] [OrderService] [user:42] Order created: 17
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Log Levels Done Right
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
class Ai::CompletionService
|
|
64
|
+
def call(prompt, context:)
|
|
65
|
+
# DEBUG: Detailed info for development troubleshooting — never in production
|
|
66
|
+
Rails.logger.debug { "Prompt tokens estimate: #{estimate_tokens(prompt)}" }
|
|
67
|
+
|
|
68
|
+
# INFO: Normal operations that are useful for monitoring
|
|
69
|
+
Rails.logger.info("[AI] Request started model=#{@model} user=#{@user.id}")
|
|
70
|
+
|
|
71
|
+
response = @client.complete(messages, model: @model, max_tokens: 4096)
|
|
72
|
+
|
|
73
|
+
# INFO: Successful completion with metrics
|
|
74
|
+
Rails.logger.info(
|
|
75
|
+
"[AI] Request completed model=#{@model} " \
|
|
76
|
+
"input_tokens=#{response.input_tokens} output_tokens=#{response.output_tokens} " \
|
|
77
|
+
"duration_ms=#{elapsed_ms} cache_hit=#{response.cache_read_tokens > 0}"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
response
|
|
81
|
+
rescue Faraday::TimeoutError => e
|
|
82
|
+
# WARN: Recoverable problem — retrying or degraded behavior
|
|
83
|
+
Rails.logger.warn("[AI] Timeout after #{elapsed_ms}ms, retrying (attempt #{retries}/3)")
|
|
84
|
+
retry if (retries += 1) <= 3
|
|
85
|
+
raise
|
|
86
|
+
rescue Anthropic::ApiError => e
|
|
87
|
+
# ERROR: Failure that needs attention but isn't crashing the app
|
|
88
|
+
Rails.logger.error("[AI] API error status=#{e.status} message=#{e.message} user=#{@user.id}")
|
|
89
|
+
raise
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
# FATAL: Unexpected failure — something is seriously wrong
|
|
92
|
+
Rails.logger.fatal("[AI] Unexpected error: #{e.class}: #{e.message}")
|
|
93
|
+
Rails.logger.fatal(e.backtrace.first(10).join("\n"))
|
|
94
|
+
raise
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Custom Instrumentation with ActiveSupport::Notifications
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
# Publishing events
|
|
103
|
+
class Credits::DeductionService
|
|
104
|
+
def call(user, credits)
|
|
105
|
+
ActiveSupport::Notifications.instrument("credits.deducted", {
|
|
106
|
+
user_id: user.id,
|
|
107
|
+
credits: credits,
|
|
108
|
+
balance_after: user.credit_balance - credits
|
|
109
|
+
}) do
|
|
110
|
+
user.deduct_credits!(credits)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Subscribing to events
|
|
116
|
+
# config/initializers/instrumentation.rb
|
|
117
|
+
ActiveSupport::Notifications.subscribe("credits.deducted") do |name, start, finish, id, payload|
|
|
118
|
+
duration = (finish - start) * 1000
|
|
119
|
+
Rails.logger.info(
|
|
120
|
+
"[Credits] Deducted #{payload[:credits]} from user=#{payload[:user_id]} " \
|
|
121
|
+
"balance=#{payload[:balance_after]} duration=#{duration.round(1)}ms"
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
ActiveSupport::Notifications.subscribe("credits.deducted") do |*, payload|
|
|
126
|
+
StatsD.increment("credits.deducted", tags: ["user:#{payload[:user_id]}"])
|
|
127
|
+
StatsD.gauge("credits.balance", payload[:balance_after], tags: ["user:#{payload[:user_id]}"])
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Subscribe to Rails built-in events
|
|
131
|
+
ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
|
|
132
|
+
if payload[:duration] > 100 # Log slow queries
|
|
133
|
+
Rails.logger.warn("[SlowQuery] #{payload[:duration].round(1)}ms: #{payload[:sql]}")
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*, payload|
|
|
138
|
+
if payload[:duration] > 1000 # Log slow requests
|
|
139
|
+
Rails.logger.warn("[SlowRequest] #{payload[:path]} #{payload[:duration].round(0)}ms")
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### What to Log (and What Not To)
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
# GOOD: Structured, searchable, useful
|
|
148
|
+
Rails.logger.info("[Orders::Create] Created order=#{order.id} user=#{user.id} total=#{order.total} items=#{order.line_items.count}")
|
|
149
|
+
|
|
150
|
+
# GOOD: Error with context
|
|
151
|
+
Rails.logger.error("[Payments] Charge failed user=#{user.id} amount=#{amount} error=#{e.message}")
|
|
152
|
+
|
|
153
|
+
# BAD: Unstructured, unsearchable
|
|
154
|
+
Rails.logger.info("Order created successfully!")
|
|
155
|
+
Rails.logger.info("Something went wrong: #{e}")
|
|
156
|
+
|
|
157
|
+
# BAD: Logging sensitive data
|
|
158
|
+
Rails.logger.info("User signed in with password: #{params[:password]}")
|
|
159
|
+
Rails.logger.info("API key used: #{api_key}")
|
|
160
|
+
Rails.logger.info("Credit card: #{card_number}")
|
|
161
|
+
|
|
162
|
+
# BAD: Logging entire objects (huge, contains sensitive fields)
|
|
163
|
+
Rails.logger.info("User: #{user.inspect}")
|
|
164
|
+
Rails.logger.info("Params: #{params.inspect}")
|
|
165
|
+
|
|
166
|
+
# GOOD: Log only what you need
|
|
167
|
+
Rails.logger.info("[Auth] Sign in user=#{user.id} email=#{user.email} ip=#{request.remote_ip}")
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Why This Is Good
|
|
171
|
+
|
|
172
|
+
- **Structured logs are searchable.** `user=42 model=haiku duration_ms=345` can be filtered and aggregated in any log platform (Datadog, Papertrail, CloudWatch). "Order created successfully!" can't.
|
|
173
|
+
- **Tagged logging adds context automatically.** Every log line in a request includes the request ID and user ID — no manual threading of context.
|
|
174
|
+
- **`ActiveSupport::Notifications` decouples events from reactions.** The service publishes "credits deducted." Logging subscribes. Metrics subscribes. Alerting subscribes. The service doesn't know about any of them.
|
|
175
|
+
- **Log levels filter noise.** Production runs at `:info`. Development runs at `:debug`. Slow query warnings are `:warn` — they're visible in production without drowning in debug noise.
|
|
176
|
+
|
|
177
|
+
## Anti-Pattern
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
# BAD: puts in production code
|
|
181
|
+
puts "Order created"
|
|
182
|
+
|
|
183
|
+
# BAD: p for debugging left in committed code
|
|
184
|
+
p user.attributes
|
|
185
|
+
|
|
186
|
+
# BAD: Logging inside a loop (10,000 log lines for 10,000 records)
|
|
187
|
+
users.each { |u| Rails.logger.info("Processing user #{u.id}") }
|
|
188
|
+
|
|
189
|
+
# BETTER: Log the batch
|
|
190
|
+
Rails.logger.info("[BatchProcess] Processing #{users.count} users")
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## When To Apply
|
|
194
|
+
|
|
195
|
+
- **Every service object** should log entry, exit, and errors with structured key=value pairs.
|
|
196
|
+
- **Lograge in production** — always. Default Rails logging is verbose and unstructured.
|
|
197
|
+
- **`ActiveSupport::Notifications`** for cross-cutting metrics (slow queries, credit usage, API latency).
|
|
198
|
+
- **Never log passwords, API keys, tokens, or PII.**
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# Rails: Mailers
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Mailers are the email equivalent of controllers — thin orchestrators that set up data and pick a template. Keep them simple, always deliver asynchronously, use previews for development, and test the envelope (to, from, subject) separately from the content.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# app/mailers/application_mailer.rb
|
|
9
|
+
class ApplicationMailer < ActionMailer::Base
|
|
10
|
+
default from: "Rubyn <noreply@rubyn.ai>"
|
|
11
|
+
layout "mailer"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# app/mailers/order_mailer.rb
|
|
15
|
+
class OrderMailer < ApplicationMailer
|
|
16
|
+
def confirmation(order)
|
|
17
|
+
@order = order
|
|
18
|
+
@user = order.user
|
|
19
|
+
|
|
20
|
+
mail(
|
|
21
|
+
to: @user.email,
|
|
22
|
+
subject: "Order #{@order.reference} Confirmed"
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def shipped(order)
|
|
27
|
+
@order = order
|
|
28
|
+
@user = order.user
|
|
29
|
+
@tracking_url = tracking_url(@order.tracking_number)
|
|
30
|
+
|
|
31
|
+
mail(
|
|
32
|
+
to: @user.email,
|
|
33
|
+
subject: "Your order has shipped!"
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def receipt(order)
|
|
38
|
+
@order = order.includes(:line_items)
|
|
39
|
+
@user = order.user
|
|
40
|
+
|
|
41
|
+
attachments["receipt-#{@order.reference}.pdf"] = Orders::ReceiptPdfService.call(@order)
|
|
42
|
+
|
|
43
|
+
mail(
|
|
44
|
+
to: @user.email,
|
|
45
|
+
subject: "Receipt for Order #{@order.reference}"
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def tracking_url(number)
|
|
52
|
+
"https://tracking.example.com/#{number}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Always Deliver Asynchronously
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
# GOOD: deliver_later — enqueues to Active Job (Sidekiq/etc)
|
|
61
|
+
OrderMailer.confirmation(order).deliver_later
|
|
62
|
+
|
|
63
|
+
# GOOD: deliver_later with delay
|
|
64
|
+
OrderMailer.review_reminder(order).deliver_later(wait: 7.days)
|
|
65
|
+
|
|
66
|
+
# BAD: deliver_now blocks the request
|
|
67
|
+
OrderMailer.confirmation(order).deliver_now
|
|
68
|
+
# User waits 1-3 seconds for SMTP handshake — terrible UX
|
|
69
|
+
|
|
70
|
+
# EXCEPTION: deliver_now is fine inside a background job
|
|
71
|
+
class OrderConfirmationJob < ApplicationJob
|
|
72
|
+
def perform(order_id)
|
|
73
|
+
order = Order.find(order_id)
|
|
74
|
+
OrderMailer.confirmation(order).deliver_now # Already async — job handles the retry
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Mailer Previews
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
# test/mailers/previews/order_mailer_preview.rb (or spec/mailers/previews/)
|
|
83
|
+
class OrderMailerPreview < ActionMailer::Preview
|
|
84
|
+
def confirmation
|
|
85
|
+
order = Order.first || FactoryBot.create(:order)
|
|
86
|
+
OrderMailer.confirmation(order)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def shipped
|
|
90
|
+
order = Order.shipped.first || FactoryBot.create(:order, :shipped, tracking_number: "1Z999AA10123456784")
|
|
91
|
+
OrderMailer.shipped(order)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def receipt
|
|
95
|
+
order = Order.includes(:line_items).first || FactoryBot.create(:order, :with_line_items)
|
|
96
|
+
OrderMailer.receipt(order)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Visit http://localhost:3000/rails/mailers to see rendered previews
|
|
101
|
+
# No actual email sent — just renders the template in the browser
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Views
|
|
105
|
+
|
|
106
|
+
```erb
|
|
107
|
+
<%# app/views/order_mailer/confirmation.html.erb %>
|
|
108
|
+
<h1>Order Confirmed!</h1>
|
|
109
|
+
<p>Hi <%= @user.name %>,</p>
|
|
110
|
+
<p>Your order <strong><%= @order.reference %></strong> has been confirmed.</p>
|
|
111
|
+
|
|
112
|
+
<table>
|
|
113
|
+
<% @order.line_items.each do |item| %>
|
|
114
|
+
<tr>
|
|
115
|
+
<td><%= item.product.name %></td>
|
|
116
|
+
<td><%= item.quantity %></td>
|
|
117
|
+
<td>$<%= format("%.2f", item.unit_price / 100.0) %></td>
|
|
118
|
+
</tr>
|
|
119
|
+
<% end %>
|
|
120
|
+
</table>
|
|
121
|
+
|
|
122
|
+
<p><strong>Total: $<%= format("%.2f", @order.total / 100.0) %></strong></p>
|
|
123
|
+
<p>Shipping to: <%= @order.shipping_address %></p>
|
|
124
|
+
|
|
125
|
+
<%= link_to "View Order", order_url(@order) %>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
```erb
|
|
129
|
+
<%# app/views/order_mailer/confirmation.text.erb — always provide a text version %>
|
|
130
|
+
Order Confirmed!
|
|
131
|
+
|
|
132
|
+
Hi <%= @user.name %>,
|
|
133
|
+
|
|
134
|
+
Your order <%= @order.reference %> has been confirmed.
|
|
135
|
+
|
|
136
|
+
<% @order.line_items.each do |item| %>
|
|
137
|
+
- <%= item.product.name %> x<%= item.quantity %> — $<%= format("%.2f", item.unit_price / 100.0) %>
|
|
138
|
+
<% end %>
|
|
139
|
+
|
|
140
|
+
Total: $<%= format("%.2f", @order.total / 100.0) %>
|
|
141
|
+
Shipping to: <%= @order.shipping_address %>
|
|
142
|
+
|
|
143
|
+
View your order: <%= order_url(@order) %>
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Why This Is Good
|
|
147
|
+
|
|
148
|
+
- **Thin mailers.** The mailer sets instance variables and calls `mail()`. No business logic, no formatting, no conditionals beyond what's needed for the template.
|
|
149
|
+
- **`deliver_later` is non-blocking.** The user's request completes instantly. The email sends in a background job with automatic retries.
|
|
150
|
+
- **Previews catch visual bugs.** See the rendered email in your browser without sending it. Catch broken layouts, missing data, and formatting issues before they reach users.
|
|
151
|
+
- **Text + HTML versions.** Email clients that don't render HTML (or users who prefer plain text) get a readable version. Also improves spam score.
|
|
152
|
+
- **Attachments via service objects.** PDF generation is delegated to a service, not done inline in the mailer.
|
|
153
|
+
|
|
154
|
+
## Anti-Pattern
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
# BAD: Business logic in the mailer
|
|
158
|
+
class OrderMailer < ApplicationMailer
|
|
159
|
+
def confirmation(order)
|
|
160
|
+
@order = order
|
|
161
|
+
@user = order.user
|
|
162
|
+
@discount = order.total > 100_00 ? "Use code SAVE10 for 10% off!" : nil
|
|
163
|
+
@recommendations = Product.where.not(id: order.line_items.pluck(:product_id)).limit(3)
|
|
164
|
+
@user.update!(last_emailed_at: Time.current) # Side effect in a mailer!
|
|
165
|
+
mail(to: @user.email, subject: "Order Confirmed")
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## When To Apply
|
|
171
|
+
|
|
172
|
+
- **Every email.** Use mailers for all outgoing email, even simple ones. Direct `Mail.deliver` bypasses Rails' template rendering, previews, and testing infrastructure.
|
|
173
|
+
- **`deliver_later` always.** The only exception is inside a background job that's already async.
|
|
174
|
+
- **Previews for every mailer.** Set them up once, save hours of "send test email, check inbox, repeat."
|
|
175
|
+
- **Both HTML and text templates.** Plain text is required for accessibility and deliverability.
|
|
176
|
+
|
|
177
|
+
## When NOT To Apply
|
|
178
|
+
|
|
179
|
+
- **Transactional SMS or push notifications.** These aren't emails — use dedicated services, not ActionMailer.
|
|
180
|
+
- **Don't put conditional sending logic in the mailer.** "Don't send if user has unsubscribed" belongs in the service that calls the mailer, not in the mailer itself.
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Rails: Safe Migrations
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Write migrations that are safe for zero-downtime deploys. Add indexes concurrently. Never remove columns without a two-step deploy. Use `strong_migrations` gem to catch unsafe operations automatically.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# SAFE: Add a column with a default (Rails 5+ handles this without rewriting the table)
|
|
9
|
+
class AddStatusToOrders < ActiveRecord::Migration[8.0]
|
|
10
|
+
def change
|
|
11
|
+
add_column :orders, :priority, :integer, default: 0, null: false
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
# SAFE: Add an index concurrently (doesn't lock the table)
|
|
18
|
+
class AddIndexOnOrdersStatus < ActiveRecord::Migration[8.0]
|
|
19
|
+
disable_ddl_transaction!
|
|
20
|
+
|
|
21
|
+
def change
|
|
22
|
+
add_index :orders, :status, algorithm: :concurrently
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
# SAFE: Two-step column removal
|
|
29
|
+
# Deploy 1: Stop using the column in code, add ignore
|
|
30
|
+
class IgnoreDeletedAtOnOrders < ActiveRecord::Migration[8.0]
|
|
31
|
+
def change
|
|
32
|
+
safety_assured { remove_column :orders, :deleted_at, :datetime }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
# But first: update the model to ignore the column
|
|
36
|
+
# class Order < ApplicationRecord
|
|
37
|
+
# self.ignored_columns += ["deleted_at"]
|
|
38
|
+
# end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# SAFE: Rename via add/copy/remove (not rename_column)
|
|
43
|
+
# Step 1: Add new column
|
|
44
|
+
class AddFullNameToUsers < ActiveRecord::Migration[8.0]
|
|
45
|
+
def change
|
|
46
|
+
add_column :users, :full_name, :string
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Step 2: Backfill data (in a separate migration or rake task)
|
|
51
|
+
class BackfillFullName < ActiveRecord::Migration[8.0]
|
|
52
|
+
def up
|
|
53
|
+
User.in_batches.update_all("full_name = name")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def down
|
|
57
|
+
# no-op
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Step 3: (After deploy) Remove old column
|
|
62
|
+
class RemoveNameFromUsers < ActiveRecord::Migration[8.0]
|
|
63
|
+
def change
|
|
64
|
+
safety_assured { remove_column :users, :name, :string }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
# SAFE: Add a foreign key constraint
|
|
71
|
+
class AddForeignKeyOnOrders < ActiveRecord::Migration[8.0]
|
|
72
|
+
def change
|
|
73
|
+
add_foreign_key :orders, :users, validate: false
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Separate migration to validate (non-blocking)
|
|
78
|
+
class ValidateForeignKeyOnOrders < ActiveRecord::Migration[8.0]
|
|
79
|
+
def change
|
|
80
|
+
validate_foreign_key :orders, :users
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Add `strong_migrations` to catch unsafe operations:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
# Gemfile
|
|
89
|
+
gem 'strong_migrations'
|
|
90
|
+
|
|
91
|
+
# config/initializers/strong_migrations.rb
|
|
92
|
+
StrongMigrations.start_after = 20260101000000
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Why This Is Good
|
|
96
|
+
|
|
97
|
+
- **Zero-downtime deploys.** The new code deploys while the migration runs. No maintenance window, no "please wait" page, no interruption to users.
|
|
98
|
+
- **Concurrent indexes don't lock.** `algorithm: :concurrently` builds the index without locking the table for writes. A standard `add_index` on a 10-million-row table locks writes for minutes.
|
|
99
|
+
- **Two-step column removal prevents errors.** If you remove a column while old code is still running (during deploy), queries referencing that column fail. Ignoring the column first ensures old code doesn't reference it.
|
|
100
|
+
- **`strong_migrations` catches mistakes.** It raises an error if you try to run an unsafe migration in production, with a helpful message explaining the safe alternative.
|
|
101
|
+
- **Separate validation of foreign keys.** Adding a FK with `validate: false` is instant. Validating it in a separate migration scans the table without blocking writes.
|
|
102
|
+
|
|
103
|
+
## Anti-Pattern
|
|
104
|
+
|
|
105
|
+
Migrations that lock tables, remove columns in one step, or change types without safety:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
# DANGEROUS: Locks the entire table while building the index
|
|
109
|
+
class AddIndexOnOrdersEmail < ActiveRecord::Migration[8.0]
|
|
110
|
+
def change
|
|
111
|
+
add_index :orders, :email # Blocks writes on large tables
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
# DANGEROUS: Removes column while running code may still reference it
|
|
118
|
+
class RemoveLegacyField < ActiveRecord::Migration[8.0]
|
|
119
|
+
def change
|
|
120
|
+
remove_column :orders, :old_status # Active servers still querying old_status
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
# DANGEROUS: Changes column type — rewrites entire table, locks it
|
|
127
|
+
class ChangeOrderTotalType < ActiveRecord::Migration[8.0]
|
|
128
|
+
def change
|
|
129
|
+
change_column :orders, :total, :decimal, precision: 10, scale: 2
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
# DANGEROUS: Data migration mixed with schema migration
|
|
136
|
+
class AddAndBackfillStatus < ActiveRecord::Migration[8.0]
|
|
137
|
+
def change
|
|
138
|
+
add_column :orders, :status, :string, default: "pending"
|
|
139
|
+
Order.update_all(status: "pending") # Locks table, runs in same transaction
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Why This Is Bad
|
|
145
|
+
|
|
146
|
+
- **Table locks block writes.** A standard `add_index` acquires an exclusive lock on the table. On a 10-million-row orders table, this blocks all INSERT/UPDATE/DELETE for 5-30 minutes. Every request that touches orders hangs.
|
|
147
|
+
- **Column removal during deploy breaks requests.** Rails caches the column list at boot. Old servers (still running during rolling deploy) try to SELECT the removed column and get a database error.
|
|
148
|
+
- **Type changes rewrite the entire table.** Changing a column type on a 50-million-row table creates a new copy of the table with the new type, copies all data, then swaps. This locks the table for the entire duration.
|
|
149
|
+
- **Data migrations in schema migrations are dangerous.** They run inside the migration transaction, hold locks longer, and can timeout. If they fail halfway, the schema migration rolls back too. Keep data migrations separate.
|
|
150
|
+
|
|
151
|
+
## When To Apply
|
|
152
|
+
|
|
153
|
+
- **Every migration in a production application.** Even if you're small now, building safe habits means you never have to relearn when your tables grow to millions of rows.
|
|
154
|
+
- **`add_index` on any table with more than 10,000 rows** should use `algorithm: :concurrently`.
|
|
155
|
+
- **Any column removal** should use the two-step process: ignore first, remove in a later deploy.
|
|
156
|
+
- **Any column type change** should use the add-new/copy/remove-old pattern.
|
|
157
|
+
- **Data backfills** should be separate from schema changes, use `in_batches`, and run outside the migration transaction.
|
|
158
|
+
|
|
159
|
+
## When NOT To Apply
|
|
160
|
+
|
|
161
|
+
- **Brand new tables** (no data yet) can have indexes added normally. No need for `concurrently` on an empty table.
|
|
162
|
+
- **Development/test environments** don't need concurrent indexes or two-step removal. These safeguards are for production deploys.
|
|
163
|
+
- **Tiny tables** (reference data with 100 rows) can be modified with standard migrations. The lock duration is negligible.
|
|
164
|
+
|
|
165
|
+
## Edge Cases
|
|
166
|
+
|
|
167
|
+
**Adding a NOT NULL column to an existing table:**
|
|
168
|
+
Add the column as nullable first, backfill, then add the constraint:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
# Step 1
|
|
172
|
+
add_column :orders, :region, :string
|
|
173
|
+
|
|
174
|
+
# Step 2 (separate migration)
|
|
175
|
+
Order.in_batches.update_all(region: "us")
|
|
176
|
+
|
|
177
|
+
# Step 3 (separate migration)
|
|
178
|
+
change_column_null :orders, :region, false
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**Adding a column with a default on PostgreSQL:**
|
|
182
|
+
Rails 5+ with PostgreSQL adds the default at the column metadata level, not by rewriting the table. This is safe and instant. But always verify your Rails and PostgreSQL versions support this.
|
|
183
|
+
|
|
184
|
+
**`reversible` for complex migrations:**
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
class AddStatusIndex < ActiveRecord::Migration[8.0]
|
|
188
|
+
disable_ddl_transaction!
|
|
189
|
+
|
|
190
|
+
def change
|
|
191
|
+
reversible do |dir|
|
|
192
|
+
dir.up { add_index :orders, :status, algorithm: :concurrently }
|
|
193
|
+
dir.down { remove_index :orders, :status }
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
**Renaming tables:**
|
|
200
|
+
Don't. Add a new table, migrate data, drop the old one. Or use a database view as an alias during the transition.
|