From 3c90d1920850789d7fa56cec802b4204b1ec73b2 Mon Sep 17 00:00:00 2001 From: prog-supdex Date: Tue, 12 Sep 2023 19:24:40 +0200 Subject: [PATCH 1/2] ability to fetch subscription stats --- README.md | 93 +++++++++++++++++++++++++++++++++++ lib/graphql-anycable.rb | 5 ++ lib/graphql/anycable/stats.rb | 88 +++++++++++++++++++++++++++++++++ spec/graphql/anycable_spec.rb | 8 +++ spec/graphql/stats_spec.rb | 66 +++++++++++++++++++++++++ 5 files changed, 260 insertions(+) create mode 100644 lib/graphql/anycable/stats.rb create mode 100644 spec/graphql/stats_spec.rb diff --git a/README.md b/README.md index dfda6f3..93390e0 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,98 @@ As in AnyCable there is no place to store subscription data in-memory, it should => 52ee8d65-275e-4d22-94af-313129116388 ``` +## Stats + +You can grab Redis subscription statistics by calling + +```ruby + GraphQL::AnyCable.stats +``` + +It will return a total of the amount of the key with the following prefixes + +``` + graphql-subscription + graphql-fingerprints + graphql-subscriptions + graphql-channel +``` + +The response will look like this + +```json + { + "total": { + "subscription":22646, + "fingerprints":3200, + "subscriptions":20101, + "channel": 4900 + } + } +``` + +You can also grab the number of subscribers grouped by subscriptions + +```ruby + GraphQL::AnyCable.stats(include_subscriptions: true) +``` + +It will return the response that contains `subscriptions` + +```json + { + "total": { + "subscription":22646, + "fingerprints":3200, + "subscriptions":20101, + "channel": 4900 + }, + "subscriptions": { + "productCreated": 11323, + "productUpdated": 11323 + } + } +``` + +We can set this data to [Yabeda] for tracking amount of subscriptions + +```ruby + # config/initializers/metrics.rb + Yabeda.configure do + group :graphql_anycable_statistics do + gauge :subscriptions_count, comment: "Number of graphql-anycable subscriptions" + end + end +``` + +```ruby + # in your app + statistics = GraphQL::AnyCable.stats[:total] + + statistics.each do |key , value| + Yabeda.graphql_anycable_statistics.subscriptions_count.set({name: key}, value) + end +``` + +Or you can use `collect` +```ruby + # config/initializers/metrics.rb + Yabeda.configure do + group :graphql_anycable_statistics do + gauge :subscriptions_count, comment: "Number of graphql-anycable subscriptions" + end + + collect do + statistics = GraphQL::AnyCable.stats[:total] + + statistics.each do |redis_prefix, value| + graphql_anycable_statistics.subscriptions_count.set({name: redis_prefix}, value) + end + end + end +``` + + ## Testing applications which use `graphql-anycable` You can pass custom redis-server URL to AnyCable using ENV variable. @@ -262,3 +354,4 @@ The gem is available as open source under the terms of the [MIT License](https:/ [AnyCable]: https://github.com/anycable/anycable "Polyglot replacement for Ruby WebSocket servers with Action Cable protocol" [LiteCable]: https://github.com/palkan/litecable "Lightweight Action Cable implementation (Rails-free)" [anyway_config]: https://github.com/palkan/anyway_config "Ruby libraries and applications configuration on steroids!" +[Yabeda]: https://github.com/yabeda-rb/yabeda "Extendable solution for easy setup of monitoring in your Ruby apps" diff --git a/lib/graphql-anycable.rb b/lib/graphql-anycable.rb index c3896c5..fc03e9a 100644 --- a/lib/graphql-anycable.rb +++ b/lib/graphql-anycable.rb @@ -6,6 +6,7 @@ require_relative "graphql/anycable/cleaner" require_relative "graphql/anycable/config" require_relative "graphql/anycable/railtie" if defined?(Rails) +require_relative "graphql/anycable/stats" require_relative "graphql/subscriptions/anycable_subscriptions" module GraphQL @@ -20,6 +21,10 @@ def self.use(schema, **options) schema.use GraphQL::Subscriptions::AnyCableSubscriptions, **options end + def self.stats(include_subscriptions: false) + AnyCable::Stats.new(redis: redis, config: config, include_subscriptions: include_subscriptions).collect + end + module_function def redis diff --git a/lib/graphql/anycable/stats.rb b/lib/graphql/anycable/stats.rb new file mode 100644 index 0000000..39930c2 --- /dev/null +++ b/lib/graphql/anycable/stats.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module GraphQL + module AnyCable + # Calculates amount of Graphql Redis keys + # (graphql-subscription, graphql-fingerprints, graphql-subscriptions, graphql-channel) + # Also, calculate the number of subscribers grouped by subscriptions + class Stats + SCAN_COUNT_RECORDS_AMOUNT = 1_000 + + attr_reader :redis, :config, :list_prefixes_keys, :include_subscriptions + + def initialize(redis:, config:, include_subscriptions: false) + @redis = redis + @config = config + @include_subscriptions = include_subscriptions + @list_prefix_keys = list_prefixes_keys + end + + def collect + total_subscriptions_result = {total: {}} + + list_prefixes_keys.each do |name, prefix| + total_subscriptions_result[:total][name] = count_by_scan(match: "#{prefix}*") + end + + if include_subscriptions + total_subscriptions_result[:subscriptions] = group_subscription_stats + end + + total_subscriptions_result + end + + private + + # Counting all keys, that match the pattern with iterating by count + def count_by_scan(match:, count: SCAN_COUNT_RECORDS_AMOUNT) + sb_amount = 0 + cursor = '0' + + loop do + cursor, result = redis.scan(cursor, match: match, count: count) + sb_amount += result.count + + break if cursor == '0' + end + + sb_amount + end + + # Calculate subscribes, grouped by subscriptions + def group_subscription_stats + subscription_groups = {} + redis.scan_each(match: "#{list_prefixes_keys[:fingerprints]}*", count: SCAN_COUNT_RECORDS_AMOUNT) do |fingerprint_key| + subscription_name = fingerprint_key.gsub(/#{list_prefixes_keys[:fingerprints]}|:/, "") + subscription_groups[subscription_name] = 0 + + redis.zscan_each(fingerprint_key) do |data| + redis.sscan_each("#{list_prefixes_keys[:subscriptions]}#{data[0]}") do |subscription_key| + next unless redis.exists?("#{list_prefixes_keys[:subscription]}#{subscription_key}") + + subscription_groups[subscription_name] += 1 + end + end + end + + subscription_groups + end + + def adapter + GraphQL::Subscriptions::AnyCableSubscriptions + end + + def list_prefixes_keys + { + subscription: redis_key(adapter::SUBSCRIPTION_PREFIX), + fingerprints: redis_key(adapter::FINGERPRINTS_PREFIX), + subscriptions: redis_key(adapter::SUBSCRIPTIONS_PREFIX), + channel: redis_key(adapter::CHANNEL_PREFIX) + } + end + + def redis_key(prefix) + "#{config.redis_prefix}-#{prefix}" + end + end + end +end diff --git a/spec/graphql/anycable_spec.rb b/spec/graphql/anycable_spec.rb index 3a51cc2..7149d1b 100644 --- a/spec/graphql/anycable_spec.rb +++ b/spec/graphql/anycable_spec.rb @@ -260,4 +260,12 @@ end end end + + describe ".stats" do + it "calls Graphql::AnyCable::Stats" do + allow_any_instance_of(GraphQL::AnyCable::Stats).to receive(:collect) + + described_class.stats + end + end end diff --git a/spec/graphql/stats_spec.rb b/spec/graphql/stats_spec.rb new file mode 100644 index 0000000..6e9eee6 --- /dev/null +++ b/spec/graphql/stats_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +RSpec.describe GraphQL::AnyCable::Stats do + describe "#collect" do + let(:query) do + <<~GRAPHQL + subscription SomeSubscription { + productCreated { id title } + productUpdated { id } + } + GRAPHQL + end + + let(:channel) do + socket = double("Socket", istate: AnyCable::Socket::State.new({})) + connection = double("Connection", anycable_socket: socket) + double("Channel", id: "legacy_id", params: { "channelId" => "legacy_id" }, stream_from: nil, connection: connection) + end + + let(:subscription_id) do + "some-truly-random-number" + end + + before do + AnycableSchema.execute( + query: query, + context: { channel: channel, subscription_id: subscription_id }, + variables: {}, + operation_name: "SomeSubscription", + ) + end + + let(:redis) { AnycableSchema.subscriptions.redis } + let(:config) { GraphQL::AnyCable.config } + + context "when include_subscriptions is false" do + subject { described_class.new(redis: redis, config: config) } + + let(:expected_result) do + {total: {subscription: 1, fingerprints: 2, subscriptions: 2, channel: 1}} + end + + it "returns total stat" do + expect(subject.collect).to eq(expected_result) + end + end + + context "when include_subscriptions is true" do + subject { described_class.new(redis: redis, config: config, include_subscriptions: true) } + + let(:expected_result) do + { + total: {subscription: 1, fingerprints: 2, subscriptions: 2, channel: 1}, + subscriptions: { + "productCreated"=> 1, + "productUpdated"=> 1 + } + } + end + + it "returns total stat with grouped subscription stats" do + expect(subject.collect).to eq(expected_result) + end + end + end +end From b730e613c693f7ea49266738e472c2e12564f014 Mon Sep 17 00:00:00 2001 From: prog-supdex Date: Fri, 15 Sep 2023 21:39:16 +0200 Subject: [PATCH 2/2] add ability to set scan_count and added a little refactoring --- README.md | 9 ++++++++- lib/graphql-anycable.rb | 4 ++-- lib/graphql/anycable/stats.rb | 31 +++++++++++++++++++------------ spec/graphql/stats_spec.rb | 7 +------ 4 files changed, 30 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 93390e0..cc21aa8 100644 --- a/README.md +++ b/README.md @@ -256,7 +256,14 @@ It will return the response that contains `subscriptions` } ``` -We can set this data to [Yabeda] for tracking amount of subscriptions +Also, you can set another `scan_count`, if needed. +The default value is 1_000 + +```ruby + GraphQL::AnyCable.stats(scan_count: 100) +``` + +We can set statistics data to [Yabeda][] for tracking amount of subscriptions ```ruby # config/initializers/metrics.rb diff --git a/lib/graphql-anycable.rb b/lib/graphql-anycable.rb index fc03e9a..97d18f1 100644 --- a/lib/graphql-anycable.rb +++ b/lib/graphql-anycable.rb @@ -21,8 +21,8 @@ def self.use(schema, **options) schema.use GraphQL::Subscriptions::AnyCableSubscriptions, **options end - def self.stats(include_subscriptions: false) - AnyCable::Stats.new(redis: redis, config: config, include_subscriptions: include_subscriptions).collect + def self.stats(**options) + Stats.new(**options).collect end module_function diff --git a/lib/graphql/anycable/stats.rb b/lib/graphql/anycable/stats.rb index 39930c2..94881bb 100644 --- a/lib/graphql/anycable/stats.rb +++ b/lib/graphql/anycable/stats.rb @@ -8,13 +8,11 @@ module AnyCable class Stats SCAN_COUNT_RECORDS_AMOUNT = 1_000 - attr_reader :redis, :config, :list_prefixes_keys, :include_subscriptions + attr_reader :scan_count, :include_subscriptions - def initialize(redis:, config:, include_subscriptions: false) - @redis = redis - @config = config + def initialize(scan_count: SCAN_COUNT_RECORDS_AMOUNT, include_subscriptions: false) + @scan_count = scan_count @include_subscriptions = include_subscriptions - @list_prefix_keys = list_prefixes_keys end def collect @@ -34,12 +32,12 @@ def collect private # Counting all keys, that match the pattern with iterating by count - def count_by_scan(match:, count: SCAN_COUNT_RECORDS_AMOUNT) + def count_by_scan(match:) sb_amount = 0 cursor = '0' loop do - cursor, result = redis.scan(cursor, match: match, count: count) + cursor, result = redis.scan(cursor, match: match, count: scan_count) sb_amount += result.count break if cursor == '0' @@ -51,7 +49,8 @@ def count_by_scan(match:, count: SCAN_COUNT_RECORDS_AMOUNT) # Calculate subscribes, grouped by subscriptions def group_subscription_stats subscription_groups = {} - redis.scan_each(match: "#{list_prefixes_keys[:fingerprints]}*", count: SCAN_COUNT_RECORDS_AMOUNT) do |fingerprint_key| + + redis.scan_each(match: "#{list_prefixes_keys[:fingerprints]}*", count: scan_count) do |fingerprint_key| subscription_name = fingerprint_key.gsub(/#{list_prefixes_keys[:fingerprints]}|:/, "") subscription_groups[subscription_name] = 0 @@ -67,10 +66,6 @@ def group_subscription_stats subscription_groups end - def adapter - GraphQL::Subscriptions::AnyCableSubscriptions - end - def list_prefixes_keys { subscription: redis_key(adapter::SUBSCRIPTION_PREFIX), @@ -80,6 +75,18 @@ def list_prefixes_keys } end + def adapter + GraphQL::Subscriptions::AnyCableSubscriptions + end + + def redis + GraphQL::AnyCable.redis + end + + def config + GraphQL::AnyCable.config + end + def redis_key(prefix) "#{config.redis_prefix}-#{prefix}" end diff --git a/spec/graphql/stats_spec.rb b/spec/graphql/stats_spec.rb index 6e9eee6..f48df1f 100644 --- a/spec/graphql/stats_spec.rb +++ b/spec/graphql/stats_spec.rb @@ -30,12 +30,7 @@ ) end - let(:redis) { AnycableSchema.subscriptions.redis } - let(:config) { GraphQL::AnyCable.config } - context "when include_subscriptions is false" do - subject { described_class.new(redis: redis, config: config) } - let(:expected_result) do {total: {subscription: 1, fingerprints: 2, subscriptions: 2, channel: 1}} end @@ -46,7 +41,7 @@ end context "when include_subscriptions is true" do - subject { described_class.new(redis: redis, config: config, include_subscriptions: true) } + subject { described_class.new(include_subscriptions: true) } let(:expected_result) do {