diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_route.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_route.rb index eb31bcbbb..104d5acdf 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_route.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_route.rb @@ -28,7 +28,14 @@ def routes end def add_route(name, method, uri, closure, format = 'json') - @routes[name] = { method: method, uri: uri, closure: closure, format: format } + instrumented = lambda do |args| + ForestAdminDatasourceToolkit::Monitoring.instrument( + 'request', + { route: name, collection: args.dig(:params, 'collection_name'), + id: args.dig(:params, 'id'), method: method } + ) { closure.call(args) } + end + @routes[name] = { method: method, uri: uri, closure: instrumented, format: format } end def setup_routes diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/abstract_route_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/abstract_route_spec.rb new file mode 100644 index 000000000..6d14a9efb --- /dev/null +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/abstract_route_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' +require 'active_support/isolated_execution_state' +require 'active_support/notifications' + +module ForestAdminAgent + module Routes + describe AbstractRoute do + let(:route_class) do + Class.new(described_class) do + def setup_routes + add_route('forest_test', 'get', '/test', ->(args) { { echoed: args.dig(:params, 'collection_name') } }) + self + end + end + end + + it 'wraps the closure in a request.forest_admin notification and passes the return value through' do + events = [] + subscriber = ::ActiveSupport::Notifications.subscribe('request.forest_admin') do |*args| + events << ::ActiveSupport::Notifications::Event.new(*args) + end + + route = route_class.new.routes['forest_test'] + result = route[:closure].call({ params: { 'collection_name' => 'user', 'id' => '42' } }) + + ::ActiveSupport::Notifications.unsubscribe(subscriber) + + expect(result).to eq({ echoed: 'user' }) + expect(events.size).to eq(1) + expect(events.first.payload).to eq({ route: 'forest_test', collection: 'user', id: '42', method: 'get' }) + expect(events.first.duration).to be >= 0 + end + end + end +end diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/context/relaxed_wrappers/relaxed_collection.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/context/relaxed_wrappers/relaxed_collection.rb index e2bc7d836..c1c04ffe8 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/context/relaxed_wrappers/relaxed_collection.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/context/relaxed_wrappers/relaxed_collection.rb @@ -9,6 +9,10 @@ def initialize(collection, caller) @caller = caller end + def name + @collection.name + end + def native_driver(&block) @collection.native_driver(&block) end diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/action_collection_decorator.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/action_collection_decorator.rb index 65bb93bb0..71a390343 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/action_collection_decorator.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/action_collection_decorator.rb @@ -27,7 +27,9 @@ def execute(caller, name, data, filter = nil) context = get_context(caller, action, data, filter) result_builder = ResultBuilder.new - result = action.execute.call(context, result_builder) + result = ForestAdminDatasourceToolkit::Monitoring.instrument( + 'action', { collection: self.name, action: name }, caller: caller + ) { action.execute.call(context, result_builder) } return result if result.is_a? Hash diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/chart/chart_collection_decorator.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/chart/chart_collection_decorator.rb index 3e90a3ba2..53a3c0ff5 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/chart/chart_collection_decorator.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/chart/chart_collection_decorator.rb @@ -24,7 +24,9 @@ def render_chart(caller, name, record_id, parameters = {}) context = ChartContext.new(self, caller, record_id, parameters) result_builder = ResultBuilder.new - return @charts[name].call(context, result_builder) + return ForestAdminDatasourceToolkit::Monitoring.instrument( + 'chart', { collection: self.name, chart: name }, caller: caller + ) { @charts[name].call(context, result_builder) } end @child_collection.render_chart(caller, name, record_id, parameters) diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/chart/chart_datasource_decorator.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/chart/chart_datasource_decorator.rb index 7af39e26f..8ba42d33f 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/chart/chart_datasource_decorator.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/chart/chart_datasource_decorator.rb @@ -32,10 +32,12 @@ def render_chart(caller, name, parameters = {}) chart_definition = @charts[name] if chart_definition - return chart_definition.call( - DatasourceChartContext.new(self, caller, parameters), - ResultBuilder.new - ) + return ForestAdminDatasourceToolkit::Monitoring.instrument('chart', { chart: name }, caller: caller) do + chart_definition.call( + DatasourceChartContext.new(self, caller, parameters), + ResultBuilder.new + ) + end end super diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/computed/utils/computed_field.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/computed/utils/computed_field.rb index 6347f0465..31c02844c 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/computed/utils/computed_field.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/computed/utils/computed_field.rb @@ -35,7 +35,9 @@ def self.queue_field(ctx, collection, new_path, paths, flatten) paths.push(new_path) - flatten << compute_field(ctx, computed, computed_dependencies, dependency_values) + flatten << ForestAdminDatasourceToolkit::Monitoring.instrument( + 'computed_field', { collection: collection.name, field: new_path }, caller: ctx.caller + ) { compute_field(ctx, computed, computed_dependencies, dependency_values) } end def self.compute_from_records(ctx, collection, records_projection, desired_projection, records) diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/hook/hook_collection_decorator.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/hook/hook_collection_decorator.rb index f12455a27..7ab2bbc45 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/hook/hook_collection_decorator.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/hook/hook_collection_decorator.rb @@ -10,11 +10,11 @@ class HookCollectionDecorator < ForestAdminDatasourceToolkit::Decorators::Collec def initialize(child_collection, datasource) super @hooks = { - 'List' => Hooks.new, - 'Create' => Hooks.new, - 'Update' => Hooks.new, - 'Delete' => Hooks.new, - 'Aggregate' => Hooks.new + 'List' => Hooks.new('List'), + 'Create' => Hooks.new('Create'), + 'Update' => Hooks.new('Update'), + 'Delete' => Hooks.new('Delete'), + 'Aggregate' => Hooks.new('Aggregate') } end diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/hook/hooks.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/hook/hooks.rb index fc644bb5a..84390e47f 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/hook/hooks.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/hook/hooks.rb @@ -4,22 +4,35 @@ module Hook class Hooks attr_reader :before, :after - def initialize + def initialize(type = nil) + @type = type @before = [] @after = [] end def execute_before(context) - @before.each { |hook| hook.call(context) } + instrument('before', context) { @before.each { |hook| hook.call(context) } } end def execute_after(context) - @after.each { |hook| hook.call(context) } + instrument('after', context) { @after.each { |hook| hook.call(context) } } end def add_handler(position, hook) position == 'After' ? @after << hook : @before << hook end + + private + + def instrument(position, context, &block) + hooks = position == 'before' ? @before : @after + return yield if hooks.empty? + + ForestAdminDatasourceToolkit::Monitoring.instrument( + 'hook', { collection: context.collection.name, operation: @type, position: position }, + caller: context.caller, &block + ) + end end end end diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/operators_emulate/operators_emulate_collection_decorator.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/operators_emulate/operators_emulate_collection_decorator.rb index 4642835ca..f8555c0fb 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/operators_emulate/operators_emulate_collection_decorator.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/operators_emulate/operators_emulate_collection_decorator.rb @@ -89,7 +89,9 @@ def compute_equivalent(caller, leaf, replacements) raise Exceptions::ForestException, "Operator replacement cycle: #{sub_replacements.join(" -> ")}" end - result = handler.call(leaf.value, Context::CollectionCustomizationContext.new(self, caller)) + result = ForestAdminDatasourceToolkit::Monitoring.instrument( + 'operator_emulation', { collection: name, field: leaf.field, operator: leaf.operator }, caller: caller + ) { handler.call(leaf.value, Context::CollectionCustomizationContext.new(self, caller)) } if result equivalent = result.class < Nodes::ConditionTree ? result : ConditionTreeFactory.from_plain_object(result) diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/override/override_collection_decorator.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/override/override_collection_decorator.rb index 9e805a734..1a3d5cc5b 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/override/override_collection_decorator.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/override/override_collection_decorator.rb @@ -9,7 +9,9 @@ class OverrideCollectionDecorator < ForestAdminDatasourceToolkit::Decorators::Co def create(caller, data) if @create_handler context = CreateOverrideCustomizationContext.new(@child_collection, caller, data) - return @create_handler.call(context) + return ForestAdminDatasourceToolkit::Monitoring.instrument( + 'override', { collection: name, operation: 'create' }, caller: caller + ) { @create_handler.call(context) } end super @@ -22,7 +24,9 @@ def add_create_handler(handler) def update(caller, filter, patch) if @update_handler context = UpdateOverrideCustomizationContext.new(@child_collection, caller, filter, patch) - return @update_handler.call(context) + return ForestAdminDatasourceToolkit::Monitoring.instrument( + 'override', { collection: name, operation: 'update' }, caller: caller + ) { @update_handler.call(context) } end super @@ -35,7 +39,9 @@ def add_update_handler(handler) def delete(caller, filter) if @delete_handler context = DeleteOverrideCustomizationContext.new(@child_collection, caller, filter) - return @delete_handler.call(context) + return ForestAdminDatasourceToolkit::Monitoring.instrument( + 'override', { collection: name, operation: 'delete' }, caller: caller + ) { @delete_handler.call(context) } end super diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/search/search_collection_decorator.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/search/search_collection_decorator.rb index 1f894f18a..56f84d908 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/search/search_collection_decorator.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/search/search_collection_decorator.rb @@ -39,7 +39,11 @@ def refine_filter(caller, filter) tree = default_replacer(filter.search, filter.search_extended) if @replacer - plain_tree = @replacer.call(filter.search, filter.search_extended, ctx) + plain_tree = ForestAdminDatasourceToolkit::Monitoring.instrument( + 'search', { collection: name }, caller: caller + ) do + @replacer.call(filter.search, filter.search_extended, ctx) + end tree = ConditionTreeFactory.from_plain_object(plain_tree) end diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/segment/segment_collection_decorator.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/segment/segment_collection_decorator.rb index ec70f3c4e..b62f31de3 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/segment/segment_collection_decorator.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/segment/segment_collection_decorator.rb @@ -25,24 +25,26 @@ def refine_schema(sub_schema) sub_schema end - def refine_filter(_caller, filter = nil) + def refine_filter(caller, filter = nil) return nil unless filter condition_tree = filter.condition_tree segment = filter.segment if segment && @segments.key?(segment) - condition_tree = compute_segment(segment, filter) + condition_tree = compute_segment(segment, filter, caller) segment = nil end filter.override(condition_tree: condition_tree, segment: segment) end - def compute_segment(segment_name, filter) + def compute_segment(segment_name, filter, caller) definition = @segments[segment_name] result = if definition.respond_to? :call - definition.call(Context::CollectionCustomizationContext.new(self, caller)) + ForestAdminDatasourceToolkit::Monitoring.instrument( + 'segment', { collection: name, segment: segment_name }, caller: caller + ) { definition.call(Context::CollectionCustomizationContext.new(self, caller)) } else definition end diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/write/write_replace/write_replace_collection_decorator.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/write/write_replace/write_replace_collection_decorator.rb index 3c3d0a940..dbc8976e3 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/write/write_replace/write_replace_collection_decorator.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/write/write_replace/write_replace_collection_decorator.rb @@ -72,12 +72,20 @@ def rewrite_key(context, key, used) if field_schema&.type == 'Column' # We either call the customer handler or a default one that does nothing. - handler = @handlers[key] || proc { |v| { key => v } } - field_patch = if context.record.key?(key) && handler.call(context.record[key], context) - handler.call(context.record[key], context) - else - {} - end + user_handler = @handlers[key] + handler = user_handler || proc { |v| { key => v } } + # ponytail: single evaluation (the previous code called the handler twice); only the + # user handler is instrumented — the default no-op proc isn't user code. + raw = if context.record.key?(key) + if user_handler + ForestAdminDatasourceToolkit::Monitoring.instrument( + 'write', { collection: name, field: key }, caller: context.caller + ) { handler.call(context.record[key], context) } + else + handler.call(context.record[key], context) + end + end + field_patch = raw || {} if field_patch && !field_patch.is_a?(Hash) raise ForestException, "The write handler of #{key} should return an Hash or nothing." diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/datasource_customizer_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/datasource_customizer_spec.rb index d91299183..4737b7270 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/datasource_customizer_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/datasource_customizer_spec.rb @@ -6,6 +6,9 @@ module ForestAdminDatasourceCustomizer describe DatasourceCustomizer do let(:datasource) { ForestAdminDatasourceToolkit::Datasource.new } + let(:caller) do + instance_double(ForestAdminDatasourceToolkit::Components::Caller, id: 1, rendering_id: 1, project: 'test') + end before do @collection = instance_double( diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/action/action_collection_decorator_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/action/action_collection_decorator_spec.rb index 828b7994e..98aa0d712 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/action/action_collection_decorator_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/action/action_collection_decorator_spec.rb @@ -91,6 +91,17 @@ module Action expect(result).to eq({ response_headers: {}, type: 'Success', message: 'Success', invalidated: [], html: nil }) end + it 'emits an action.forest_admin notification' do + events = [] + sub = ::ActiveSupport::Notifications.subscribe('action.forest_admin') do |*a| + events << ::ActiveSupport::Notifications::Event.new(*a) + end + @decorated_book.execute(caller, 'make photocopy', {}, Filter.new) + ::ActiveSupport::Notifications.unsubscribe(sub) + + expect(events.map(&:payload)).to include(include(action: 'make photocopy')) + end + it 'generate empty form' do form = @decorated_book.get_form(caller, 'make photocopy', {}, Filter.new) expect(form).to eq([]) diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/chart/chart_collection_decorator_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/chart/chart_collection_decorator_spec.rb index ba15435d3..56e167647 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/chart/chart_collection_decorator_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/chart/chart_collection_decorator_spec.rb @@ -64,6 +64,17 @@ module Chart expect(result).to eq({ countCurrent: 2 }) end + + it 'emits a chart.forest_admin notification' do + events = [] + sub = ::ActiveSupport::Notifications.subscribe('chart.forest_admin') do |*a| + events << ::ActiveSupport::Notifications::Event.new(*a) + end + @decorated_book.render_chart(caller, 'new_chart', [123]) + ::ActiveSupport::Notifications.unsubscribe(sub) + + expect(events.map(&:payload)).to include(include(chart: 'new_chart')) + end end end end diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/computed/compute_collection_decorator_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/computed/compute_collection_decorator_spec.rb index c5c811639..073ed89e0 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/computed/compute_collection_decorator_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/computed/compute_collection_decorator_spec.rb @@ -1,4 +1,5 @@ require 'spec_helper' +require 'active_support/notifications' module ForestAdminDatasourceCustomizer module Decorators @@ -193,6 +194,21 @@ module Computed ]) end + it 'list() should emit a computed_field.forest_admin notification' do + events = [] + subscriber = ::ActiveSupport::Notifications.subscribe('computed_field.forest_admin') do |*args| + events << ::ActiveSupport::Notifications::Event.new(*args) + end + + @new_books.list(caller, Filter.new, Projection.new(['title', 'author:fullName'])) + + ::ActiveSupport::Notifications.unsubscribe(subscriber) + + expect(events.map(&:payload)).to include( + include(collection: 'book', field: 'author:fullName', user_id: 1, rendering_id: 1, project: 'terminator') + ) + end + it 'aggregate() should use the child implementation when relevant' do rows = @new_books.aggregate( caller, diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/hook/hook_collection_decorator_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/hook/hook_collection_decorator_spec.rb index c7447d763..e048f4598 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/hook/hook_collection_decorator_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/hook/hook_collection_decorator_spec.rb @@ -12,7 +12,9 @@ module Hook describe HookCollectionDecorator do subject(:hook_collection_decorator) { described_class } - let(:caller) { instance_double(ForestAdminDatasourceToolkit::Components::Caller) } + let(:caller) do + instance_double(ForestAdminDatasourceToolkit::Components::Caller, id: 1, rendering_id: 1, project: 'test') + end let(:aggregation) { instance_double(ForestAdminDatasourceToolkit::Components::Query::Aggregation) } before do @@ -60,6 +62,19 @@ module Hook expect(spy).to have_received(:call).once end + + it 'emits a hook.forest_admin notification for registered hooks' do + spy = instance_double(Proc, call: nil) + @decorated_transaction.add_hook('Before', 'Create', spy) + events = [] + sub = ::ActiveSupport::Notifications.subscribe('hook.forest_admin') do |*a| + events << ::ActiveSupport::Notifications::Event.new(*a) + end + @decorated_transaction.create(caller, []) + ::ActiveSupport::Notifications.unsubscribe(sub) + + expect(events.map(&:payload)).to include(include(operation: 'Create', position: 'before', collection: 'transaction')) + end end describe 'on a update' do diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/override/override_collection_decorator_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/override/override_collection_decorator_spec.rb index 5dea845bf..bca402e49 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/override/override_collection_decorator_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/override/override_collection_decorator_spec.rb @@ -10,7 +10,7 @@ module Override describe OverrideCollectionDecorator do subject(:override_collection_decorator) { described_class } - let(:caller) { instance_double(ForestAdminDatasourceToolkit::Components::Caller) } + let(:caller) { instance_double(ForestAdminDatasourceToolkit::Components::Caller, id: 1, rendering_id: 1, project: 'test') } before do datasource = Datasource.new diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/search/search_collection_decorator_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/search/search_collection_decorator_spec.rb index 46fb7afcd..483bb2433 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/search/search_collection_decorator_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/search/search_collection_decorator_spec.rb @@ -10,7 +10,7 @@ module Search describe SearchCollectionDecorator do let(:datasource) { ForestAdminDatasourceToolkit::Datasource.new } - let(:caller) { instance_double(ForestAdminDatasourceToolkit::Components::Caller) } + let(:caller) { instance_double(ForestAdminDatasourceToolkit::Components::Caller, id: 1, rendering_id: 1, project: 'test') } before do @collection_user = instance_double( diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/segment/segment_collection_decorator_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/segment/segment_collection_decorator_spec.rb index 84af63f63..4cdb38956 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/segment/segment_collection_decorator_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/segment/segment_collection_decorator_spec.rb @@ -10,6 +10,10 @@ module Segment include ForestAdminDatasourceToolkit::Schema describe SegmentCollectionDecorator do + let(:caller) do + instance_double(ForestAdminDatasourceToolkit::Components::Caller, id: 1, rendering_id: 1, project: 'test') + end + before do datasource = Datasource.new @collection = build_collection( @@ -58,6 +62,22 @@ module Segment context 'when the segment is managed by this decorator' do describe 'refine_filter' do + it 'emits a segment.forest_admin notification when the definition is callable' do + @decorated_collection.add_segment('segment_name', proc do + Nodes::ConditionTreeLeaf.new('name', Operators::EQUAL, 'foo') + end) + a_filter = Filter.new(segment: 'segment_name') + + events = [] + sub = ::ActiveSupport::Notifications.subscribe('segment.forest_admin') do |*a| + events << ::ActiveSupport::Notifications::Event.new(*a) + end + @decorated_collection.refine_filter(caller, a_filter) + ::ActiveSupport::Notifications.unsubscribe(sub) + + expect(events.map(&:payload)).to include(include(segment: 'segment_name')) + end + it 'return the filter with the merged conditionTree' do condition_tree_generator = Nodes::ConditionTreeLeaf.new('name', Operators::EQUAL, 'foo') @decorated_collection.add_segment('segment_name', condition_tree_generator) diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_mixed_relations_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_mixed_relations_spec.rb index 1d739377f..ec8fd1b6f 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_mixed_relations_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_mixed_relations_spec.rb @@ -11,7 +11,7 @@ module WriteReplace describe WriteDatasourceDecorator do let(:datasource) { ForestAdminDatasourceToolkit::Datasource.new } let(:write_datasource_decorator) { described_class } - let(:caller) { instance_double(ForestAdminDatasourceToolkit::Components::Caller) } + let(:caller) { instance_double(ForestAdminDatasourceToolkit::Components::Caller, id: 1, rendering_id: 1, project: 'test') } before do @collection_book = instance_double( diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_multiple_many_to_one_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_multiple_many_to_one_spec.rb index 04faa494d..1b88b5c53 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_multiple_many_to_one_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_multiple_many_to_one_spec.rb @@ -11,7 +11,7 @@ module WriteReplace describe WriteDatasourceDecorator do let(:datasource) { ForestAdminDatasourceToolkit::Datasource.new } let(:write_datasource_decorator) { described_class } - let(:caller) { instance_double(ForestAdminDatasourceToolkit::Components::Caller) } + let(:caller) { instance_double(ForestAdminDatasourceToolkit::Components::Caller, id: 1, rendering_id: 1, project: 'test') } before do @collection_book = instance_double( diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_multiple_one_to_one_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_multiple_one_to_one_spec.rb index 71ac3f8ac..09c6c16d4 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_multiple_one_to_one_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_multiple_one_to_one_spec.rb @@ -11,7 +11,7 @@ module WriteReplace describe WriteDatasourceDecorator do let(:datasource) { ForestAdminDatasourceToolkit::Datasource.new } let(:write_datasource_decorator) { described_class } - let(:caller) { instance_double(ForestAdminDatasourceToolkit::Components::Caller) } + let(:caller) { instance_double(ForestAdminDatasourceToolkit::Components::Caller, id: 1, rendering_id: 1, project: 'test') } before do @collection_book = instance_double( diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_single_many_to_one_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_single_many_to_one_spec.rb index b0341f8cd..77af1497b 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_single_many_to_one_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_single_many_to_one_spec.rb @@ -12,7 +12,7 @@ module WriteReplace describe WriteDatasourceDecorator do let(:datasource) { ForestAdminDatasourceToolkit::Datasource.new } let(:write_datasource_decorator) { described_class } - let(:caller) { instance_double(ForestAdminDatasourceToolkit::Components::Caller) } + let(:caller) { instance_double(ForestAdminDatasourceToolkit::Components::Caller, id: 1, rendering_id: 1, project: 'test') } before do @collection_price = instance_double( diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_single_one_to_one_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_single_one_to_one_spec.rb index 41ce6911b..91fac16ae 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_single_one_to_one_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_single_one_to_one_spec.rb @@ -12,7 +12,7 @@ module WriteReplace describe WriteDatasourceDecorator do let(:datasource) { ForestAdminDatasourceToolkit::Datasource.new } let(:write_datasource_decorator) { described_class } - let(:caller) { instance_double(ForestAdminDatasourceToolkit::Components::Caller) } + let(:caller) { instance_double(ForestAdminDatasourceToolkit::Components::Caller, id: 1, rendering_id: 1, project: 'test') } before do @collection_book = instance_double( @@ -106,7 +106,7 @@ module WriteReplace ) allow(@collection_owner).to receive(:create) - caller = instance_double(ForestAdminDatasourceToolkit::Components::Caller) + caller = instance_double(ForestAdminDatasourceToolkit::Components::Caller, id: 1, rendering_id: 1, project: 'test') @decorated_book.create(caller, { 'title' => 'a title' }) expect(@collection_owner).to have_received(:create).with(caller, { 'name' => 'NAME TO CHANGE', 'book_id' => '123e4567-e89b-12d3-a456-111111111111' }) diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/update_relations/collection_many_to_one_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/update_relations/collection_many_to_one_spec.rb index 44ddf6aa6..51e061647 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/update_relations/collection_many_to_one_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/update_relations/collection_many_to_one_spec.rb @@ -13,7 +13,7 @@ module UpdateRelations describe UpdateRelationsCollectionDecorator do let(:datasource) { ForestAdminDatasourceToolkit::Datasource.new } let(:update_relations_datasource_decorator) { described_class } - let(:caller) { instance_double(ForestAdminDatasourceToolkit::Components::Caller) } + let(:caller) { instance_double(ForestAdminDatasourceToolkit::Components::Caller, id: 1, rendering_id: 1, project: 'test') } before do @collection_author = instance_double( diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/update_relations/collection_one_to_one_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/update_relations/collection_one_to_one_spec.rb index 46a50587f..0607af628 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/update_relations/collection_one_to_one_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/update_relations/collection_one_to_one_spec.rb @@ -13,7 +13,7 @@ module UpdateRelations describe UpdateRelationsCollectionDecorator do let(:datasource) { ForestAdminDatasourceToolkit::Datasource.new } let(:update_relations_datasource_decorator) { described_class } - let(:caller) { instance_double(ForestAdminDatasourceToolkit::Components::Caller) } + let(:caller) { instance_double(ForestAdminDatasourceToolkit::Components::Caller, id: 1, rendering_id: 1, project: 'test') } before do @collection_author = instance_double( diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/write_replace/collection_relation_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/write_replace/collection_relation_spec.rb index ca3e34c96..6e938fd03 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/write_replace/collection_relation_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/write_replace/collection_relation_spec.rb @@ -11,7 +11,7 @@ module WriteReplace describe WriteReplaceCollectionDecorator do let(:datasource) { ForestAdminDatasourceToolkit::Datasource.new } let(:write_datasource_decorator) { ForestAdminDatasourceCustomizer::Decorators::Write::WriteDatasourceDecorator } - let(:caller) { instance_double(ForestAdminDatasourceToolkit::Components::Caller) } + let(:caller) { instance_double(ForestAdminDatasourceToolkit::Components::Caller, id: 1, rendering_id: 1, project: 'test') } before do @collection_author = instance_double( diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/write_replace/collection_simple_creation_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/write_replace/collection_simple_creation_spec.rb index c05029e20..1e82b6254 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/write_replace/collection_simple_creation_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/write_replace/collection_simple_creation_spec.rb @@ -11,7 +11,7 @@ module WriteReplace describe WriteReplaceCollectionDecorator do let(:datasource) { ForestAdminDatasourceToolkit::Datasource.new } let(:write_datasource_decorator) { ForestAdminDatasourceCustomizer::Decorators::Write::WriteDatasourceDecorator } - let(:caller) { instance_double(ForestAdminDatasourceToolkit::Components::Caller) } + let(:caller) { instance_double(ForestAdminDatasourceToolkit::Components::Caller, id: 1, rendering_id: 1, project: 'test') } before do @collection_book = instance_double( @@ -43,6 +43,27 @@ module WriteReplace end context 'when rewrite the record' do + it 'invokes the handler once and emits a write.forest_admin notification' do + record = { 'name' => 'a name' } + allow(@collection_book).to receive(:create).and_return(record) + + calls = 0 + @decorated_book.replace_field_writing('name') do |value, _context| + calls += 1 + { 'name' => value } + end + + events = [] + sub = ::ActiveSupport::Notifications.subscribe('write.forest_admin') do |*a| + events << ::ActiveSupport::Notifications::Event.new(*a) + end + @decorated_book.create(caller, record) + ::ActiveSupport::Notifications.unsubscribe(sub) + + expect(calls).to eq(1) + expect(events.map(&:payload)).to include(include(collection: 'book', field: 'name')) + end + it 'with a empty handler' do record = { 'name' => 'a name', 'age' => 'some age' } allow(@collection_book).to receive(:create).and_return(record) diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/write_replace/collection_simple_update_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/write_replace/collection_simple_update_spec.rb index fbfee9c36..84087963a 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/write_replace/collection_simple_update_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/write_replace/collection_simple_update_spec.rb @@ -11,7 +11,7 @@ module WriteReplace describe WriteReplaceCollectionDecorator do let(:datasource) { ForestAdminDatasourceToolkit::Datasource.new } let(:write_datasource_decorator) { ForestAdminDatasourceCustomizer::Decorators::Write::WriteDatasourceDecorator } - let(:caller) { instance_double(ForestAdminDatasourceToolkit::Components::Caller) } + let(:caller) { instance_double(ForestAdminDatasourceToolkit::Components::Caller, id: 1, rendering_id: 1, project: 'test') } let(:filter) { Filter.new } before do diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/write_replace/write_customization_context_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/write_replace/write_customization_context_spec.rb index ad08a1e6c..9e78945ec 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/write_replace/write_customization_context_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/write_replace/write_customization_context_spec.rb @@ -8,7 +8,7 @@ module WriteReplace describe WriteCustomizationContext do let(:collection) { build_collection } - let(:caller_context) { instance_double(ForestAdminDatasourceToolkit::Components::Caller) } + let(:caller_context) { instance_double(ForestAdminDatasourceToolkit::Components::Caller, id: 1, rendering_id: 1, project: 'test') } let(:action) { 'create' } let(:record) { { 'id' => 1, 'name' => 'Test' } } let(:filter) { instance_double(ForestAdminDatasourceToolkit::Components::Query::Filter) } diff --git a/packages/forest_admin_datasource_customizer/spec/spec_helper.rb b/packages/forest_admin_datasource_customizer/spec/spec_helper.rb index f76e472a0..62c1c01e0 100644 --- a/packages/forest_admin_datasource_customizer/spec/spec_helper.rb +++ b/packages/forest_admin_datasource_customizer/spec/spec_helper.rb @@ -7,6 +7,8 @@ require 'forest_admin_test_toolkit' require 'filecache' require 'singleton' +require 'active_support/isolated_execution_state' +require 'active_support/notifications' SimpleCov.formatters = [SimpleCov::Formatter::JSONFormatter, SimpleCov::Formatter::HTMLFormatter] SimpleCov.start do diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/caller.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/caller.rb index 183e6b5ea..0123fa4cd 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/caller.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/caller.rb @@ -1,7 +1,8 @@ module ForestAdminDatasourceToolkit module Components class Caller - attr_reader :id, :email, :first_name, :last_name, :tags, :team, :rendering_id, :timezone, :permission_level, :role + attr_reader :id, :email, :first_name, :last_name, :tags, :team, :rendering_id, :timezone, :permission_level, + :role, :project def initialize( id:, diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/monitoring.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/monitoring.rb new file mode 100644 index 000000000..41a24b8ab --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/monitoring.rb @@ -0,0 +1,24 @@ +require 'active_support/isolated_execution_state' +require 'active_support/notifications' + +module ForestAdminDatasourceToolkit + # Backend-agnostic instrumentation seam. Emits ActiveSupport::Notifications + # events under the "*.forest_admin" namespace so host apps can subscribe and + # forward to any monitoring stack. No-op cost when nobody subscribes. + module Monitoring + # Uniform entry point, callable from anywhere (decorator, utility class, plain + # object, or a client's custom handler). Pass `caller:` to auto-merge the acting + # user's non-PII identity (user_id/rendering_id/project) into the payload. + def self.instrument(event, payload = {}, caller: nil, &block) + enriched = payload.merge(caller_payload(caller)).compact + ActiveSupport::Notifications.instrument("#{event}.forest_admin", enriched, &block) + end + + # Non-PII identity of the acting Forest user, to merge into an event payload. + def self.caller_payload(caller) + return {} unless caller + + { user_id: caller.id, rendering_id: caller.rendering_id, project: caller.project } + end + end +end diff --git a/packages/forest_admin_rails/lib/forest_admin_rails.rb b/packages/forest_admin_rails/lib/forest_admin_rails.rb index 2606f91be..51553c60f 100644 --- a/packages/forest_admin_rails/lib/forest_admin_rails.rb +++ b/packages/forest_admin_rails/lib/forest_admin_rails.rb @@ -34,6 +34,7 @@ module ForestAdminRails setting :disable_route_cache, default: false setting :rpc_max_polling_threads, default: nil setting :workflow_executor_url, default: nil + setting :monitoring, default: {} # opt-in log subscriber; see ForestAdminRails::Monitoring::DEFAULTS if defined?(Rails::Railtie) # logic for cors middleware,... here // or it might be into Engine diff --git a/packages/forest_admin_rails/lib/forest_admin_rails/engine.rb b/packages/forest_admin_rails/lib/forest_admin_rails/engine.rb index ced010c7c..59da0e0e0 100644 --- a/packages/forest_admin_rails/lib/forest_admin_rails/engine.rb +++ b/packages/forest_admin_rails/lib/forest_admin_rails/engine.rb @@ -28,6 +28,13 @@ class Engine < ::Rails::Engine Rails.error.subscribe(ForestAdminErrorSubscriber.new) end + # Runs after the host's config/initializers (where `config.monitoring` is set) + # but before the middleware stack is built, so the correlation middleware can + # still be inserted. No-op unless monitoring is enabled. + initializer 'forest_admin_rails.monitoring', after: :load_config_initializers do |app| + ForestAdminRails::Monitoring.install!(app) + end + config.after_initialize do Rails.error.handle(ForestAdminDatasourceToolkit::Exceptions::ForestException) do agent_factory = ForestAdminAgent::Builder::AgentFactory.instance diff --git a/packages/forest_admin_rails/lib/forest_admin_rails/monitoring.rb b/packages/forest_admin_rails/lib/forest_admin_rails/monitoring.rb new file mode 100644 index 000000000..7a0bc1727 --- /dev/null +++ b/packages/forest_admin_rails/lib/forest_admin_rails/monitoring.rb @@ -0,0 +1,209 @@ +require 'json' +require 'logger' +require 'active_support/notifications' +require 'active_support/isolated_execution_state' +require 'active_support/core_ext/string/filters' + +module ForestAdminRails + # Opt-in log subscriber for Forest agent monitoring events. + # + # The agent only *emits* ActiveSupport::Notifications (`*.forest_admin`); this + # is a convenience sink that turns them into structured log lines, so most + # clients get monitoring by flipping one config flag instead of writing a + # subscriber. Ships disabled. Enable via: + # + # ForestAdminRails.configure do |config| + # config.monitoring = { enabled: true } # see DEFAULTS for all options + # end + # + # Output defaults to STDOUT + JSON tagged with "source":"forest_admin", so it + # is auto-collected by Datadog/CloudWatch/k8s and filterable even when mixed + # with the Rails log. Point `output` at a file path or your own Logger to keep + # it separate. Advanced backends (StatsD, Sentry, OTel) subscribe in code. + module Monitoring + DEFAULTS = { + enabled: false, + output: :stdout, # :stdout | "path/to.log" (rotated) | a Logger instance + format: :json, # :json | :text + ignore: %w[hook], # event short-names to drop (name without ".forest_admin") + sql_level: 'off', # off (group by query name) | medium (by SQL shape) | full (per query) + sql_truncate: 200, # max SQL length shown at the :medium level + sql_summary_top: 15, # distinct queries listed per request summary + slow_threshold_ms: 0, # only log forest events at least this slow + source: 'forest_admin' # discriminator field, so mixed streams stay filterable + }.freeze + + # Carries Rails' X-Request-Id onto a thread-local so every event of a request + # shares one correlation id (also matching Rails / load-balancer logs). + class RequestTagger + def initialize(app) + @app = app + end + + def call(env) + Thread.current[:forest_request_id] = env['action_dispatch.request_id'] + @app.call(env) + ensure + Thread.current[:forest_request_id] = nil + end + end + + class << self + # Called from the engine at boot. Reads config, and if enabled wires the + # correlation middleware, a logger, and the notification subscribers. + def install!(app) + config = DEFAULTS.merge((ForestAdminRails.config[:monitoring] || {}).transform_keys(&:to_sym)) + return unless config[:enabled] + return if @installed + + @installed = true + insert_middleware(app) + subscribe!(build_logger(config[:output]), config) + end + + # Registers the subscribers against a given logger. Returns the subscriber + # handles so callers (and tests) can tear them down. Kept app-free so it is + # unit-testable without booting Rails. + def subscribe!(logger, options = {}) + config = DEFAULTS.merge(options.transform_keys(&:to_sym)) + ignore = config[:ignore].map(&:to_s) + + handles = [] + handles << subscribe_events(logger, config, ignore) + handles << subscribe_request_scope(logger, config) + handles << subscribe_sql(logger, config) + handles + end + + private + + def subscribe_events(logger, config, ignore) + ActiveSupport::Notifications.subscribe(/\.forest_admin$/) do |*args| + event = ActiveSupport::Notifications::Event.new(*args) + short = event.name.delete_suffix('.forest_admin') + next if ignore.include?(short) + next if event.duration < config[:slow_threshold_ms] + + write(logger, config, name: event.name, duration_ms: event.duration, + payload: event.payload.except(:exception, :exception_object), + error: event.payload[:exception_object]) + end + end + + # Scopes SQL logging to Forest requests (start fires before any nested SQL) + # and flushes the per-request SQL summary on finish for the off/medium tiers. + def subscribe_request_scope(logger, config) + ActiveSupport::Notifications.subscribe('request.forest_admin', RequestScope.new(logger, config)) + end + + def subscribe_sql(logger, config) + full = config[:sql_level].to_s == 'full' + + ActiveSupport::Notifications.subscribe('sql.active_record') do |*args| + event = ActiveSupport::Notifications::Event.new(*args) + next unless Thread.current[:forest_in_request] + next if event.payload[:name] == 'SCHEMA' || event.payload[:cached] + + if full + write(logger, config, name: 'sql.active_record', duration_ms: event.duration, + payload: { name: event.payload[:name], sql: event.payload[:sql].to_s.squish }) + else + acc = (Thread.current[:forest_sql_acc] ||= {}) + key = if config[:sql_level].to_s == 'medium' + event.payload[:sql].to_s.squish[0, config[:sql_truncate]] + else + event.payload[:name] || 'SQL' + end + entry = (acc[key] ||= { count: 0, ms: 0.0 }) + entry[:count] += 1 + entry[:ms] += event.duration + end + end + end + + # Emits one line in the configured format, tagged with the source so a mixed + # stream stays filterable. + def write(logger, config, event) + request_id = Thread.current[:forest_request_id] + name = event[:name] + duration_ms = event[:duration_ms] + payload = event[:payload] || {} + error = event[:error] + + if config[:format].to_s == 'text' + line = "#{name.to_s.ljust(28)} #{duration_ms.round(2)}ms [#{request_id || "-"}] #{payload.inspect}" + line += " ERROR=#{error.class}: #{error.message}" if error + logger.info(line) + else + data = { source: config[:source], event: name, duration_ms: duration_ms.round(2), + request_id: request_id }.merge(payload) + data[:error] = "#{error.class}: #{error.message}" if error + logger.info(JSON.generate(data.compact)) + end + end + + def build_logger(output) + case output + when Logger then output + when String then formatted(Logger.new(log_path(output), 5, 100 * 1024 * 1024)) + else formatted(Logger.new($stdout)) + end + end + + # We pass fully-formatted messages (a JSON string or a text line), so strip + # Logger's default severity/timestamp prefix. + def formatted(logger) + logger.formatter = proc { |_severity, _time, _progname, msg| "#{msg}\n" } + logger + end + + def log_path(path) + defined?(Rails) && Rails.respond_to?(:root) && Rails.root ? Rails.root.join(path) : path + end + + def insert_middleware(app) + return unless app&.config&.middleware + + app.config.middleware.insert_after(ActionDispatch::RequestId, RequestTagger) + rescue StandardError + app.config.middleware.use(RequestTagger) + end + end + + # Object subscriber: needs start/finish (not just finish), to bracket the + # request's SQL and flush its summary. + class RequestScope + def initialize(logger, config) + @logger = logger + @config = config + @summarise = config[:sql_level].to_s != 'full' + end + + def start(_name, _id, _payload) + Thread.current[:forest_in_request] = true + Thread.current[:forest_sql_acc] = {} if @summarise + end + + def finish(_name, _id, _payload) + Thread.current[:forest_in_request] = false + acc = Thread.current[:forest_sql_acc] + Thread.current[:forest_sql_acc] = nil + flush(acc) if @summarise && acc && !acc.empty? + end + + private + + def flush(acc) + ranked = acc.sort_by { |_key, v| -v[:count] } + top = ranked.first(@config[:sql_summary_top]) + payload = { + queries: acc.values.sum { |v| v[:count] }, + dropped: [ranked.size - top.size, 0].max, + breakdown: top.map { |key, v| { query: key, count: v[:count], ms: v[:ms].round(2) } } + } + total_ms = acc.values.sum { |v| v[:ms] } + Monitoring.send(:write, @logger, @config, name: 'sql.summary', duration_ms: total_ms, payload: payload) + end + end + end +end diff --git a/packages/forest_admin_rails/lib/generators/forest_admin_rails/templates/initializers/config.rb b/packages/forest_admin_rails/lib/generators/forest_admin_rails/templates/initializers/config.rb index f21565868..585600a49 100644 --- a/packages/forest_admin_rails/lib/generators/forest_admin_rails/templates/initializers/config.rb +++ b/packages/forest_admin_rails/lib/generators/forest_admin_rails/templates/initializers/config.rb @@ -1,4 +1,16 @@ ForestAdminRails.configure do |config| config.auth_secret = '<%= @auth_secret %>' config.env_secret = '<%= @env_secret %>' + + # Monitoring — log what the agent does (request latency, slow smart fields, + # actions, hooks, SQL). Disabled by default. Uncomment to enable; output is + # JSON on STDOUT (auto-collected by Datadog/CloudWatch/k8s) unless you point + # `output` at a file path or your own Logger. + # config.monitoring = { + # enabled: true, + # output: :stdout, # :stdout | "log/forest_admin_monitoring.log" | a Logger + # format: :json, # :json | :text + # sql_level: 'off', # off | medium | full (full = one line per query; use temporarily) + # slow_threshold_ms: 0 # only log events at least this slow + # } end diff --git a/packages/forest_admin_rails/spec/lib/forest_admin_rails/monitoring_spec.rb b/packages/forest_admin_rails/spec/lib/forest_admin_rails/monitoring_spec.rb new file mode 100644 index 000000000..d686635ba --- /dev/null +++ b/packages/forest_admin_rails/spec/lib/forest_admin_rails/monitoring_spec.rb @@ -0,0 +1,86 @@ +require 'spec_helper' +require 'json' +require 'stringio' +require 'active_support' +require 'active_support/core_ext/string/filters' +require_relative '../../../lib/forest_admin_rails/monitoring' + +module ForestAdminRails + RSpec.describe Monitoring do + let(:io) { StringIO.new } + let(:logger) do + Logger.new(io).tap { |l| l.formatter = proc { |_s, _t, _p, msg| "#{msg}\n" } } + end + let(:handles) { [] } + + after { handles.flatten.each { |h| ActiveSupport::Notifications.unsubscribe(h) } } + + def lines + io.string.each_line.map(&:strip).reject(&:empty?) + end + + it 'logs a forest event as tagged JSON with duration and payload' do + handles.concat(described_class.subscribe!(logger, format: :json)) + + ActiveSupport::Notifications.instrument('request.forest_admin', collection: 'user', id: '42') { nil } + + data = JSON.parse(lines.grep(/request\.forest_admin/).first) + expect(data).to include( + 'source' => 'forest_admin', 'event' => 'request.forest_admin', 'collection' => 'user', 'id' => '42' + ) + expect(data['duration_ms']).to be >= 0 + end + + it 'drops ignored events (hooks by default)' do + handles.concat(described_class.subscribe!(logger)) + + ActiveSupport::Notifications.instrument('hook.forest_admin', collection: 'user', operation: 'Create') { nil } + + expect(io.string).not_to include('hook.forest_admin') + end + + it 'attaches the exception when an operation raises' do + handles.concat(described_class.subscribe!(logger, format: :json)) + + expect do + ActiveSupport::Notifications.instrument('action.forest_admin', collection: 'user', action: 'ban') { raise 'boom' } + end.to raise_error('boom') + + data = JSON.parse(lines.grep(/action\.forest_admin/).first) + expect(data['error']).to eq('RuntimeError: boom') + end + + it 'summarises SQL per request grouped by query name (off level)' do + handles.concat(described_class.subscribe!(logger, sql_level: 'off')) + + ActiveSupport::Notifications.instrument('request.forest_admin', collection: 'user') do + 3.times { ActiveSupport::Notifications.instrument('sql.active_record', name: 'Subscription Load', sql: 'SELECT 1') { nil } } + ActiveSupport::Notifications.instrument('sql.active_record', name: 'SCHEMA', sql: 'x') { nil } # skipped + ActiveSupport::Notifications.instrument('sql.active_record', name: 'User Load', sql: 'SELECT 2', cached: true) { nil } # skipped + end + + summary = JSON.parse(lines.grep(/sql\.summary/).first) + expect(summary['queries']).to eq(3) + expect(summary['breakdown']).to include(include('query' => 'Subscription Load', 'count' => 3)) + end + + it 'logs each query in full mode instead of summarising' do + handles.concat(described_class.subscribe!(logger, sql_level: 'full')) + + ActiveSupport::Notifications.instrument('request.forest_admin', collection: 'user') do + 2.times { ActiveSupport::Notifications.instrument('sql.active_record', name: 'User Load', sql: 'SELECT 1') { nil } } + end + + expect(lines.grep(/sql\.active_record/).size).to eq(2) + expect(io.string).not_to include('sql.summary') + end + + it 'does not log SQL that runs outside a forest request' do + handles.concat(described_class.subscribe!(logger, sql_level: 'full')) + + ActiveSupport::Notifications.instrument('sql.active_record', name: 'User Load', sql: 'SELECT 1') { nil } + + expect(io.string).not_to include('sql.active_record') + end + end +end