From 911f211427bc23726691a956eb69c7a15a160e4e Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 13 Mar 2026 10:53:29 +0530 Subject: [PATCH] feat: add metric registry mapping events to rollup columns --- .../reporting_events/metric_registry.rb | 127 +++++++++++ .../reporting_events/metric_registry_spec.rb | 215 ++++++++++++++++++ 2 files changed, 342 insertions(+) create mode 100644 app/services/reporting_events/metric_registry.rb create mode 100644 spec/services/reporting_events/metric_registry_spec.rb diff --git a/app/services/reporting_events/metric_registry.rb b/app/services/reporting_events/metric_registry.rb new file mode 100644 index 000000000..ea208d6cf --- /dev/null +++ b/app/services/reporting_events/metric_registry.rb @@ -0,0 +1,127 @@ +# Raw reporting events and rollup rows do not share a single metric namespace; this registry keeps write and read paths aligned. +module ReportingEvents::MetricRegistry + SUMMARY_METRICS = { + resolutions_count: :resolved_conversations_count, + avg_resolution_time: :avg_resolution_time, + avg_first_response_time: :avg_first_response_time, + reply_time: :avg_reply_time + }.freeze + + EVENT_METRICS = { + 'conversation_resolved' => lambda do |values| + { + resolutions_count: count_metric(values[:count]), + resolution_time: duration_metric(values) + } + end, + 'first_response' => ->(values) { { first_response: duration_metric(values) } }, + 'reply_time' => ->(values) { { reply_time: duration_metric(values) } }, + 'conversation_bot_resolved' => ->(values) { { bot_resolutions_count: count_metric(values[:count]) } }, + 'conversation_bot_handoff' => ->(values) { { bot_handoffs_count: count_metric(values[:count]) } } + }.freeze + + REPORT_METRICS = { + conversations_count: { aggregate: :count }.freeze, + incoming_messages_count: { aggregate: :count }.freeze, + outgoing_messages_count: { aggregate: :count }.freeze, + avg_first_response_time: { + raw_event_name: :first_response, + rollup_metric: :first_response, + aggregate: :average + }.freeze, + avg_resolution_time: { + raw_event_name: :conversation_resolved, + rollup_metric: :resolution_time, + aggregate: :average + }.freeze, + reply_time: { + raw_event_name: :reply_time, + rollup_metric: :reply_time, + aggregate: :average + }.freeze, + resolutions_count: { + raw_event_name: :conversation_resolved, + rollup_metric: :resolutions_count, + aggregate: :count + }.freeze, + bot_resolutions_count: { + raw_event_name: :conversation_bot_resolved, + rollup_metric: :bot_resolutions_count, + aggregate: :count + }.freeze, + bot_handoffs_count: { + raw_event_name: :conversation_bot_handoff, + rollup_metric: :bot_handoffs_count, + aggregate: :count, + raw_count_strategy: :distinct_conversation + }.freeze + }.freeze + + module_function + + def event_metrics_for(event) + return {} if event.blank? + return {} unless EVENT_METRICS.key?(event.name.to_s) + + event_metrics_for_aggregate( + event.name, + count: 1, + sum_value: event.try(:value), + sum_value_business_hours: event.try(:value_in_business_hours) + ) + end + + def event_metrics_for_aggregate(event_name, count:, sum_value:, sum_value_business_hours:) + values = { + count: count.to_i, + sum_value: sum_value.to_f, + sum_value_business_hours: sum_value_business_hours.to_f + } + + EVENT_METRICS[event_name.to_s]&.call(values) || {} + end + + def report_metric(metric) + return if metric.blank? + + REPORT_METRICS[metric.to_sym] + end + + def supported_metric?(metric) + report_metric(metric).present? + end + + def aggregate_for(metric) + report_metric(metric)&.dig(:aggregate) + end + + def rollup_supported_metric?(metric) + rollup_metric_for(metric).present? + end + + def rollup_metric_for(metric) + report_metric(metric)&.dig(:rollup_metric) + end + + def raw_event_name_for(metric) + report_metric(metric)&.dig(:raw_event_name) + end + + def summary_metrics + SUMMARY_METRICS.map do |metric_name, summary_key| + report_metric(metric_name).merge(metric_name: metric_name, summary_key: summary_key) + end + end + + private_class_method def count_metric(count) + { count: count, sum_value: 0, sum_value_business_hours: 0 } + end + + private_class_method def duration_metric(values) + { + count: values[:count], + sum_value: values[:sum_value], + sum_value_business_hours: values[:sum_value_business_hours] + } + end +end diff --git a/spec/services/reporting_events/metric_registry_spec.rb b/spec/services/reporting_events/metric_registry_spec.rb new file mode 100644 index 000000000..3c73a4cb8 --- /dev/null +++ b/spec/services/reporting_events/metric_registry_spec.rb @@ -0,0 +1,215 @@ +require 'rails_helper' + +RSpec.describe ReportingEvents::MetricRegistry do + describe '.event_metrics_for' do + it 'returns the emitted rollup metrics for conversation_resolved' do + event = instance_double(ReportingEvent, name: 'conversation_resolved', value: 120, value_in_business_hours: 45) + + expect(described_class.event_metrics_for(event)).to eq( + resolutions_count: { + count: 1, + sum_value: 0, + sum_value_business_hours: 0 + }, + resolution_time: { + count: 1, + sum_value: 120.0, + sum_value_business_hours: 45.0 + } + ) + end + + it 'returns the emitted rollup metrics for first_response' do + event = instance_double(ReportingEvent, name: 'first_response', value: 80, value_in_business_hours: 20) + + expect(described_class.event_metrics_for(event)).to eq( + first_response: { + count: 1, + sum_value: 80.0, + sum_value_business_hours: 20.0 + } + ) + end + + it 'returns the emitted rollup metrics for reply_time' do + event = instance_double(ReportingEvent, name: 'reply_time', value: 40, value_in_business_hours: 15) + + expect(described_class.event_metrics_for(event)).to eq( + reply_time: { + count: 1, + sum_value: 40.0, + sum_value_business_hours: 15.0 + } + ) + end + + it 'returns the emitted rollup metrics for conversation_bot_resolved' do + event = instance_double(ReportingEvent, name: 'conversation_bot_resolved') + + expect(described_class.event_metrics_for(event)).to eq( + bot_resolutions_count: { + count: 1, + sum_value: 0, + sum_value_business_hours: 0 + } + ) + end + + it 'returns the emitted rollup metrics for conversation_bot_handoff' do + event = instance_double(ReportingEvent, name: 'conversation_bot_handoff') + + expect(described_class.event_metrics_for(event)).to eq( + bot_handoffs_count: { + count: 1, + sum_value: 0, + sum_value_business_hours: 0 + } + ) + end + + it 'returns an empty hash for unsupported events' do + event = instance_double(ReportingEvent, name: 'conversation_created') + + expect(described_class.event_metrics_for(event)).to eq({}) + end + end + + describe '.event_metrics_for_aggregate' do + it 'returns aggregated rollup metrics for conversation_resolved groups' do + expect( + described_class.event_metrics_for_aggregate( + 'conversation_resolved', + count: 3, + sum_value: 420, + sum_value_business_hours: 210 + ) + ).to eq( + resolutions_count: { + count: 3, + sum_value: 0, + sum_value_business_hours: 0 + }, + resolution_time: { + count: 3, + sum_value: 420.0, + sum_value_business_hours: 210.0 + } + ) + end + + it 'returns an empty hash for unsupported grouped events' do + expect( + described_class.event_metrics_for_aggregate( + 'conversation_created', + count: 2, + sum_value: 100, + sum_value_business_hours: 50 + ) + ).to eq({}) + end + end + + describe '.report_metric' do + it 'returns the definition for raw-only count metrics' do + expect(described_class.report_metric(:conversations_count)).to eq( + aggregate: :count + ) + end + + it 'returns the definition for avg_resolution_time' do + expect(described_class.report_metric(:avg_resolution_time)).to eq( + raw_event_name: :conversation_resolved, + rollup_metric: :resolution_time, + aggregate: :average + ) + end + + it 'locks the distinct conversation strategy for bot_handoffs_count' do + expect(described_class.report_metric(:bot_handoffs_count)).to eq( + raw_event_name: :conversation_bot_handoff, + rollup_metric: :bot_handoffs_count, + aggregate: :count, + raw_count_strategy: :distinct_conversation + ) + end + + it 'returns nil for unsupported metrics' do + expect(described_class.report_metric(:unknown_metric)).to be_nil + end + end + + describe '.supported_metric?' do + it 'returns true for supported raw-only metrics' do + expect(described_class.supported_metric?(:conversations_count)).to be(true) + end + + it 'returns false for unsupported metrics' do + expect(described_class.supported_metric?(:unknown_metric)).to be(false) + end + end + + describe '.aggregate_for' do + it 'returns the aggregate type for a supported metric' do + expect(described_class.aggregate_for(:avg_first_response_time)).to eq(:average) + end + end + + describe '.rollup_supported_metric?' do + it 'returns true for rollup-backed metrics' do + expect(described_class.rollup_supported_metric?(:reply_time)).to be(true) + end + + it 'returns false for unsupported metrics' do + expect(described_class.rollup_supported_metric?(:conversations_count)).to be(false) + end + end + + describe '.rollup_metric_for' do + it 'returns the rollup metric name' do + expect(described_class.rollup_metric_for(:avg_first_response_time)).to eq(:first_response) + end + end + + describe '.raw_event_name_for' do + it 'returns the raw event name' do + expect(described_class.raw_event_name_for(:bot_resolutions_count)).to eq(:conversation_bot_resolved) + end + end + + describe '.summary_metrics' do + it 'returns the registry-backed summary metric definitions' do + expect(described_class.summary_metrics).to eq( + [ + { + metric_name: :resolutions_count, + summary_key: :resolved_conversations_count, + aggregate: :count, + raw_event_name: :conversation_resolved, + rollup_metric: :resolutions_count + }, + { + metric_name: :avg_resolution_time, + summary_key: :avg_resolution_time, + aggregate: :average, + raw_event_name: :conversation_resolved, + rollup_metric: :resolution_time + }, + { + metric_name: :avg_first_response_time, + summary_key: :avg_first_response_time, + aggregate: :average, + raw_event_name: :first_response, + rollup_metric: :first_response + }, + { + metric_name: :reply_time, + summary_key: :avg_reply_time, + aggregate: :average, + raw_event_name: :reply_time, + rollup_metric: :reply_time + } + ] + ) + end + end +end