feat(reports): align bot count strategies

This commit is contained in:
2026-06-07 00:23:11 +08:00
parent c9a86793ce
commit aa159f6296
3 changed files with 82 additions and 12 deletions
+8 -5
View File
@@ -1,6 +1,6 @@
# Chatwoot Parity Development Plan
Updated: 2026-06-06
Updated: 2026-06-07
## Goal
@@ -49,11 +49,11 @@ Hermes task landing checklist:
## Current Baseline
- Current tracking checkpoint: 2026-06-06 reporting live rollup parity checkpoint, prepared as `feat(reports): add live rollup upserts`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(reports): add live rollup upserts`.
- Latest documentation/tooling checkpoint: this tracker update records P3.44 reporting live additive rollup parity plus the committed P3.43/P3.42/P3.41/P3.40 reports baseline and landed parity tracker history; this document is the active follow-up plan and supersedes `.hermes/plans/*`.
- Current tracking checkpoint: 2026-06-07 report bot raw-count strategy parity checkpoint, prepared as `feat(reports): align bot count strategies`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(reports): align bot count strategies`.
- Latest documentation/tooling checkpoint: this tracker update records P3.45 report bot raw-count strategy parity plus the committed P3.44/P3.43/P3.42/P3.41/P3.40 reports baseline and landed parity tracker history; this document is the active follow-up plan and supersedes `.hermes/plans/*`.
- Plan landing status: complete for the current known Hermes plans and user-confirmed scope. Future work should update this file directly instead of opening a parallel tracker.
- Worktree status at this implementation checkpoint: live reporting-event rollups from `reference/chatwoot/app/listeners/reporting_event_listener.rb`, `ReportingEvents::RollupService`, and `ReportingEvents::EventMetricRegistry` are aligned for `AnalyticsService.RecordEvent`: after the raw event is persisted, GoChat builds the same account/agent/inbox rollup rows, skips disabled/invalid account reporting timezones, ignores unknown raw metrics, and additively upserts count/sum/business-hours values into the unique rollup row like Chatwoot `upsert_all` `on_duplicate`. P3.43 rollup/backfill timezone parity, P3.42 timeseries timezone parity, P3.41 CSV/download parity, and P3.40 raw JSON/date parity for report, live-report, and summary-report endpoints remain implemented. Profile MFA, Facebook callbacks, account WhatsApp calls, onboarding, notification scoping, API v2 year-in-review, and WhatsApp calling toggle parity remain implemented from previous checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
- Worktree status at this implementation checkpoint: report bot metric count strategies from `reference/chatwoot/app/services/reports/raw_data_source.rb`, `ReportMetricRegistry`, and `spec/builders/v2/reports/timeseries/report_builder_spec.rb` are aligned for v2 report timeseries. `bot_handoffs_count` now counts distinct non-null `conversation_id` values per bucket, and `bot_resolutions_count` now excludes conversations that also have a bot handoff in the same scoped range before bucketing. P3.44 live additive rollup parity, P3.43 rollup/backfill timezone parity, P3.42 timeseries timezone parity, P3.41 CSV/download parity, and P3.40 raw JSON/date parity for report, live-report, and summary-report endpoints remain implemented. Profile MFA, Facebook callbacks, account WhatsApp calls, onboarding, notification scoping, API v2 year-in-review, and WhatsApp calling toggle parity remain implemented from previous checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
- Next executable implementation checkpoint: continue Phase 2/3 drift audit for deeper report data-source/metric-builder drift or the next reused-frontend mismatch, Phase 6 placeholder audit, or B12 live smoke from fresh reference/smoke evidence.
- `go test ./...` passes when run outside the restricted socket sandbox; focused Facebook callback handler/router/route-parity tests pass in the sandbox.
- Route dump succeeds with `967` registered routes after profile MFA route tracking.
@@ -140,6 +140,7 @@ This table is the shortest authoritative handoff view. If an older lower section
| Priority | Workstream | Current state | Next checkpoint | Commit close rule |
| --- | --- | --- | --- | --- |
| 0 | P3.45 report bot raw-count strategy parity | Implemented for reused v2 reports chart clients: GoChat now follows inspected Chatwoot `Reports::RawDataSource#reporting_event_count_scope`, `Reports::ReportMetricRegistry`, and v2 timeseries specs for bot metrics. `bot_handoffs_count` uses the `distinct_conversation` strategy and ignores nil conversation IDs, so duplicate handoff rows for the same conversation count once per bucket. `bot_resolutions_count` uses the `exclude_bot_handoffs` strategy and removes conversations that also have a bot handoff in the same scoped range before timeseries bucketing. Dimension scopes reuse the existing account/inbox/agent/team/label filters. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for aggregate-value endpoints beyond the current timeseries route, data-source scope behavior that differs for team/label dimensions, or future `ReportMetricRegistry` count strategies. | Focused Analytics service bot-timeseries tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
| 0 | P3.44 reporting live additive rollup parity | Implemented for live reporting-event freshness: GoChat now follows inspected Chatwoot `ReportingEventListener#safe_rollup`, `ReportingEvents::RollupService#upsert_rollups`, and `ReportingEvents::EventMetricRegistry` behavior when `AnalyticsService.RecordEvent` persists a raw event. Valid account `reporting_timezone` gates the live write, event `created_at` determines the local rollup date, account/agent/inbox dimensions are emitted only when present, known event metrics expand to Chatwoot rollup metrics, unknown raw events emit no rollup rows, and conflicts add incoming `count`, `sum_value`, and `sum_value_business_hours` to the existing unique row. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for dispatcher/listener event creation paths that bypass `AnalyticsService.RecordEvent`, Sidekiq retry semantics beyond swallowed live-rollup errors, or report metric registry additions beyond the inspected reference event registry. | Focused Reporting live rollup/repository tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
| 0 | P3.43 reporting rollup/backfill timezone parity | Implemented for reused report data freshness paths: GoChat now follows inspected Chatwoot `ReportingEvents::RollupService`, `ReportingEvents::BackfillService`, reporting-event service specs, and `Account#reporting_timezone` validation behavior by persisting/serializing `reporting_timezone`, treating blank or invalid values as a rollup-write skip, converting local report dates into UTC windows through the account reporting timezone, querying reporting events by `created_at >= start AND created_at < end`, skipping nil agent/inbox dimensions instead of falling back to the account id, zeroing count-metric sum fields, and counting distinct `conversation_bot_handoff` conversations per account/agent/inbox dimension. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for ActiveSupport timezone-name aliases beyond IANA names, data-source rollup read feature flags, or report metric registry drift beyond the inspected rollup/backfill contract. | Focused Reporting rollup/backfill/service tests, focused Analytics/Account handler tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
| 0 | P3.42 API v2 reports timeseries timezone parity | Implemented for reused dashboard chart clients: `GET /api/v2/accounts/:account_id/reports` now follows inspected Chatwoot `ReportsController#index`, `V2::Reports::Timeseries::BaseTimeseriesBuilder`, `Reports::RawDataSource`, and `TimezoneHelper` behavior by honoring frontend `timezone_offset` for bucket boundaries and returned timestamps. Conversation, message, and reporting-event timeseries now seed zero buckets across the requested range, group by local hour/day/week/month/year starts, preserve data totals while redistributing values across timezone-local buckets, and keep summary-report totals timezone-boundary driven by the frontend `since/until` values. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for ActiveSupport named timezone/DST drift, exact Groupdate range edge cases, rollup-data-source behavior, or metric-specific builder differences beyond the inspected raw timeseries contract. | Focused Analytics handler/service timeseries tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
@@ -267,6 +268,7 @@ This ledger records the committed parity checkpoints that future slices should b
| Commit | Scope | Verification summary | Follow-up state |
| --- | --- | --- | --- |
| `feat(reports): align bot count strategies` | Advances P3.45 report bot raw-count strategy parity by matching Chatwoot `Reports::RawDataSource#reporting_event_count_scope`, `Reports::ReportMetricRegistry`, and v2 report timeseries specs. GoChat now applies `distinct_conversation` for `bot_handoffs_count`, ignores nil handoff conversation IDs, and applies `exclude_bot_handoffs` for `bot_resolutions_count` before bucketing so conversations that both resolve and hand off are not double counted. | `go test ./internal/service -run 'Analytics.*Timeseries\|BotTimeseries\|Reporting.*Rollup\|Backfill' -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.45 moves to Review for current bot metric raw-count evidence; continue Phase 2/3 drift audit for aggregate-value endpoints, report data-source/metric-builder depth, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
| `feat(reports): add live rollup upserts` | Advances P3.44 reporting live additive rollup parity by matching Chatwoot `ReportingEventListener#safe_rollup`, `ReportingEvents::RollupService`, and `ReportingEvents::EventMetricRegistry`. GoChat now applies rollups immediately after `AnalyticsService.RecordEvent` persists the raw reporting event, computes the local rollup date from the account `reporting_timezone`, emits only known event-registry metrics, and additively upserts unique rollup rows so repeated live events increment existing `count`, `sum_value`, and `sum_value_business_hours`. | `go test ./internal/service ./internal/repository -run 'Reporting.*Rollup\|Backfill\|Analytics\|ReportingEventsRollup' -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.44 moves to Review for current live-rollup evidence; continue Phase 2/3 drift audit for event-listener paths bypassing `AnalyticsService.RecordEvent`, report data-source/metric-builder depth, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
| `feat(reports): align rollup timezones` | Advances P3.43 reporting rollup/backfill timezone parity by matching Chatwoot `ReportingEvents::RollupService`, `ReportingEvents::BackfillService`, reporting event rollup/backfill specs, `ReportingEvents::EventMetricRegistry`, and `Account#reporting_timezone`. GoChat now adds account `reporting_timezone` storage/serialization, skips blank/invalid rollup writes, computes UTC windows from local reporting dates, uses `reporting_events.created_at` half-open ranges, skips nil agent/inbox dimensions, zeroes count-metric sum columns, and deduplicates `conversation_bot_handoff` by distinct conversation per dimension. | `go test ./internal/service ./internal/handler/api/v1 -run 'Reporting.*Rollup\|Backfill\|Analytics\|Account' -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.43 moves to Review for current rollup/backfill timezone evidence; continue Phase 2/3 drift audit for per-event additive rollup wiring, ActiveSupport timezone alias depth, rollup data-source feature flags, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
| `feat(reports): align v2 timeseries timezones` | Advances P3.42 API v2 reports timeseries timezone parity by matching Chatwoot `Api::V2::Accounts::ReportsController#index`, `V2::Reports::Timeseries::BaseTimeseriesBuilder`, `Reports::RawDataSource`, `TimezoneHelper`, `DateRangeHelper`, controller timezone specs, and reused dashboard `reports.js`. GoChat now parses frontend `timezone_offset` on `/reports`, seeds zero buckets across the range, groups conversation/message/reporting-event timeseries by local hour/day/week/month/year starts, and returns timestamps for the local bucket boundary so timezone offsets redistribute values while preserving totals. | `go test ./internal/handler/api/v1 ./internal/service -run 'Analytics\|Timeseries' -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.42 moves to Review for current timeseries timezone evidence; continue Phase 2/3 drift audit for Groupdate/DST/rollup builder depth, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
@@ -2524,3 +2526,4 @@ Verification milestone gates:
- 2026-06-06: P3.42 API v2 reports timeseries timezone checkpoint prepared as `feat(reports): align v2 timeseries timezones`; audited Chatwoot report index controller, Timeseries builder, RawDataSource, TimezoneHelper, DateRangeHelper, controller specs, and reused dashboard reports client. GoChat `/reports` timeseries now honors frontend `timezone_offset` for local hour/day/week/month/year bucket starts, returns local bucket-boundary timestamps, and seeds zero-value buckets across the requested range while preserving totals. Focused Analytics/Timeseries tests, escalated full `go test ./...`, and `git diff --check` passed. No route artifacts change.
- 2026-06-06: P3.43 reporting rollup/backfill timezone checkpoint prepared as `feat(reports): align rollup timezones`; audited Chatwoot `ReportingEvents::RollupService`, `ReportingEvents::BackfillService`, rollup/backfill specs, event metric registry, and `Account#reporting_timezone`. GoChat now stores and serializes `reporting_timezone`, skips blank/invalid rollups, computes local-date UTC windows through account reporting timezone, filters reporting events by `created_at` half-open ranges, skips nil agent/inbox dimensions, preserves zero sum fields for count metrics, and deduplicates bot handoffs by distinct conversation per account/agent/inbox. Focused reporting rollup/backfill/service and analytics/account handler tests, escalated full `go test ./...`, and `git diff --check` passed. No route artifacts change.
- 2026-06-06: P3.44 reporting live additive rollup checkpoint prepared as `feat(reports): add live rollup upserts`; audited Chatwoot reporting event listener safe-rollup path, rollup service additive `upsert_all`, and event metric registry. GoChat now applies live rollups from `AnalyticsService.RecordEvent` after raw event persistence, uses account reporting timezone for the event-created local date, ignores unknown raw metrics, and additively upserts existing rollup rows instead of waiting for lazy daily recompute. Focused reporting live rollup/repository tests, escalated full `go test ./...`, and `git diff --check` passed. No route artifacts change.
- 2026-06-07: P3.45 report bot raw-count strategy checkpoint prepared as `feat(reports): align bot count strategies`; audited Chatwoot `Reports::RawDataSource`, `Reports::ReportMetricRegistry`, and v2 timeseries bot-resolution specs. GoChat `bot_handoffs_count` timeseries now counts distinct non-null conversations per bucket, while `bot_resolutions_count` excludes conversations that also handed off in the scoped range before bucketing. Focused Analytics timeseries tests, escalated full `go test ./...`, and `git diff --check` passed. No route artifacts change.
+41
View File
@@ -200,3 +200,44 @@ func TestAnalyticsTimeseriesAndRollupWorker(t *testing.T) {
require.NoError(t, db.Model(&model.ReportingEventsRollup{}).Where("account_id = ? AND date = ?", account.ID, since).Count(&rollupCount).Error)
assert.Greater(t, rollupCount, int64(0))
}
func TestAnalyticsBotTimeseriesUsesRawCountStrategies(t *testing.T) {
db, svc, account, inbox, contact, user, _ := setupAnalyticsP513Test(t)
since := time.Date(2026, 6, 4, 0, 0, 0, 0, time.UTC)
until := since.Add(24 * time.Hour)
resolvedOnly := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
doubleCounted := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
handoffOnly := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
require.NoError(t, db.Create(resolvedOnly).Error)
require.NoError(t, db.Create(doubleCounted).Error)
require.NoError(t, db.Create(handoffOnly).Error)
seed := func(name string, conversationID *uint, minute int) {
require.NoError(t, db.Create(&model.ReportingEvent{
Base: model.Base{CreatedAt: since.Add(time.Duration(minute) * time.Minute)},
AccountID: account.ID,
Name: name,
ConversationID: conversationID,
InboxID: &inbox.ID,
UserID: &user.ID,
EventStartTime: since,
EventEndTime: since.Add(time.Duration(minute) * time.Minute),
}).Error)
}
seed("conversation_bot_resolved", &resolvedOnly.ID, 10)
seed("conversation_bot_resolved", &doubleCounted.ID, 20)
seed("conversation_bot_handoff", &doubleCounted.ID, 30)
seed("conversation_bot_handoff", &handoffOnly.ID, 40)
seed("conversation_bot_handoff", &handoffOnly.ID, 50)
seed("conversation_bot_handoff", nil, 60)
resolutionPoints, err := svc.GetTimeseries(context.Background(), account.ID, "bot_resolutions_count", since, until, "account", 0, "day", 0, false)
require.NoError(t, err)
require.Len(t, resolutionPoints, 1)
assert.Equal(t, float64(1), resolutionPoints[0].Value)
handoffPoints, err := svc.GetTimeseries(context.Background(), account.ID, "bot_handoffs_count", since, until, "account", 0, "day", 0, false)
require.NoError(t, err)
require.Len(t, handoffPoints, 1)
assert.Equal(t, float64(2), handoffPoints[0].Value)
}
+33 -7
View File
@@ -71,15 +71,15 @@ func (s *AnalyticsService) GetTimeseries(ctx context.Context, accountID uint, me
case "outgoing_messages_count":
return s.messageCountTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, model.MessageTypeOutgoing)
case "avg_first_response_time":
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, []string{model.MetricNameFirstResponse}, true, businessHours)
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, []string{model.MetricNameFirstResponse}, true, businessHours, "")
case "avg_resolution_time":
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, []string{"conversation_resolved", model.MetricNameResolutionTime}, true, businessHours)
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, []string{"conversation_resolved", model.MetricNameResolutionTime}, true, businessHours, "")
case "reply_time":
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, []string{model.MetricNameReplyTime}, true, businessHours)
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, []string{model.MetricNameReplyTime}, true, businessHours, "")
case "bot_resolutions_count":
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, []string{"conversation_bot_resolved", model.MetricNameBotResolutionsCount}, false, businessHours)
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, []string{"conversation_bot_resolved", model.MetricNameBotResolutionsCount}, false, businessHours, "exclude_bot_handoffs")
case "bot_handoffs_count":
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, []string{"conversation_bot_handoff", model.MetricNameBotHandoffsCount}, false, businessHours)
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, []string{"conversation_bot_handoff", model.MetricNameBotHandoffsCount}, false, businessHours, "distinct_conversation")
default:
return nil, fmt.Errorf("unsupported report metric %q", metric)
}
@@ -161,7 +161,7 @@ func (s *AnalyticsService) messageCountTimeseries(ctx context.Context, accountID
return sortedTimeseries(buckets), nil
}
func (s *AnalyticsService) eventTimeseries(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, groupBy string, loc *time.Location, names []string, average bool, businessHours bool) ([]AnalyticsTimeseriesPoint, error) {
func (s *AnalyticsService) eventTimeseries(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, groupBy string, loc *time.Location, names []string, average bool, businessHours bool, countStrategy string) ([]AnalyticsTimeseriesPoint, error) {
db, err := s.analyticsDB()
if err != nil {
return nil, err
@@ -169,12 +169,26 @@ func (s *AnalyticsService) eventTimeseries(ctx context.Context, accountID uint,
var events []model.ReportingEvent
q := db.WithContext(ctx).Model(&model.ReportingEvent{}).Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ?", accountID, names, since, until)
q = applyEventDimension(q, reportType, id)
if !average && countStrategy == "exclude_bot_handoffs" {
var handoffIDs []uint
handoffQ := db.WithContext(ctx).Model(&model.ReportingEvent{}).
Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ? AND conversation_id IS NOT NULL", accountID, []string{"conversation_bot_handoff", model.MetricNameBotHandoffsCount}, since, until)
handoffQ = applyEventDimension(handoffQ, reportType, id)
if err := handoffQ.Distinct("conversation_id").Pluck("conversation_id", &handoffIDs).Error; err != nil {
return nil, err
}
if len(handoffIDs) > 0 {
q = q.Where("conversation_id NOT IN ?", handoffIDs)
}
}
if err := q.Find(&events).Error; err != nil {
return nil, err
}
buckets := seedTimeseriesBuckets(since, until, groupBy, loc)
distinctConversationsByBucket := map[time.Time]map[uint]struct{}{}
for _, event := range events {
point := ensureTimeseriesBucket(buckets, bucketStart(event.CreatedAt, groupBy, loc))
bucket := bucketStart(event.CreatedAt, groupBy, loc)
point := ensureTimeseriesBucket(buckets, bucket)
value := event.Value
if businessHours {
value = event.ValueInBusinessHours
@@ -182,6 +196,18 @@ func (s *AnalyticsService) eventTimeseries(ctx context.Context, accountID uint,
if average {
point.Value += value
point.Count++
} else if countStrategy == "distinct_conversation" {
if event.ConversationID == nil {
continue
}
if distinctConversationsByBucket[bucket] == nil {
distinctConversationsByBucket[bucket] = map[uint]struct{}{}
}
if _, exists := distinctConversationsByBucket[bucket][*event.ConversationID]; exists {
continue
}
distinctConversationsByBucket[bucket][*event.ConversationID] = struct{}{}
point.Value++
} else {
point.Value++
}