Skip to content

Commit 77818a2

Browse files
committed
add storage interface with Redis cluster and Memcached adapters for distributed deployments
1 parent 995c53b commit 77818a2

24 files changed

+1303
-72
lines changed

.github/workflows/main.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,25 @@ jobs:
2626
env:
2727
RAILS_VERSION: ${{ matrix.rails }}
2828
RAILS_ENV: development
29+
REDIS_URL: redis://localhost:6379
30+
REDIS_CLUSTER_NODE_1: redis://localhost:8000
31+
REDIS_CLUSTER_NODE_2: redis://localhost:8001
32+
REDIS_CLUSTER_NODE_3: redis://localhost:8002
33+
MEMCACHED_URL: localhost:11211
2934

3035
steps:
3136
- uses: actions/checkout@v4
37+
- name: Start storage services
38+
run: |
39+
docker compose -f docker-compose.storage.yml up -d
40+
docker compose -f docker-compose.storage.yml wait redis-cluster-init
3241
- name: Set up Ruby
3342
uses: ruby/setup-ruby@v1
3443
with:
3544
ruby-version: ${{ matrix.ruby }}
3645
bundler-cache: true
3746
- name: Run the default task
3847
run: bundle exec rake
48+
- name: Stop storage services
49+
if: always()
50+
run: docker compose -f docker-compose.storage.yml down

.gitignore

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
/.bundle/
2-
/tmp/
1+
.bundle/
2+
log/
3+
tmp/
34
Gemfile.lock
45
*.gem

CHANGELOG.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
## [Unreleased]
22

3+
- Add storage interface with Redis cluster and Memcached adapters for distributed deployments
4+
35
## [0.4.0] - 2025-08-30
46

5-
- Add sampling configuration to control percentage of requests profiled
6-
- Replace response body buffering with streaming response wrapper
7-
- Make prosopite logging thread-safe with thread-local storage
8-
- Fix missing namespace for VERNIER_PROFILE_OUT_FILE_EXTENSION in engine routes
97
- Improve error handling and input validation
8+
- Fix missing namespace for VERNIER_PROFILE_OUT_FILE_EXTENSION in engine routes
9+
- Make prosopite logging thread-safe with thread-local storage
10+
- Replace response body buffering with streaming response wrapper
11+
- Add sampling configuration to control percentage of requests profiled
1012

1113
## [0.3.2] - 2025-05-14
1214

Gemfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,7 @@ end
3131
gem "rails", ENV["RAILS_VERSION"]
3232

3333
gem "rails-dom-testing"
34+
35+
gem "redis", ">= 5.0"
36+
gem "redis-clustering", ">= 5.0"
37+
gem "dalli", ">= 3.0"

