## PR2: Report builder refactor — DataSource abstraction
The existing report builders (timeseries + summary) had their SQL
queries inlined — each builder constructed its own scopes, groupings,
and aggregations directly. This made it hard to swap the underlying data
source without duplicating builder logic.
This PR extracts all raw-event querying into a `Reports::RawDataSource`
behind a `Reports::DataSource` factory. Builders now call
`data_source.timeseries`, `.aggregate`, or `.summary` instead of
constructing queries themselves. Behavior is identical —
`DataSource.for(...)` returns `RawDataSource` in all cases today.
The timeseries path had two separate builders (`CountReportBuilder`,
`AverageReportBuilder`) that were selected via a metric-name case
statement in `Conversations::BaseReportBuilder`. These are replaced by a
single `ReportBuilder` that delegates to the data source. The metric
type (count vs average) is now decided inside the data source, not the
builder.
Summary builders similarly moved their inline SQL into
`RawDataSource#summary`, which returns a unified hash keyed by dimension
ID.
the rollup read path.
## Flow
### Before
```
ReportsController ──▶ case metric ──▶ AverageReportBuilder ──▶ inline SQL ──▶ DB
└──▶ CountReportBuilder ──▶ inline SQL ──▶ DB
SummaryController ──▶ AgentSummaryBuilder ──▶ inline SQL ──▶ DB
└──▶ InboxSummaryBuilder ──▶ inline SQL ──▶ DB
└──▶ TeamSummaryBuilder ──▶ inline SQL ──▶ DB
```
### After
```
ReportsController ──▶ ReportBuilder ──┐
├──▶ DataSource.for ──▶ RawDataSource ──▶ DB
SummaryController ──▶ SummaryBuilder ──┘
```
### Expected (after rollup read path)
```
ReportsController ──▶ ReportBuilder ──┐
├──▶ DataSource.for ──▶ RawDataSource ──▶ reporting_events
SummaryController ──▶ SummaryBuilder ──┘ └──▶ RollupDataSource ──▶ reporting_events_rollups
```
### What changed
- `Reports::DataSource` factory + `Reports::RawDataSource`
- `TimezoneHelper#timezone_name_from_params` — prefers IANA name, falls
back to offset
- Unified `Timeseries::ReportBuilder` replaces `CountReportBuilder` +
`AverageReportBuilder`
- Summary builders delegate to `DataSource` instead of querying directly
### How to test
This is a pure refactor — all existing report pages (Overview, Agent,
Inbox, Label, Team) should produce identical numbers. No feature flag or
new config needed.
---------
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Tanmay Deep Sharma <tanmaydeepsharma21@gmail.com>
Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
121 lines
3.1 KiB
Ruby
121 lines
3.1 KiB
Ruby
module Reports::ReportMetricRegistry
|
|
# Describes one public report metric.
|
|
# name: API-facing metric name requested by reports.
|
|
# aggregate: whether the metric is a count or average.
|
|
# raw_event_name: source reporting_events name for raw queries.
|
|
# rollup_metric: source reporting_events_rollups metric for rollup queries.
|
|
# summary_key: key used when this metric appears in grouped summary responses.
|
|
# raw_count_strategy: optional raw-query counting rule, such as distinct conversations.
|
|
Metric = Data.define(
|
|
:name,
|
|
:aggregate,
|
|
:raw_event_name,
|
|
:rollup_metric,
|
|
:summary_key,
|
|
:raw_count_strategy
|
|
) do
|
|
def initialize(name:, aggregate:, raw_event_name: nil, rollup_metric: nil, summary_key: nil, raw_count_strategy: nil) # rubocop:disable Metrics/ParameterLists
|
|
super
|
|
end
|
|
|
|
def average?
|
|
aggregate == :average
|
|
end
|
|
|
|
def count?
|
|
aggregate == :count
|
|
end
|
|
|
|
def rollup_supported?
|
|
rollup_metric.present?
|
|
end
|
|
|
|
def summary?
|
|
summary_key.present?
|
|
end
|
|
end
|
|
|
|
METRICS = {
|
|
conversations_count: Metric.new(
|
|
name: :conversations_count,
|
|
aggregate: :count
|
|
),
|
|
incoming_messages_count: Metric.new(
|
|
name: :incoming_messages_count,
|
|
aggregate: :count
|
|
),
|
|
outgoing_messages_count: Metric.new(
|
|
name: :outgoing_messages_count,
|
|
aggregate: :count
|
|
),
|
|
avg_first_response_time: Metric.new(
|
|
name: :avg_first_response_time,
|
|
aggregate: :average,
|
|
raw_event_name: :first_response,
|
|
rollup_metric: :first_response,
|
|
summary_key: :avg_first_response_time
|
|
),
|
|
avg_resolution_time: Metric.new(
|
|
name: :avg_resolution_time,
|
|
aggregate: :average,
|
|
raw_event_name: :conversation_resolved,
|
|
rollup_metric: :resolution_time,
|
|
summary_key: :avg_resolution_time
|
|
),
|
|
reply_time: Metric.new(
|
|
name: :reply_time,
|
|
aggregate: :average,
|
|
raw_event_name: :reply_time,
|
|
rollup_metric: :reply_time,
|
|
summary_key: :avg_reply_time
|
|
),
|
|
resolutions_count: Metric.new(
|
|
name: :resolutions_count,
|
|
aggregate: :count,
|
|
raw_event_name: :conversation_resolved,
|
|
rollup_metric: :resolutions_count,
|
|
summary_key: :resolved_conversations_count
|
|
),
|
|
bot_resolutions_count: Metric.new(
|
|
name: :bot_resolutions_count,
|
|
aggregate: :count,
|
|
raw_event_name: :conversation_bot_resolved,
|
|
rollup_metric: :bot_resolutions_count
|
|
),
|
|
bot_handoffs_count: Metric.new(
|
|
name: :bot_handoffs_count,
|
|
aggregate: :count,
|
|
raw_event_name: :conversation_bot_handoff,
|
|
rollup_metric: :bot_handoffs_count,
|
|
raw_count_strategy: :distinct_conversation
|
|
)
|
|
}.freeze
|
|
|
|
SUMMARY_METRIC_NAMES = %i[
|
|
resolutions_count
|
|
avg_resolution_time
|
|
avg_first_response_time
|
|
reply_time
|
|
].freeze
|
|
|
|
module_function
|
|
|
|
def fetch(name)
|
|
return if name.blank?
|
|
|
|
METRICS[name.to_sym]
|
|
end
|
|
|
|
def supported?(name)
|
|
fetch(name).present?
|
|
end
|
|
|
|
def rollup_supported?(name)
|
|
fetch(name)&.rollup_supported? || false
|
|
end
|
|
|
|
def summary_metrics
|
|
SUMMARY_METRIC_NAMES.map { |metric_name| METRICS.fetch(metric_name) }
|
|
end
|
|
end
|