FEATURE: Add plugin API to register About stat group (#17442)

This commit introduces a new plugin API to register
a group of stats that will be included in about.json
and also conditionally in the site about UI at /about.

The usage is like this:

```ruby
register_about_stat_group("chat_messages", show_in_ui: true) do
  {
    last_day: 1,
    "7_days" => 10,
    "30_days" => 100,
    count: 1000,
    previous_30_days: 120
  }
end
```

In reality the stats will be generated any way the implementer
chooses within the plugin. The `last_day`, `7_days`, `30_days,` and `count`
keys must be present but apart from that additional stats may be added.
Only those core 4 stat keys will be shown in the UI, but everything will be shown
in about.json.

The stat group name is used to prefix the stats in about.json like so:

```json
"chat_messages_last_day": 2322,
"chat_messages_7_days": 2322,
"chat_messages_30_days": 2322,
"chat_messages_count": 2322,
```

The `show_in_ui` option (default false) is used to determine whether the
group of stats is shown on the site About page in the Site Statistics
table. Some stats may be needed purely for reporting purposes and thus
do not need to be shown in the UI to admins/users. An extension to the Site
serializer, `displayed_about_plugin_stat_groups`, has been added so this
can be inspected on the client-side.
This commit is contained in:
Martin Brennan 2022-07-15 13:16:00 +10:00 committed by GitHub
parent 8dad778fcc
commit 098ab29d41
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 249 additions and 11 deletions

View File

@ -29,6 +29,7 @@ export default DiscourseRoute.extend({
result.about.category_moderators[index].category = category; result.about.category_moderators[index].category = category;
}); });
} }
return result.about; return result.about;
}); });
}, },

View File

@ -69,41 +69,50 @@
<th>{{i18n "about.stat.last_30_days"}}</th> <th>{{i18n "about.stat.last_30_days"}}</th>
<th>{{i18n "about.stat.all_time"}}</th> <th>{{i18n "about.stat.all_time"}}</th>
</tr> </tr>
<tr> <tr class="about-topic-count">
<td class="title">{{i18n "about.topic_count"}}</td> <td class="title">{{i18n "about.topic_count"}}</td>
<td>{{number this.model.stats.topics_last_day}}</td> <td>{{number this.model.stats.topics_last_day}}</td>
<td>{{number this.model.stats.topics_7_days}}</td> <td>{{number this.model.stats.topics_7_days}}</td>
<td>{{number this.model.stats.topics_30_days}}</td> <td>{{number this.model.stats.topics_30_days}}</td>
<td>{{number this.model.stats.topic_count}}</td> <td>{{number this.model.stats.topic_count}}</td>
</tr> </tr>
<tr> <tr class="about-post-count">
<td>{{i18n "about.post_count"}}</td> <td>{{i18n "about.post_count"}}</td>
<td>{{number this.model.stats.posts_last_day}}</td> <td>{{number this.model.stats.posts_last_day}}</td>
<td>{{number this.model.stats.posts_7_days}}</td> <td>{{number this.model.stats.posts_7_days}}</td>
<td>{{number this.model.stats.posts_30_days}}</td> <td>{{number this.model.stats.posts_30_days}}</td>
<td>{{number this.model.stats.post_count}}</td> <td>{{number this.model.stats.post_count}}</td>
</tr> </tr>
<tr> <tr class="about-user-count">
<td>{{i18n "about.user_count"}}</td> <td>{{i18n "about.user_count"}}</td>
<td>{{number this.model.stats.users_last_day}}</td> <td>{{number this.model.stats.users_last_day}}</td>
<td>{{number this.model.stats.users_7_days}}</td> <td>{{number this.model.stats.users_7_days}}</td>
<td>{{number this.model.stats.users_30_days}}</td> <td>{{number this.model.stats.users_30_days}}</td>
<td>{{number this.model.stats.user_count}}</td> <td>{{number this.model.stats.user_count}}</td>
</tr> </tr>
<tr> <tr class="about-active-user-count">
<td>{{i18n "about.active_user_count"}}</td> <td>{{i18n "about.active_user_count"}}</td>
<td>{{number this.model.stats.active_users_last_day}}</td> <td>{{number this.model.stats.active_users_last_day}}</td>
<td>{{number this.model.stats.active_users_7_days}}</td> <td>{{number this.model.stats.active_users_7_days}}</td>
<td>{{number this.model.stats.active_users_30_days}}</td> <td>{{number this.model.stats.active_users_30_days}}</td>
<td>&mdash;</td> <td>&mdash;</td>
</tr> </tr>
<tr> <tr class="about-like-count">
<td>{{i18n "about.like_count"}}</td> <td>{{i18n "about.like_count"}}</td>
<td>{{number this.model.stats.likes_last_day}}</td> <td>{{number this.model.stats.likes_last_day}}</td>
<td>{{number this.model.stats.likes_7_days}}</td> <td>{{number this.model.stats.likes_7_days}}</td>
<td>{{number this.model.stats.likes_30_days}}</td> <td>{{number this.model.stats.likes_30_days}}</td>
<td>{{number this.model.stats.like_count}}</td> <td>{{number this.model.stats.like_count}}</td>
</tr> </tr>
{{#each this.site.displayed_about_plugin_stat_groups as |statGroupName|}}
<tr class={{concat "about-" statGroupName "-count"}}>
<td>{{i18n (concat "about." statGroupName "_count")}}</td>
<td>{{number (get this.model.stats (concat statGroupName "_last_day"))}}</td>
<td>{{number (get this.model.stats (concat statGroupName "_7_days"))}}</td>
<td>{{number (get this.model.stats (concat statGroupName "_30_days"))}}</td>
<td>{{number (get this.model.stats (concat statGroupName "_count"))}}</td>
</tr>
{{/each}}
</tbody> </tbody>
</table> </table>
</section> </section>

View File

@ -9,6 +9,24 @@ acceptance("About", function () {
assert.ok(document.body.classList.contains("about-page"), "has body class"); assert.ok(document.body.classList.contains("about-page"), "has body class");
assert.ok(exists(".about.admins .user-info"), "has admins"); assert.ok(exists(".about.admins .user-info"), "has admins");
assert.ok(exists(".about.moderators .user-info"), "has moderators"); assert.ok(exists(".about.moderators .user-info"), "has moderators");
assert.ok(exists(".about.stats tr td"), "has stats"); assert.ok(
exists(".about.stats tr.about-topic-count td"),
"has topic stats"
);
assert.ok(exists(".about.stats tr.about-post-count td"), "has post stats");
assert.ok(exists(".about.stats tr.about-user-count td"), "has user stats");
assert.ok(
exists(".about.stats tr.about-active-user-count td"),
"has active user stats"
);
assert.ok(exists(".about.stats tr.about-like-count td"), "has like stats");
assert.ok(
exists(".about.stats tr.about-chat_messages-count td"),
"has plugin stats"
);
assert.notOk(
exists(".about.stats tr.about-chat_users-count td"),
"does not show hidden plugin stats"
);
}); });
}); });

