Skip to content

Commit bef401d

Browse files
authored
Merge pull request #37 from prog-supdex/feat/add-stats
feat: add ability to fetch subscription stats
2 parents 69f893b + b730e61 commit bef401d

5 files changed

Lines changed: 269 additions & 0 deletions

File tree

README.md

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,105 @@ As in AnyCable there is no place to store subscription data in-memory, it should
239239
=> 52ee8d65-275e-4d22-94af-313129116388
240240
```
241241
242+
## Stats
243+
244+
You can grab Redis subscription statistics by calling
245+
246+
```ruby
247+
GraphQL::AnyCable.stats
248+
```
249+
250+
It will return a total of the amount of the key with the following prefixes
251+
252+
```
253+
graphql-subscription
254+
graphql-fingerprints
255+
graphql-subscriptions
256+
graphql-channel
257+
```
258+
259+
The response will look like this
260+
261+
```json
262+
{
263+
"total": {
264+
"subscription":22646,
265+
"fingerprints":3200,
266+
"subscriptions":20101,
267+
"channel": 4900
268+
}
269+
}
270+
```
271+
272+
You can also grab the number of subscribers grouped by subscriptions
273+
274+
```ruby
275+
GraphQL::AnyCable.stats(include_subscriptions: true)
276+
```
277+
278+
It will return the response that contains `subscriptions`
279+
280+
```json
281+
{
282+
"total": {
283+
"subscription":22646,
284+
"fingerprints":3200,
285+
"subscriptions":20101,
286+
"channel": 4900
287+
},
288+
"subscriptions": {
289+
"productCreated": 11323,
290+
"productUpdated": 11323
291+
}
292+
}
293+
```
294+
295+
Also, you can set another `scan_count`, if needed.
296+
The default value is 1_000
297+
298+
```ruby
299+
GraphQL::AnyCable.stats(scan_count: 100)
300+
```
301+
302+
We can set statistics data to [Yabeda][] for tracking amount of subscriptions
303+
304+
```ruby
305+
# config/initializers/metrics.rb
306+
Yabeda.configure do
307+
group :graphql_anycable_statistics do
308+
gauge :subscriptions_count, comment: "Number of graphql-anycable subscriptions"
309+
end
310+
end
311+
```
312+
313+
```ruby
314+
# in your app
315+
statistics = GraphQL::AnyCable.stats[:total]
316+
317+
statistics.each do |key , value|
318+
Yabeda.graphql_anycable_statistics.subscriptions_count.set({name: key}, value)
319+
end
320+
```
321+
322+
Or you can use `collect`
323+
```ruby
324+
# config/initializers/metrics.rb
325+
Yabeda.configure do
326+
group :graphql_anycable_statistics do
327+
gauge :subscriptions_count, comment: "Number of graphql-anycable subscriptions"
328+
end
329+
330+
collect do
331+
statistics = GraphQL::AnyCable.stats[:total]
332+
333+
statistics.each do |redis_prefix, value|
334+
graphql_anycable_statistics.subscriptions_count.set({name: redis_prefix}, value)
335+
end
336+
end
337+
end
338+
```
339+
340+
242341
## Testing applications which use `graphql-anycable`
243342

244343
You can pass custom redis-server URL to AnyCable using ENV variable.
@@ -298,3 +397,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
298397
[AnyCable]: https://github.com/anycable/anycable "Polyglot replacement for Ruby WebSocket servers with Action Cable protocol"
299398
[LiteCable]: https://github.com/palkan/litecable "Lightweight Action Cable implementation (Rails-free)"
300399
[anyway_config]: https://github.com/palkan/anyway_config "Ruby libraries and applications configuration on steroids!"
400+
[Yabeda]: https://github.com/yabeda-rb/yabeda "Extendable solution for easy setup of monitoring in your Ruby apps"

lib/graphql-anycable.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
require_relative "graphql/anycable/cleaner"
77
require_relative "graphql/anycable/config"
88
require_relative "graphql/anycable/railtie" if defined?(Rails)
9+
require_relative "graphql/anycable/stats"
910
require_relative "graphql/subscriptions/anycable_subscriptions"
1011

1112
module GraphQL
@@ -20,6 +21,10 @@ def self.use(schema, **options)
2021
schema.use GraphQL::Subscriptions::AnyCableSubscriptions, **options
2122
end
2223

24+
def self.stats(**options)
25+
Stats.new(**options).collect
26+
end
27+
2328
module_function
2429

2530
def redis

lib/graphql/anycable/stats.rb

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# frozen_string_literal: true
2+
3+
module GraphQL
4+
module AnyCable
5+
# Calculates amount of Graphql Redis keys
6+
# (graphql-subscription, graphql-fingerprints, graphql-subscriptions, graphql-channel)
7+
# Also, calculate the number of subscribers grouped by subscriptions
8+
class Stats
9+
SCAN_COUNT_RECORDS_AMOUNT = 1_000
10+
11+
attr_reader :scan_count, :include_subscriptions
12+
13+
def initialize(scan_count: SCAN_COUNT_RECORDS_AMOUNT, include_subscriptions: false)
14+
@scan_count = scan_count
15+
@include_subscriptions = include_subscriptions
16+
end
17+
18+
def collect
19+
total_subscriptions_result = {total: {}}
20+
21+
list_prefixes_keys.each do |name, prefix|
22+
total_subscriptions_result[:total][name] = count_by_scan(match: "#{prefix}*")
23+
end
24+
25+
if include_subscriptions
26+
total_subscriptions_result[:subscriptions] = group_subscription_stats
27+
end
28+
29+
total_subscriptions_result
30+
end
31+
32+
private
33+
34+
# Counting all keys, that match the pattern with iterating by count
35+
def count_by_scan(match:)
36+
sb_amount = 0
37+
cursor = '0'
38+
39+
loop do
40+
cursor, result = redis.scan(cursor, match: match, count: scan_count)
41+
sb_amount += result.count
42+
43+
break if cursor == '0'
44+
end
45+
46+
sb_amount
47+
end
48+
49+
# Calculate subscribes, grouped by subscriptions
50+
def group_subscription_stats
51+
subscription_groups = {}
52+
53+
redis.scan_each(match: "#{list_prefixes_keys[:fingerprints]}*", count: scan_count) do |fingerprint_key|
54+
subscription_name = fingerprint_key.gsub(/#{list_prefixes_keys[:fingerprints]}|:/, "")
55+
subscription_groups[subscription_name] = 0
56+
57+
redis.zscan_each(fingerprint_key) do |data|
58+
redis.sscan_each("#{list_prefixes_keys[:subscriptions]}#{data[0]}") do |subscription_key|
59+
next unless redis.exists?("#{list_prefixes_keys[:subscription]}#{subscription_key}")
60+
61+
subscription_groups[subscription_name] += 1
62+
end
63+
end
64+
end
65+
66+
subscription_groups
67+
end
68+
69+
def list_prefixes_keys
70+
{
71+
subscription: redis_key(adapter::SUBSCRIPTION_PREFIX),
72+
fingerprints: redis_key(adapter::FINGERPRINTS_PREFIX),
73+
subscriptions: redis_key(adapter::SUBSCRIPTIONS_PREFIX),
74+
channel: redis_key(adapter::CHANNEL_PREFIX)
75+
}
76+
end
77+
78+
def adapter
79+
GraphQL::Subscriptions::AnyCableSubscriptions
80+
end
81+
82+
def redis
83+
GraphQL::AnyCable.redis
84+
end
85+
86+
def config
87+
GraphQL::AnyCable.config
88+
end
89+
90+
def redis_key(prefix)
91+
"#{config.redis_prefix}-#{prefix}"
92+
end
93+
end
94+
end
95+
end

spec/graphql/anycable_spec.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,4 +260,12 @@
260260
end
261261
end
262262
end
263+
264+
describe ".stats" do
265+
it "calls Graphql::AnyCable::Stats" do
266+
allow_any_instance_of(GraphQL::AnyCable::Stats).to receive(:collect)
267+
268+
described_class.stats
269+
end
270+
end
263271
end

spec/graphql/stats_spec.rb

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe GraphQL::AnyCable::Stats do
4+
describe "#collect" do
5+
let(:query) do
6+
<<~GRAPHQL
7+
subscription SomeSubscription {
8+
productCreated { id title }
9+
productUpdated { id }
10+
}
11+
GRAPHQL
12+
end
13+
14+
let(:channel) do
15+
socket = double("Socket", istate: AnyCable::Socket::State.new({}))
16+
connection = double("Connection", anycable_socket: socket)
17+
double("Channel", id: "legacy_id", params: { "channelId" => "legacy_id" }, stream_from: nil, connection: connection)
18+
end
19+
20+
let(:subscription_id) do
21+
"some-truly-random-number"
22+
end
23+
24+
before do
25+
AnycableSchema.execute(
26+
query: query,
27+
context: { channel: channel, subscription_id: subscription_id },
28+
variables: {},
29+
operation_name: "SomeSubscription",
30+
)
31+
end
32+
33+
context "when include_subscriptions is false" do
34+
let(:expected_result) do
35+
{total: {subscription: 1, fingerprints: 2, subscriptions: 2, channel: 1}}
36+
end
37+
38+
it "returns total stat" do
39+
expect(subject.collect).to eq(expected_result)
40+
end
41+
end
42+
43+
context "when include_subscriptions is true" do
44+
subject { described_class.new(include_subscriptions: true) }
45+
46+
let(:expected_result) do
47+
{
48+
total: {subscription: 1, fingerprints: 2, subscriptions: 2, channel: 1},
49+
subscriptions: {
50+
"productCreated"=> 1,
51+
"productUpdated"=> 1
52+
}
53+
}
54+
end
55+
56+
it "returns total stat with grouped subscription stats" do
57+
expect(subject.collect).to eq(expected_result)
58+
end
59+
end
60+
end
61+
end

0 commit comments

Comments
 (0)