Files
sure/app/models/concerns/sync_stats/collector.rb
LPW 140ea78b0e Add global sync summary component for all providers (#588)
* Add shared sync statistics collection and provider sync summary UI

- Introduced `SyncStats::Collector` concern to centralize sync statistics logic, including account, transaction, holdings, and health stats collection.
- Added collapsible `ProviderSyncSummary` component for displaying sync summaries across providers.
- Updated syncers (e.g., `LunchflowItem::Syncer`) to use the shared collector methods for consistent stats calculation.
- Added rake tasks under `dev:sync_stats` for testing and development purposes, including fake stats generation with optional issues.
- Enhanced provider-specific views to include sync summaries using the new shared component.

* Refactor `ProviderSyncSummary` to improve maintainability

- Extracted `severity_color_class` to simplify severity-to-CSS mapping.
- Replaced `holdings_label` with `holdings_label_key` for streamlined localization.
- Updated locale file to separate `found` and `processed` translations for clarity.

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Josh Waldrep <joshua.waldrep5+github@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-01-09 19:26:37 +01:00

199 lines
7.1 KiB
Ruby

# frozen_string_literal: true
# SyncStats::Collector provides shared methods for collecting sync statistics
# across different provider syncers.
#
# This concern standardizes the stat collection interface so all providers
# can report consistent sync summaries.
#
# @example Include in a syncer class
# class PlaidItem::Syncer
# include SyncStats::Collector
#
# def perform_sync(sync)
# # ... sync logic ...
# collect_setup_stats(sync, provider_accounts: plaid_item.plaid_accounts)
# collect_transaction_stats(sync, account_ids: account_ids, source: "plaid")
# # ...
# end
# end
#
module SyncStats
module Collector
extend ActiveSupport::Concern
# Collects account setup statistics (total, linked, unlinked counts).
#
# @param sync [Sync] The sync record to update
# @param provider_accounts [ActiveRecord::Relation] The provider accounts (e.g., SimplefinAccount, PlaidAccount)
# @param linked_check [Proc, nil] Optional proc to check if an account is linked. If nil, uses default logic.
# @return [Hash] The setup stats that were collected
def collect_setup_stats(sync, provider_accounts:, linked_check: nil)
return {} unless sync.respond_to?(:sync_stats)
total_accounts = provider_accounts.count
# Count linked accounts - either via custom check or default association check
linked_count = if linked_check
provider_accounts.count { |pa| linked_check.call(pa) }
else
# Default: check for current_account method or account association
provider_accounts.count do |pa|
(pa.respond_to?(:current_account) && pa.current_account.present?) ||
(pa.respond_to?(:account) && pa.account.present?)
end
end
unlinked_count = total_accounts - linked_count
setup_stats = {
"total_accounts" => total_accounts,
"linked_accounts" => linked_count,
"unlinked_accounts" => unlinked_count
}
merge_sync_stats(sync, setup_stats)
setup_stats
end
# Collects transaction statistics (imported, updated, seen, skipped).
#
# @param sync [Sync] The sync record to update
# @param account_ids [Array<String>] The account IDs to count transactions for
# @param source [String] The transaction source (e.g., "simplefin", "plaid")
# @param window_start [Time, nil] Start of the sync window (defaults to sync.created_at or 30 minutes ago)
# @param window_end [Time, nil] End of the sync window (defaults to Time.current)
# @return [Hash] The transaction stats that were collected
def collect_transaction_stats(sync, account_ids:, source:, window_start: nil, window_end: nil)
return {} unless sync.respond_to?(:sync_stats)
return {} if account_ids.empty?
window_start ||= sync.created_at || 30.minutes.ago
window_end ||= Time.current
tx_scope = Entry.where(account_id: account_ids, source: source, entryable_type: "Transaction")
tx_imported = tx_scope.where(created_at: window_start..window_end).count
tx_updated = tx_scope.where(updated_at: window_start..window_end)
.where.not(created_at: window_start..window_end).count
tx_seen = tx_imported + tx_updated
tx_stats = {
"tx_imported" => tx_imported,
"tx_updated" => tx_updated,
"tx_seen" => tx_seen,
"window_start" => window_start.iso8601,
"window_end" => window_end.iso8601
}
merge_sync_stats(sync, tx_stats)
tx_stats
end
# Collects holdings statistics.
#
# @param sync [Sync] The sync record to update
# @param holdings_count [Integer] The number of holdings found/processed
# @param label [String] The label for the stat ("found" or "processed")
# @return [Hash] The holdings stats that were collected
def collect_holdings_stats(sync, holdings_count:, label: "found")
return {} unless sync.respond_to?(:sync_stats)
key = label == "processed" ? "holdings_processed" : "holdings_found"
holdings_stats = { key => holdings_count }
merge_sync_stats(sync, holdings_stats)
holdings_stats
end
# Collects health/error statistics.
#
# @param sync [Sync] The sync record to update
# @param errors [Array<Hash>, nil] Array of error objects with :message and optional :category
# @param rate_limited [Boolean] Whether the sync was rate limited
# @param rate_limited_at [Time, nil] When rate limiting occurred
# @return [Hash] The health stats that were collected
def collect_health_stats(sync, errors: nil, rate_limited: false, rate_limited_at: nil)
return {} unless sync.respond_to?(:sync_stats)
health_stats = {
"import_started" => true
}
if errors.present?
health_stats["errors"] = errors.map do |e|
e.is_a?(Hash) ? e.stringify_keys : { "message" => e.to_s }
end
health_stats["total_errors"] = errors.size
else
health_stats["total_errors"] = 0
end
if rate_limited
health_stats["rate_limited"] = true
health_stats["rate_limited_at"] = rate_limited_at&.iso8601
end
merge_sync_stats(sync, health_stats)
health_stats
end
# Collects data quality warnings and notices.
#
# @param sync [Sync] The sync record to update
# @param warnings [Integer] Number of data warnings
# @param notices [Integer] Number of notices
# @param details [Array<Hash>] Array of detail objects with :message and optional :severity
# @return [Hash] The data quality stats that were collected
def collect_data_quality_stats(sync, warnings: 0, notices: 0, details: [])
return {} unless sync.respond_to?(:sync_stats)
quality_stats = {
"data_warnings" => warnings,
"notices" => notices
}
if details.present?
quality_stats["data_quality_details"] = details.map do |d|
d.is_a?(Hash) ? d.stringify_keys : { "message" => d.to_s, "severity" => "info" }
end
end
merge_sync_stats(sync, quality_stats)
quality_stats
end
# Marks the sync as having started import (used for health indicator).
#
# @param sync [Sync] The sync record to update
def mark_import_started(sync)
return unless sync.respond_to?(:sync_stats)
merge_sync_stats(sync, { "import_started" => true })
end
# Clears previous sync stats (useful at start of sync).
#
# @param sync [Sync] The sync record to update
def clear_sync_stats(sync)
return unless sync.respond_to?(:sync_stats)
sync.update!(sync_stats: { "cleared_at" => Time.current.iso8601 })
end
private
# Merges new stats into the existing sync_stats hash.
#
# @param sync [Sync] The sync record to update
# @param new_stats [Hash] The new stats to merge
def merge_sync_stats(sync, new_stats)
return unless sync.respond_to?(:sync_stats)
existing = sync.sync_stats || {}
sync.update!(sync_stats: existing.merge(new_stats))
rescue => e
Rails.logger.warn("SyncStats::Collector#merge_sync_stats failed: #{e.class} - #{e.message}")
end
end
end