View File

@ -6,17 +6,30 @@ export default {
topic_count: 27480, topic_count: 27480,
post_count: 490358, post_count: 490358,
user_count: 41719, user_count: 41719,
topics_last_day: 34,
topics_7_days: 169, topics_7_days: 169,
topics_30_days: 517, topics_30_days: 517,
posts_last_day: 794,
posts_7_days: 3128, posts_7_days: 3128,
posts_30_days: 10660, posts_30_days: 10660,
users_last_day: 123,
users_7_days: 237, users_7_days: 237,
users_30_days: 866, users_30_days: 866,
active_users_last_day: 432,
active_users_7_days: 1004, active_users_7_days: 1004,
active_users_30_days: 2026, active_users_30_days: 2026,
like_count: 499135, like_count: 499135,
likes_last_day: 120,
likes_7_days: 3449, likes_7_days: 3449,
likes_30_days: 12313, likes_30_days: 12313,
chat_messages_last_day: 10,
chat_messages_7_days: 100,
chat_messages_30_days: 1000,
chat_messages_count: 10000,
chat_users_last_day: 10,
chat_users_7_days: 100,
chat_users_30_days: 1000,
chat_users_count: 10000
}, },
description: description:
"Discussion about the next-generation open source Discourse forum software", "Discussion about the next-generation open source Discourse forum software",

View File

@ -697,6 +697,7 @@ export default {
can_revoke: true, can_revoke: true,
}, },
], ],
displayed_about_plugin_stat_groups: ["chat_messages"]
}, },
}, },
}; };

View File

