0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 00:54:21 +08:00
discourse/plugins/automation/plugin.rb
Régis Hanol 527a13566f
DEV: Add usage metrics for workflows, automations, and data explorer (#41437)
Adds basic usage metrics for **workflows**, **automations**, and **data
explorer** so we can measure adoption across all sites, following the
pattern established for Kanban (dev topic `t/185911`).

Each plugin registers stats via the freeform `stat_type` API, so the
values flow automatically into our site statistics pipeline. No
infrastructure changes are needed.

## Metrics

For each feature: `total` (running count), `created`, `edited`,
`executed` (distinct objects run), and `executions` (total run count) —
each windowed over `last_day / 7_days / 30_days / previous_30_days`,
with a lifetime `count` on `total` and `executions`. Resulting columns:

| | Workflows | Automations | Data Explorer |
|---|---|---|---|
| total | `workflows_total_count` | `automations_total_count` |
`de_queries_total_count` |
| created | `workflows_created_*` | `automations_created_*` |
`de_queries_created_*` |
| edited | `workflows_edited_*` | `automations_edited_*` |
`de_queries_edited_*` |
| executed | `workflows_executed_*` | `automations_executed_*` |
`de_queries_executed_*` |
| executions | `workflows_executions_*` | `automations_executions_*` |
`de_executions_*` |

## How executions are counted

Automations already have a daily rollup (`discourse_automation_stats`),
so they reuse it.

Workflows and Data Explorer get a small daily rollup table each
(`discourse_workflows_execution_stats`, `data_explorer_query_stats`)
recording `total_runs` per object per day. This deliberately avoids two
traps:

- **Workflow executions are purged** after
`workflow_executions_retention_days` (default 30), and the purged rows
carry heavy payloads (full node-graph snapshot + step I/O). Counting
from a lightweight rollup keeps `previous_30_days`/lifetime metrics
valid without retaining those payloads, and decouples the metrics from a
per-site retention setting.
- **Data Explorer has no run log** — only a single `last_run_at`. The
rollup gives us real execution counts and a durable `previous_30_days`
window.

Workflow runs are recorded via an `after_create` on `Execution` (one row
per execution, rate-limited executions excluded). Data Explorer runs go
through a new `Query#record_run!` used by the three run sites.

## Data Explorer `updated_at` fix

Query runs previously did `query.update!(last_run_at:)`, which bumped
`updated_at` on every run and made "edited" indistinguishable from
"executed". `record_run!` now uses `update_columns`, so `updated_at`
reflects genuine edits again. Default (unpersisted) queries are still
persisted on first run as before, and the `DeleteHiddenQueries` job is
unaffected (it also gates on `last_run_at`).

## Core change

`register_stat` deduplicated by name only, so the three plugins couldn't
all register generic names like `total`/`executions` — whichever
activated first won and the rest were silently dropped. The identity
check now includes `stat_type`, so a name can be reused across stat
types (columns stay unique because they are prefixed with the stat
type). Kanban avoided this only because its names happened to be
globally unique.

## Notes for review

- **Single `de` stat_type** (yielding `de_queries_*` and
`de_executions_*`) rather than splitting into
`de_queries`/`de_executions` — happy to change if you'd rather query by
two separate `stat_type_freeform` values. cc measurement folks.
- `structure.sql` and model annotations were hand-updated (local box is
on PG17, which can't run the temp-DB dump/annotate tasks); CI will
verify.

## Tests

- New specs for all three `Statistics` modules and both rollup models
(`.log`, the `after_create`/`record_run!` hooks, the `updated_at`
behavior).
- New core spec covering same-name/different-`stat_type` registration.
2026-07-13 11:41:58 +02:00

270 lines
8.9 KiB
Ruby
Vendored

# frozen_string_literal: true
# name: automation
# about: Allows admins to automate actions through scripts and triggers. Customisation is made through an automatically generated UI.
# meta_topic_id: 195773
# version: 0.1
# authors: jjaffeux
# url: https://github.com/discourse/discourse/tree/main/plugins/automation
enabled_site_setting :discourse_automation_enabled
register_asset "stylesheets/common/discourse-automation.scss"
register_svg_icon "wand-magic-sparkles"
module ::DiscourseAutomation
PLUGIN_NAME = "automation"
AUTOMATION_IDS_CUSTOM_FIELD = "discourse_automation_ids_json"
TOPIC_LAST_CHECKED_BY = "discourse_automation_last_checked_by"
TOPIC_LAST_CHECKED_AT = "discourse_automation_last_checked_at"
USER_PROMOTED_TRUST_LEVEL_CHOICES = [
{ id: "TLALL", name: "discourse_automation.triggerables.user_promoted.trust_levels.ALL" },
{ id: "TL01", name: "discourse_automation.triggerables.user_promoted.trust_levels.TL01" },
{ id: "TL12", name: "discourse_automation.triggerables.user_promoted.trust_levels.TL12" },
{ id: "TL23", name: "discourse_automation.triggerables.user_promoted.trust_levels.TL23" },
{ id: "TL34", name: "discourse_automation.triggerables.user_promoted.trust_levels.TL34" },
]
AUTO_RESPONDER_TRIGGERED_IDS = "auto_responder_triggered_ids_json"
USER_GROUP_MEMBERSHIP_THROUGH_BADGE_BULK_MODIFY_START_COUNT = 1000
REMOVE_UPLOAD_MARKUP_FROM_DELETED_POSTS_BATCH_SIZE = 1000
RECURSION_DEPTH_KEY = :discourse_automation_recursion_depth
SUPPRESSED_TRIGGERS_KEY = :discourse_automation_suppressed_triggers
def self.set_active_automation(_id)
deprecated_active_automation_api
end
def self.get_active_automation
deprecated_active_automation_api
end
def self.suppress_triggers
raise StandardError, "Expecting a block" if !block_given?
Thread.current[SUPPRESSED_TRIGGERS_KEY] = suppressed_triggers_count + 1
begin
yield
ensure
decrement_suppressed_triggers_count
end
end
def self.triggers_suppressed?
suppressed_triggers_count.positive?
end
def self.recursion_depth
Thread.current[RECURSION_DEPTH_KEY] || 0
end
def self.max_recursion_depth
SiteSetting.discourse_automation_max_recursion_depth
end
def self.increment_recursion_depth
Thread.current[RECURSION_DEPTH_KEY] = recursion_depth + 1
end
def self.decrement_recursion_depth
new_depth = recursion_depth - 1
Thread.current[RECURSION_DEPTH_KEY] = new_depth.positive? ? new_depth : nil
end
def self.suppressed_triggers_count
Thread.current[SUPPRESSED_TRIGGERS_KEY] || 0
end
def self.decrement_suppressed_triggers_count
new_count = suppressed_triggers_count - 1
Thread.current[SUPPRESSED_TRIGGERS_KEY] = new_count.positive? ? new_count : nil
end
def self.deprecated_active_automation_api
Discourse.deprecate(
"DiscourseAutomation.set_active_automation/get_active_automation are deprecated. " \
"Use DiscourseAutomation.suppress_triggers instead.",
since: "2026.6.0-latest",
drop_from: "2026.8.0-latest",
raise_error: true,
)
end
private_class_method :suppressed_triggers_count,
:decrement_suppressed_triggers_count,
:deprecated_active_automation_api
end
require_relative "lib/discourse_automation/engine"
after_initialize do
Dir
.glob("./lib/discourse_automation/scripts/*.rb", base: __dir__)
.each { |file| require_relative file }
Dir
.glob("./lib/discourse_automation/triggers/*.rb", base: __dir__)
.each { |file| require_relative file }
reloadable_patch do
Post.prepend DiscourseAutomation::PostExtension
Plugin::Instance.prepend DiscourseAutomation::PluginInstanceExtension
end
add_admin_route "discourse_automation.title", "automation", use_new_show_route: true
add_api_key_scope(
:automation,
{
trigger_automation: {
actions: %w[discourse_automation/automations#trigger],
params: %i[context],
formats: :json,
},
},
)
add_to_serializer(:current_user, :global_notices) do
notices = DiscourseAutomation::UserGlobalNotice.where(user_id: object.id)
ActiveModel::ArraySerializer.new(
notices,
each_serializer: DiscourseAutomation::UserGlobalNoticeSerializer,
).as_json
end
on(:user_first_logged_in) do |user|
name = DiscourseAutomation::Triggers::USER_FIRST_LOGGED_IN
DiscourseAutomation::Automation
.where(trigger: name, enabled: true)
.find_each { |automation| automation.trigger!("kind" => name, "user" => user) }
end
on(:user_added_to_group) do |user, group|
name = DiscourseAutomation::Triggers::USER_ADDED_TO_GROUP
DiscourseAutomation::Automation
.where(trigger: name, enabled: true)
.find_each do |automation|
joined_group = automation.trigger_field("joined_group")
if joined_group["value"] == group.id
automation.trigger!(
"kind" => name,
"usernames" => [user.username],
"user" => user,
"group" => group,
"placeholders" => {
"group_name" => group.name,
},
)
end
end
end
on(:user_removed_from_group) do |user, group|
name = DiscourseAutomation::Triggers::USER_REMOVED_FROM_GROUP
DiscourseAutomation::Automation
.where(trigger: name, enabled: true)
.find_each do |automation|
left_group = automation.trigger_field("left_group")
if left_group["value"] == group.id
automation.trigger!(
"kind" => DiscourseAutomation::Triggers::USER_REMOVED_FROM_GROUP,
"usernames" => [user.username],
"user" => user,
"group" => group,
"placeholders" => {
"group_name" => group.name,
},
)
end
end
end
on(:user_badge_granted) do |badge_id, user_id|
name = DiscourseAutomation::Triggers::USER_BADGE_GRANTED
DiscourseAutomation::Automation
.where(trigger: name, enabled: true)
.find_each do |automation|
DiscourseAutomation::UserBadgeGrantedHandler.handle(automation, badge_id, user_id)
end
end
on(:user_promoted) do |payload|
user_id, new_trust_level, old_trust_level =
payload.values_at(:user_id, :new_trust_level, :old_trust_level)
DiscourseAutomation::EventHandlers.handle_user_promoted(
user_id,
new_trust_level,
old_trust_level,
)
end
on(:topic_created) do |topic|
DiscourseAutomation::EventHandlers.handle_pm_created(topic) if topic.private_message?
end
on(:topic_tags_changed) do |topic, payload|
old_tag_names, new_tag_names, user = payload.values_at(:old_tag_names, :new_tag_names, :user)
DiscourseAutomation::EventHandlers.handle_topic_tags_changed(
topic,
old_tag_names,
new_tag_names,
user,
)
end
on(:topic_closed) do |topic, status|
::DiscourseAutomation::EventHandlers.handle_topic_closed(topic, status)
end
on(:post_created) do |post|
DiscourseAutomation::EventHandlers.handle_post_created_edited(post, :create)
end
on(:post_edited) do |post|
DiscourseAutomation::EventHandlers.handle_post_created_edited(post, :edit)
end
on(:flag_created) do |post_action|
DiscourseAutomation::EventHandlers.handle_post_flag_created(post_action) if post_action.post
end
on(:category_created) do |category|
DiscourseAutomation::EventHandlers.handle_category_created_edited(category, :create)
end
on(:category_edited) do |category|
DiscourseAutomation::EventHandlers.handle_category_created_edited(category, :edit)
end
Plugin::Filter.register(:after_post_cook) do |post, cooked|
DiscourseAutomation::EventHandlers.handle_after_post_cook(post, cooked)
end
on(:post_created) { |post| DiscourseAutomation::EventHandlers.handle_stalled_topic(post) }
register_topic_custom_field_type(DiscourseAutomation::AUTOMATION_IDS_CUSTOM_FIELD, :json)
register_topic_custom_field_type(DiscourseAutomation::AUTO_RESPONDER_TRIGGERED_IDS, :json)
on(:user_updated) { |user| DiscourseAutomation::EventHandlers.handle_user_updated(user) }
on(:user_created) do |user|
DiscourseAutomation::EventHandlers.handle_user_updated(user, new_user: true)
end
register_user_custom_field_type(DiscourseAutomation::AUTOMATION_IDS_CUSTOM_FIELD, :json)
register_post_custom_field_type(DiscourseAutomation::AUTOMATION_IDS_CUSTOM_FIELD, :json)
register_post_custom_field_type("stalled_wiki_triggered_at", :string)
register_stat("total", stat_type: :automations) { DiscourseAutomation::Statistics.total }
register_stat("created", stat_type: :automations) { DiscourseAutomation::Statistics.created }
register_stat("edited", stat_type: :automations) { DiscourseAutomation::Statistics.edited }
register_stat("executed", stat_type: :automations) { DiscourseAutomation::Statistics.executed }
register_stat("executions", stat_type: :automations) do
DiscourseAutomation::Statistics.executions
end
end