README.md

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,7 @@ Utilizes [vernier](https://github.com/jhawthorn/vernier) for profiling and
1919
1. Add the gem to your Rails application's Gemfile:
2020

2121
```ruby
22-
group :development do
23-
gem "dial"
24-
end
22+
gem "dial"
2523
```
2624

2725
2. Install the gem:
@@ -34,7 +32,7 @@ bundle install
3432

3533
```ruby
3634
# this will mount the engine at /dial
37-
mount Dial::Engine, at: "/" if Rails.env.development?
35+
mount Dial::Engine, at: "/"
3836
```
3937

4038
4. (Optional) Configure the gem in an initializer:
@@ -44,6 +42,8 @@ mount Dial::Engine, at: "/" if Rails.env.development?
4442

4543
Dial.configure do |config|
4644
config.sampling_percentage = 50
45+
config.storage = Dial::Storage::RedisAdapter
46+
config.storage_options = { client: Redis.new(url: ENV["REDIS_URL"]), ttl: 86400 }
4747
config.vernier_interval = 100
4848
config.vernier_allocation_interval = 10_000
4949
config.prosopite_ignore_queries += [/pg_sleep/i]
@@ -55,11 +55,62 @@ end
5555
Option | Description | Default
5656
:- | :- | :-
5757
`sampling_percentage` | Percentage of requests to profile. | `100` in development, `1` in production
58+
`storage` | Storage adapter class for profile data | `Dial::Storage::FileAdapter`
59+
`storage_options` | Options hash passed to storage adapter | `{ ttl: 3600 }`
5860
`content_security_policy_nonce` | Sets the content security policy nonce to use when inserting Dial's script. Can be a string, or a Proc which receives `env` and response `headers` as arguments and returns the nonce string. | Rails generated nonce or `nil`
5961
`vernier_interval` | Sets the `interval` option for vernier. | `200`
6062
`vernier_allocation_interval` | Sets the `allocation_interval` option for vernier. | `2_000`
6163
`prosopite_ignore_queries` | Sets the `ignore_queries` option for prosopite. | `[/schema_migrations/i]`
6264

65+
## Storage Backends
66+
67+
### File Storage (Default)
68+
69+
Profile data is stored as files on disk with polled expiration. Only suitable for development and single-server deployments.
70+
71+
```ruby
72+
Dial.configure do |config|
73+
config.storage = Dial::Storage::FileAdapter
74+
config.storage_options = { ttl: 86400 }
75+
end
76+
```
77+
78+
### Redis Storage
79+
80+
Profile data is stored in Redis with automatic expiration. Supports both single Redis instances and Redis Cluster.
81+
82+
```ruby
83+
# Single Redis instance
84+
Dial.configure do |config|
85+
config.storage = Dial::Storage::RedisAdapter
86+
config.storage_options = { client: Redis.new(url: "redis://localhost:6379"), ttl: 86400 }
87+
end
88+
89+
# Redis Cluster
90+
Dial.configure do |config|
91+
config.storage = Dial::Storage::RedisAdapter
92+
config.storage_options = {
93+
client: Redis::Cluster.new(nodes: [
94+
"redis://node1:7000",
95+
"redis://node2:7001",
96+
"redis://node3:7002"
97+
]),
98+
ttl: 86400
99+
}
100+
end
101+
```
102+
103+
### Memcached Storage
104+
105+
Profile data is stored in Memcached with automatic expiration.
106+
107+
```ruby
108+
Dial.configure do |config|
109+
config.storage = Dial::Storage::MemcachedAdapter
110+
config.storage_options = { client: Dalli::Client.new("localhost:11211"), ttl: 86400 }
111+
end
112+
```
113+
63114
## Comparison with [rack-mini-profiler](https://github.com/MiniProfiler/rack-mini-profiler)
64115

65116
| | rack-mini-profiler | Dial |
@@ -72,7 +123,8 @@ Option | Description | Default
72123
| Memory Profiling | Yes (with memory_profiler) | Yes (*overall usage only) (via vernier hook - graph) |
73124
| View Profiling | Yes | Yes (via vernier hook - marker table, chart) |
74125
| Snapshot Sampling | Yes | No |
75-
| Production Support | Yes | No |
126+
| Storage Backends | Redis, Memcached, File, Memory | Redis, Memcached, File |
127+
| Production Ready | Yes | Yes |
76128

77129
> [!NOTE]
78130
> SQL queries displayed in the profile are not annotated with the caller location by default. If you're not using the
@@ -84,6 +136,10 @@ Option | Description | Default
84136
After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rake test` to run the
85137
tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
86138

139+
### Testing Storage Adapters
140+
141+
To test the Redis and Memcached storage adapters, you'll need running instances: `docker compose -f docker-compose.storage.yml up`
142+
87143
## Contributing
88144

89145
Bug reports and pull requests are welcome on GitHub at https://github.com/joshuay03/dial.

docker-compose.storage.yml

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
x-redis-cluster: &redis-cluster-template
2+
image: redis:latest
3+
network_mode: host
4+
command: redis-server --cluster-enabled yes --cluster-config-file nodes.conf --cluster-node-timeout 5000 --appendonly yes
5+
6+
services:
7+
redis:
8+
image: redis:latest
9+
ports:
10+
- "6379:6379"
11+
command: redis-server --appendonly yes
12+
volumes:
13+
- redis_data:/data
14+
healthcheck:
15+
test: ["CMD", "redis-cli", "ping"]
16+
interval: 10s
17+
timeout: 5s
18+
retries: 5
19+
20+
redis-cluster-1:
21+
<<: *redis-cluster-template
22+
command: redis-server --port 8000 --bind 127.0.0.1 --cluster-enabled yes --cluster-config-file nodes.conf --cluster-node-timeout 5000 --appendonly yes
23+
volumes:
24+
- redis_cluster_1:/data
25+
26+
redis-cluster-2:
27+
<<: *redis-cluster-template
28+
command: redis-server --port 8001 --bind 127.0.0.1 --cluster-enabled yes --cluster-config-file nodes.conf --cluster-node-timeout 5000 --appendonly yes
29+
volumes:
30+
- redis_cluster_2:/data
31+
32+
redis-cluster-3:
33+
<<: *redis-cluster-template
34+
command: redis-server --port 8002 --bind 127.0.0.1 --cluster-enabled yes --cluster-config-file nodes.conf --cluster-node-timeout 5000 --appendonly yes
35+
volumes:
36+
- redis_cluster_3:/data
37+
38+
redis-cluster-4:
39+
<<: *redis-cluster-template
40+
command: redis-server --port 8003 --bind 127.0.0.1 --cluster-enabled yes --cluster-config-file nodes.conf --cluster-node-timeout 5000 --appendonly yes
41+
volumes:
42+
- redis_cluster_4:/data
43+
44+
redis-cluster-5:
45+
<<: *redis-cluster-template
46+
command: redis-server --port 8004 --bind 127.0.0.1 --cluster-enabled yes --cluster-config-file nodes.conf --cluster-node-timeout 5000 --appendonly yes
47+
volumes:
48+
- redis_cluster_5:/data
49+
50+
redis-cluster-6:
51+
<<: *redis-cluster-template
52+
command: redis-server --port 8005 --bind 127.0.0.1 --cluster-enabled yes --cluster-config-file nodes.conf --cluster-node-timeout 5000 --appendonly yes
53+
volumes:
54+
- redis_cluster_6:/data
55+
56+
redis-cluster-init:
57+
image: redis:latest
58+
network_mode: host
59+
depends_on:
60+
- redis-cluster-1
61+
- redis-cluster-2
62+
- redis-cluster-3
63+
- redis-cluster-4
64+
- redis-cluster-5
65+
- redis-cluster-6
66+
command: |
67+
sh -c '
68+
echo "Waiting for Redis cluster nodes to be ready..."
69+
until redis-cli -h 127.0.0.1 -p 8000 ping && redis-cli -h 127.0.0.1 -p 8001 ping && redis-cli -h 127.0.0.1 -p 8002 ping && redis-cli -h 127.0.0.1 -p 8003 ping && redis-cli -h 127.0.0.1 -p 8004 ping && redis-cli -h 127.0.0.1 -p 8005 ping; do
70+
echo "Waiting for all Redis nodes..."
71+
sleep 2
72+
done
73+
echo "All Redis nodes are ready. Initializing cluster..."
74+
redis-cli --cluster create 127.0.0.1:8000 127.0.0.1:8001 127.0.0.1:8002 127.0.0.1:8003 127.0.0.1:8004 127.0.0.1:8005 --cluster-replicas 1 --cluster-yes
75+
echo "Redis cluster initialized successfully"
76+
'
77+
restart: "no"
78+
volumes:
79+
- redis_cluster_init:/data
80+
81+
memcached:
82+
image: memcached:latest
83+
ports:
84+
- "11211:11211"
85+
command: memcached -m 64
86+
87+
volumes:
88+
redis_data:
89+
redis_cluster_1:
90+
redis_cluster_2:
91+
redis_cluster_3:
92+
redis_cluster_4:
93+
redis_cluster_5:
94+
redis_cluster_6:
95+
redis_cluster_init:

lib/dial.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
require_relative "dial/util"
55

66
require_relative "dial/configuration"
7+
require_relative "dial/storage"
78

89
require_relative "dial/railtie"
910
require_relative "dial/engine"

lib/dial/configuration.rb

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ def self._configuration
1212
class Configuration
1313
def initialize
1414
@options = {
15-
sampling_percentage: ::Rails.env.development? ? SAMPLING_PERCENTAGE_DEV : SAMPLING_PERCENTAGE_PROD,
16-
content_security_policy_nonce: -> (env, _headers) { env[NONCE] || EMPTY_NONCE },
15+
sampling_percentage: default_sampling_percentage,
16+
storage: default_storage,
17+
storage_options: { ttl: STORAGE_TTL },
18+
content_security_policy_nonce: -> env, _headers { env[NONCE] || EMPTY_NONCE },
1719
vernier_interval: VERNIER_INTERVAL,
1820
vernier_allocation_interval: VERNIER_ALLOCATION_INTERVAL,
1921
prosopite_ignore_queries: PROSOPITE_IGNORE_QUERIES,
@@ -35,5 +37,15 @@ def freeze
3537

3638
super
3739
end
40+
41+
private
42+
43+
def default_sampling_percentage
44+
::Rails.env.development? ? SAMPLING_PERCENTAGE_DEV : SAMPLING_PERCENTAGE_PROD
45+
end
46+
47+
def default_storage
48+
Storage::FileAdapter
49+
end
3850
end
3951
end

lib/dial/constants.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,9 @@ module Dial
1616
NONCE = ::ActionDispatch::ContentSecurityPolicy::Request::NONCE
1717
REQUEST_TIMING = "dial_request_timing"
1818

19-
FILE_STALE_SECONDS = 60 * 60
20-
2119
SAMPLING_PERCENTAGE_DEV = 100
2220
SAMPLING_PERCENTAGE_PROD = 1
21+
STORAGE_TTL = 60 * 60
2322
EMPTY_NONCE = ""
2423

2524
VERNIER_INTERVAL = 200

lib/dial/engine/routes.rb

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,41 @@
11
# frozen_string_literal: true
22

3+
require "uri"
4+
35
Dial::Engine.routes.draw do
46
scope path: "/dial", as: "dial" do
57
get "profile", to: lambda { |env|
6-
uuid = env[::Rack::QUERY_STRING].sub "uuid=", ""
7-
8-
# Validate UUID format (should end with _vernier)
9-
unless uuid.match?(/\A[0-9a-f-]+_vernier\z/)
8+
query_params = URI.decode_www_form(env[::Rack::QUERY_STRING]).to_h
9+
profile_key = query_params["key"]
10+
unless profile_key && profile_key.match?(/\A[0-9a-f-]+_vernier\z/i)
1011
return [
1112
400,
1213
{ "Content-Type" => "text/plain" },
1314
["Bad Request"]
1415
]
1516
end
1617

17-
path = String ::Rails.root.join Dial::VERNIER_PROFILE_OUT_RELATIVE_DIRNAME, (uuid + Dial::VERNIER_PROFILE_OUT_FILE_EXTENSION)
18-
19-
if File.exist? path
20-
begin
21-
content = File.read path
18+
profile_storage_key = Dial::Storage.profile_storage_key profile_key
19+
begin
20+
content = Dial::Storage.fetch profile_storage_key
21+
if content
2222
[
2323
200,
2424
{ "Content-Type" => "application/json", "Access-Control-Allow-Origin" => Dial::VERNIER_VIEWER_URL },
2525
[content]
2626
]
27-
rescue
27+
else
2828
[
29-
500,
29+
404,
3030
{ "Content-Type" => "text/plain" },
31-
["Internal Server Error"]
31+
["Not Found"]
3232
]
3333
end
34-
else
34+
rescue
3535
[
36-
404,
36+
500,
3737
{ "Content-Type" => "text/plain" },
38-
["Not Found"]
38+
["Internal Server Error"]
3939
]
4040
end
4141
}

0 commit comments

Comments
 (0)