@ -1,6 +1,24 @@
# frozen_string_literal: true # frozen_string_literal: true
class About class About
cattr_reader :plugin_stat_groups
def self.add_plugin_stat_group(prefix, show_in_ui: false, &block)
@@displayed_plugin_stat_groups << prefix if show_in_ui
@@plugin_stat_groups[prefix] = block
end
def self.clear_plugin_stat_groups
@@displayed_plugin_stat_groups = Set.new
@@plugin_stat_groups = {}
end
def self.displayed_plugin_stat_groups
@@displayed_plugin_stat_groups.to_a
end
clear_plugin_stat_groups
class CategoryMods class CategoryMods
include ActiveModel::Serialization include ActiveModel::Serialization
attr_reader :category_id, :moderators attr_reader :category_id, :moderators
@ -82,7 +100,30 @@ class About
likes_last_day: UserAction.where(action_type: UserAction::LIKE).where("created_at > ?", 1.days.ago).count, likes_last_day: UserAction.where(action_type: UserAction::LIKE).where("created_at > ?", 1.days.ago).count,
likes_7_days: UserAction.where(action_type: UserAction::LIKE).where("created_at > ?", 7.days.ago).count, likes_7_days: UserAction.where(action_type: UserAction::LIKE).where("created_at > ?", 7.days.ago).count,
likes_30_days: UserAction.where(action_type: UserAction::LIKE).where("created_at > ?", 30.days.ago).count likes_30_days: UserAction.where(action_type: UserAction::LIKE).where("created_at > ?", 30.days.ago).count
} }.merge(plugin_stats)
end
def plugin_stats
final_plugin_stats = {}
@@plugin_stat_groups.each do |plugin_stat_group_name, stat_group|
begin
stats = stat_group.call
rescue StandardError => err
Discourse.warn_exception(err, message: "Unexpected error when collecting #{plugin_stat_group_name} About stats.")
next
end
if !stats.key?(:last_day) || !stats.key?("7_days") || !stats.key?("30_days") || !stats.key?(:count)
Rails.logger.warn("Plugin stat group #{plugin_stat_group_name} for About stats does not have all required keys, skipping.")
else
final_plugin_stats.merge!(
stats.transform_keys do |key|
"#{plugin_stat_group_name}_#{key}".to_sym
end
)
end
end
final_plugin_stats
end end
def category_moderators def category_moderators

View File

@ -33,7 +33,8 @@ class SiteSerializer < ApplicationSerializer
:watched_words_replace, :watched_words_replace,
:watched_words_link, :watched_words_link,
:categories, :categories,
:markdown_additional_options :markdown_additional_options,
:displayed_about_plugin_stat_groups
) )
has_many :archetypes, embed: :objects, serializer: ArchetypeSerializer has_many :archetypes, embed: :objects, serializer: ArchetypeSerializer
@ -208,6 +209,10 @@ class SiteSerializer < ApplicationSerializer
Site.markdown_additional_options Site.markdown_additional_options
end end
def displayed_about_plugin_stat_groups
About.displayed_plugin_stat_groups
end
private private
def ordered_flags(flags) def ordered_flags(flags)

View File

@ -100,6 +100,15 @@
<td><%= stats["likes_7_days"] %></td> <td><%= stats["likes_7_days"] %></td>
<td><%= stats["likes_30_days"] %></td> <td><%= stats["likes_30_days"] %></td>
</tr> </tr>
<% About.displayed_plugin_stat_groups.each do |stat_group_name| %>
<tr>
<td><%=t "js.about.#{stat_group_name}_count" %></td>
<td><%= stats["#{stat_group_name}_count"] %></td>
<td><%= stats["#{stat_group_name}_last_day"] %></td>
<td><%= stats["#{stat_group_name}_7_days"] %></td>
<td><%= stats["#{stat_group_name}_30_days"] %></td>
</tr>
<% end %>
</table> </table>
</section> </section>

View File

@ -1032,6 +1032,47 @@ class Plugin::Instance
DiscoursePluginRegistry.register_email_unsubscriber({ type => unsubscriber }, self) DiscoursePluginRegistry.register_email_unsubscriber({ type => unsubscriber }, self)
end end
# Allows the plugin to export additional site stats via the About class
# which will be shown on the /about route. The stats returned by the block
# should be in the following format (these four keys are _required_):
#
# {
# last_day: 1,
# 7_days: 10,
# 30_days: 100,
# count: 1000
# }
#
# Only keys above will be shown on the /about page in the UI,
# but all stats will be shown on the /about.json route. For example take
# this usage:
#
# register_about_stat_group("chat_messages") do
# { last_day: 1, "7_days" => 10, "30_days" => 100, count: 1000, previous_30_days: 150 }
# end
#
# In the UI we will show a table like this:
#
# | 24h | 7 days | 30 days | all time|
# Chat Messages | 1 | 10 | 100 | 1000 |
#
# But the JSON will be like this:
#
# {
# "chat_messages_last_day": 1,
# "chat_messages_7_days": 10,
# "chat_messages_30_days": 100,
# "chat_messages_count": 1000,
# }
#
# The show_in_ui option (default false) is used to determine whether the
# group of stats is shown on the site About page in the Site Statistics
# table. Some stats may be needed purely for reporting purposes and thus
# do not need to be shown in the UI to admins/users.
def register_about_stat_group(plugin_stat_group_name, show_in_ui: false, &block)
About.add_plugin_stat_group(plugin_stat_group_name, show_in_ui: show_in_ui, &block)
end
protected protected
def self.js_path def self.js_path

View File

@ -742,4 +742,35 @@ describe Plugin::Instance do
expect(UnsubscribeKey.get_unsubscribe_strategy_for(key).class).to eq(CustomUnsubscriber) expect(UnsubscribeKey.get_unsubscribe_strategy_for(key).class).to eq(CustomUnsubscriber)
end end
end end
describe "#register_about_stat_group" do
let(:plugin) { Plugin::Instance.new }
after do
About.clear_plugin_stat_groups
end
it "registers an about stat group correctly" do
stats = { last_day: 1, "7_days" => 10, "30_days" => 100, count: 1000 }
plugin.register_about_stat_group("some_group", show_in_ui: true) do
stats
end
expect(About.new.plugin_stats.with_indifferent_access).to match(
hash_including(
some_group_last_day: 1,
some_group_7_days: 10,
some_group_30_days: 100,
some_group_count: 1000,
)
)
end
it "hides the stat group from the UI by default" do
stats = { last_day: 1, "7_days" => 10, "30_days" => 100, count: 1000 }
plugin.register_about_stat_group("some_group") do
stats
end
expect(About.displayed_plugin_stat_groups).to eq([])
end
end
end end

View File

@ -6,6 +6,72 @@ describe About do
include_examples 'stats cacheable' include_examples 'stats cacheable'
end end
describe "#stats" do
after do
About.clear_plugin_stat_groups
end
it "adds plugin stats to the output" do
stats = { last_day: 1, "7_days" => 10, "30_days" => 100, count: 1000 }
About.add_plugin_stat_group("some_group", show_in_ui: true) do
stats
end
expect(described_class.new.stats.with_indifferent_access).to match(
hash_including(
some_group_last_day: 1,
some_group_7_days: 10,
some_group_30_days: 100,
some_group_count: 1000,
)
)
end
it "does not add plugin stats to the output if they are missing one of the required keys" do
stats = { "7_days" => 10, "30_days" => 100, count: 1000 }
About.add_plugin_stat_group("some_group", show_in_ui: true) do
stats
end
expect(described_class.new.stats).not_to match(
hash_including(
some_group_last_day: 1,
some_group_7_days: 10,
some_group_30_days: 100,
some_group_count: 1000,
)
)
end
it "does not error if any of the plugin stat blocks throw an error and still adds the non-errored stats to output" do
stats = { last_day: 1, "7_days" => 10, "30_days" => 100, count: 1000 }
About.add_plugin_stat_group("some_group", show_in_ui: true) do
stats
end
About.add_plugin_stat_group("other_group", show_in_ui: true) do
raise StandardError
end
expect(described_class.new.stats.with_indifferent_access).to match(
hash_including(
some_group_last_day: 1,
some_group_7_days: 10,
some_group_30_days: 100,
some_group_count: 1000,
)
)
expect { described_class.new.stats.with_indifferent_access }.not_to raise_error
end
it "does not allow duplicate displayed stat groups" do
stats = { last_day: 1, "7_days" => 10, "30_days" => 100, count: 1000 }
About.add_plugin_stat_group("some_group", show_in_ui: true) do
stats
end
About.add_plugin_stat_group("some_group", show_in_ui: true) do
stats
end
expect(described_class.displayed_plugin_stat_groups).to eq(["some_group"])
end
end
describe "#category_moderators" do describe "#category_moderators" do
let(:user) { Fabricate(:user) } let(:user) { Fabricate(:user) }
let(:public_cat_moderator) { Fabricate(:user, last_seen_at: 1.month.ago) } let(:public_cat_moderator) { Fabricate(:user, last_seen_at: 1.month.ago) }

View File

@ -470,6 +470,9 @@
"markdown_additional_options" : { "markdown_additional_options" : {
"type": "object" "type": "object"
}, },
"displayed_about_plugin_stat_groups" : {
"type": "array"
},
"categories": { "categories": {
"type": "array", "type": "array",
"items": "items":