diff --git a/app/controllers/settings/debugs_controller.rb b/app/controllers/settings/debugs_controller.rb
new file mode 100644
index 000000000..aebed5616
--- /dev/null
+++ b/app/controllers/settings/debugs_controller.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+class Settings::DebugsController < Admin::BaseController
+ FILTER_ID_PARAMS = %i[family_id account_id user_id account_provider_id].freeze
+
+ def show
+ filter_params = debug_filters_params
+
+ @start_date = safe_parse_date(filter_params[:start_date])
+ @end_date = safe_parse_date(filter_params[:end_date])
+
+ @breadcrumbs = [
+ [ t("breadcrumbs.home"), root_path ],
+ [ t("settings.debugs.show.page_title"), nil ]
+ ]
+
+ scope = DebugLogEntry.includes(:family, :account, :user, :account_provider).recent
+ scope = scope.with_category(filter_params[:category])
+ scope = scope.with_level(filter_params[:level])
+ scope = scope.with_source(filter_params[:source])
+ scope = scope.with_provider_key(filter_params[:provider_key])
+
+ FILTER_ID_PARAMS.each do |key|
+ value = safe_uuid(filter_params[key])
+ scope = scope.where(key => value) if value.present?
+ end
+
+ scope = scope.where("created_at >= ?", @start_date.beginning_of_day) if @start_date.present?
+ scope = scope.where("created_at < ?", @end_date.next_day.beginning_of_day) if @end_date.present?
+
+ @pagy, @debug_log_entries = pagy(scope, limit: safe_per_page(50))
+ @categories = DebugLogEntry.distinct.order(:category).pluck(:category)
+ @levels = DebugLogEntry::LEVELS
+ @sources = DebugLogEntry.distinct.order(:source).pluck(:source)
+ @provider_keys = DebugLogEntry.where.not(provider_key: [ nil, "" ]).distinct.order(:provider_key).pluck(:provider_key)
+ end
+
+ private
+ def safe_parse_date(value)
+ Date.iso8601(value)
+ rescue ArgumentError, TypeError
+ nil
+ end
+
+ def safe_uuid(value)
+ return if value.blank?
+
+ uuid = value.to_s.strip
+ uuid.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i) ? uuid : nil
+ end
+
+ def debug_filters_params
+ params.permit(:category, :level, :source, :provider_key, :start_date, :end_date, *FILTER_ID_PARAMS)
+ end
+end
diff --git a/app/jobs/debug_log_cleanup_job.rb b/app/jobs/debug_log_cleanup_job.rb
new file mode 100644
index 000000000..3edf46f9f
--- /dev/null
+++ b/app/jobs/debug_log_cleanup_job.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class DebugLogCleanupJob < ApplicationJob
+ queue_as :scheduled
+
+ def perform
+ deleted_count = DebugLogEntry.where(created_at: ...retention_period.ago).delete_all
+ Rails.logger.info("DebugLogCleanupJob: Deleted #{deleted_count} debug log entries older than #{retention_period.inspect}") if deleted_count > 0
+ end
+
+ private
+ def retention_period
+ Rails.application.config.x.debug_log.retention_days.days
+ end
+end
diff --git a/app/models/debug_log_entry.rb b/app/models/debug_log_entry.rb
new file mode 100644
index 000000000..fa2949b89
--- /dev/null
+++ b/app/models/debug_log_entry.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+class DebugLogEntry < ApplicationRecord
+ LEVELS = %w[debug info warn error].freeze
+
+ belongs_to :family, optional: true
+ belongs_to :account, optional: true
+ belongs_to :user, optional: true
+ belongs_to :account_provider, optional: true
+
+ validates :category, :level, :message, :source, presence: true
+ validates :level, inclusion: { in: LEVELS }
+
+ scope :recent, -> { order(created_at: :desc) }
+ scope :with_category, ->(category) { category.present? ? where(category: category) : all }
+ scope :with_level, ->(level) { level.present? ? where(level: level) : all }
+ scope :with_source, ->(source) { source.present? ? where(source: source) : all }
+ scope :with_provider_key, ->(provider_key) { provider_key.present? ? where(provider_key: provider_key) : all }
+
+ class << self
+ def log!(category:, level:, message:, source:, metadata: {}, family: nil, family_id: nil,
+ account: nil, account_id: nil, user: nil, user_id: nil,
+ account_provider: nil, account_provider_id: nil, provider_key: nil, provider: nil)
+ create!(
+ category:,
+ level:,
+ message:,
+ source:,
+ metadata: normalize_metadata(metadata),
+ family: resolve_family(family, family_id, account, account_id, user, user_id, account_provider, account_provider_id),
+ account: resolve_account(account, account_id, account_provider, account_provider_id),
+ user: resolve_user(user, user_id),
+ account_provider: resolve_account_provider(account_provider, account_provider_id),
+ provider_key: normalize_provider_key(provider_key, provider)
+ )
+ end
+
+ def capture(...)
+ log!(...)
+ rescue => e
+ Rails.logger.error("DebugLogEntry.capture failed: #{e.class}: #{e.message}")
+ nil
+ end
+
+ private
+ def normalize_metadata(metadata)
+ return {} if metadata.blank?
+ return metadata.deep_stringify_keys if metadata.respond_to?(:deep_stringify_keys)
+
+ { value: metadata.to_s }
+ end
+
+ def normalize_provider_key(provider_key, provider)
+ return provider_key.to_s if provider_key.present?
+ return if provider.blank?
+
+ provider_name = provider.is_a?(String) || provider.is_a?(Symbol) ? provider.to_s : provider.class.name.demodulize
+ provider_name.to_s.underscore
+ end
+
+ def resolve_family(family, family_id, account, account_id, user, user_id, account_provider, account_provider_id)
+ family ||
+ find_record(Family, family_id) ||
+ resolve_account(account, account_id, account_provider, account_provider_id)&.family ||
+ resolve_user(user, user_id)&.family
+ end
+
+ def resolve_account(account, account_id, account_provider, account_provider_id)
+ account ||
+ find_record(Account, account_id) ||
+ resolve_account_provider(account_provider, account_provider_id)&.account
+ end
+
+ def resolve_user(user, user_id)
+ user || find_record(User, user_id)
+ end
+
+ def resolve_account_provider(account_provider, account_provider_id)
+ account_provider || find_record(AccountProvider, account_provider_id)
+ end
+
+ def find_record(klass, id)
+ return if id.blank?
+
+ klass.find_by(id: id)
+ end
+ end
+end
diff --git a/app/models/security/price/importer.rb b/app/models/security/price/importer.rb
index e0e323ccc..082ef0262 100644
--- a/app/models/security/price/importer.rb
+++ b/app/models/security/price/importer.rb
@@ -196,10 +196,20 @@ class Security::Price::Importer
)
end
- Sentry.capture_exception(MissingSecurityPriceError.new("Could not fetch prices for ticker"), level: :warning) do |scope|
- scope.set_tags(security_id: security.id)
- scope.set_context("security", { id: security.id, start_date: start_date, end_date: end_date })
- end
+ DebugLogEntry.capture(
+ category: "security_price_fetch",
+ level: "warn",
+ message: "Could not fetch prices for ticker",
+ source: self.class.name,
+ provider: security_provider,
+ metadata: {
+ security_id: security.id,
+ ticker: security.ticker,
+ start_date: start_date,
+ end_date: end_date,
+ provider_error: error_message
+ }
+ )
@provider_error = error_message
{}
diff --git a/app/models/security/provided.rb b/app/models/security/provided.rb
index de7142b1a..1d7805422 100644
--- a/app/models/security/provided.rb
+++ b/app/models/security/provided.rb
@@ -240,10 +240,18 @@ module Security::Provided
update(attrs) if attrs.any?
else
Rails.logger.warn("Failed to fetch security info for #{ticker} from #{price_data_provider.class.name}: #{response.error.message}")
- Sentry.capture_exception(SecurityInfoMissingError.new("Failed to get security info"), level: :warning) do |scope|
- scope.set_tags(security_id: self.id)
- scope.set_context("security", { id: self.id, provider_error: response.error.message })
- end
+ DebugLogEntry.capture(
+ category: "security_metadata_fetch",
+ level: "warn",
+ message: "Failed to get security info",
+ source: self.class.name,
+ provider: price_data_provider,
+ metadata: {
+ security_id: self.id,
+ ticker: ticker,
+ provider_error: response.error.message
+ }
+ )
end
end
diff --git a/app/views/settings/_settings_nav.html.erb b/app/views/settings/_settings_nav.html.erb
index f72cbc196..f9e5e975b 100644
--- a/app/views/settings/_settings_nav.html.erb
+++ b/app/views/settings/_settings_nav.html.erb
@@ -30,6 +30,7 @@ nav_sections = [
{ label: t(".ai_prompts_label"), path: settings_ai_prompts_path, icon: "bot" },
{ label: t(".llm_usage_label"), path: settings_llm_usage_path, icon: "activity" },
{ label: t(".api_keys_label"), path: settings_api_key_path, icon: "key" },
+ { label: t(".debug_label", default: "Debug"), path: settings_debug_path, icon: "bug", if: Current.user&.super_admin? },
{ label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? },
{ label: t(".imports_label"), path: imports_path, icon: "download" },
{ label: t(".exports_label"), path: family_exports_path, icon: "upload" },
diff --git a/app/views/settings/debugs/show.html.erb b/app/views/settings/debugs/show.html.erb
new file mode 100644
index 000000000..252ec4ed5
--- /dev/null
+++ b/app/views/settings/debugs/show.html.erb
@@ -0,0 +1,113 @@
+<%= content_for :page_title, t(".page_title") %>
+
+
+
+
<%= t(".title") %>
+
<%= t(".subtitle") %>
+
+
+
+ <%= form_with url: settings_debug_path, method: :get, class: "grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 items-end" do |f| %>
+
+ <%= f.label :category, t(".filters.category"), class: "block text-sm font-medium text-primary mb-1" %>
+ <%= f.select :category, options_for_select([[t(".filters.all"), ""]] + @categories.map { |value| [value, value] }, params[:category]), {}, class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container-inset text-primary w-full" %>
+
+
+ <%= f.label :level, t(".filters.level"), class: "block text-sm font-medium text-primary mb-1" %>
+ <%= f.select :level, options_for_select([[t(".filters.all"), ""]] + @levels.map { |value| [value, value] }, params[:level]), {}, class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container-inset text-primary w-full" %>
+
+
+ <%= f.label :source, t(".filters.source"), class: "block text-sm font-medium text-primary mb-1" %>
+ <%= f.select :source, options_for_select([[t(".filters.all"), ""]] + @sources.map { |value| [value, value] }, params[:source]), {}, class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container-inset text-primary w-full" %>
+
+
+ <%= f.label :provider_key, t(".filters.provider"), class: "block text-sm font-medium text-primary mb-1" %>
+ <%= f.select :provider_key, options_for_select([[t(".filters.all"), ""]] + @provider_keys.map { |value| [value, value] }, params[:provider_key]), {}, class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container-inset text-primary w-full" %>
+
+
+ <%= f.label :start_date, t(".filters.start_date"), class: "block text-sm font-medium text-primary mb-1" %>
+ <%= f.date_field :start_date, value: @start_date || params[:start_date], class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container-inset text-primary w-full" %>
+
+
+ <%= f.label :end_date, t(".filters.end_date"), class: "block text-sm font-medium text-primary mb-1" %>
+ <%= f.date_field :end_date, value: @end_date || params[:end_date], class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container-inset text-primary w-full" %>
+
+
+ <%= f.label :family_id, t(".filters.family_id"), class: "block text-sm font-medium text-primary mb-1" %>
+ <%= f.text_field :family_id, value: params[:family_id], class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container-inset text-primary w-full font-mono" %>
+
+
+ <%= f.label :account_id, t(".filters.account_id"), class: "block text-sm font-medium text-primary mb-1" %>
+ <%= f.text_field :account_id, value: params[:account_id], class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container-inset text-primary w-full font-mono" %>
+
+
+ <%= f.label :user_id, t(".filters.user_id"), class: "block text-sm font-medium text-primary mb-1" %>
+ <%= f.text_field :user_id, value: params[:user_id], class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container-inset text-primary w-full font-mono" %>
+
+
+ <%= f.label :account_provider_id, t(".filters.account_provider_id"), class: "block text-sm font-medium text-primary mb-1" %>
+ <%= f.text_field :account_provider_id, value: params[:account_provider_id], class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container-inset text-primary w-full font-mono" %>
+
+
+ <%= render DS::Button.new(variant: :primary, size: :md, type: "submit", text: t(".filters.submit"), class: "justify-center") %>
+ <%= render DS::Link.new(text: t(".filters.reset"), href: settings_debug_path, variant: "ghost") %>
+
+ <% end %>
+
+
+
+ <% if @debug_log_entries.any? %>
+
+
+
+
+ | <%= t(".table.time") %> |
+ <%= t(".table.level") %> |
+ <%= t(".table.category") %> |
+ <%= t(".table.source") %> |
+ <%= t(".table.message") %> |
+ <%= t(".table.context") %> |
+ <%= t(".table.metadata") %> |
+
+
+
+ <% @debug_log_entries.each do |entry| %>
+
+ | <%= l(entry.created_at, format: :long) %> |
+ <%= entry.level %> |
+ <%= entry.category %> |
+ <%= entry.source %> |
+ <%= entry.message %> |
+
+ <%= t(".context.provider", value: entry.provider_key.presence || t(".missing_value")) %>
+ <%= t(".context.family", value: entry.family_id || t(".missing_value")) %>
+ <%= t(".context.account", value: entry.account_id || t(".missing_value")) %>
+ <%= t(".context.user", value: entry.user_id || t(".missing_value")) %>
+ <%= t(".context.account_provider", value: entry.account_provider_id || t(".missing_value")) %>
+ |
+
+ <% if entry.metadata.present? %>
+
+ <%= t(".table.view_metadata") %>
+ <%= JSON.pretty_generate(entry.metadata) %>
+
+ <% else %>
+ <%= t(".missing_value") %>
+ <% end %>
+ |
+
+ <% end %>
+
+
+
+ <% else %>
+
<%= t(".empty") %>
+ <% end %>
+
+
+ <% if @pagy.pages > 1 %>
+
+ <%= render "shared/pagination", pagy: @pagy %>
+
+ <% end %>
+
diff --git a/config/application.rb b/config/application.rb
index 1269f1aad..17909aadf 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -48,6 +48,11 @@ module Sure
config.x.ui = ActiveSupport::OrderedOptions.new
default_layout = ENV.fetch("DEFAULT_UI_LAYOUT", "dashboard")
config.x.ui.default_layout = default_layout.in?(%w[dashboard intro]) ? default_layout : "dashboard"
+
+ config.x.debug_log = ActiveSupport::OrderedOptions.new
+ retention_days = ENV.fetch("DEBUG_LOG_RETENTION_DAYS", "90").to_i
+ config.x.debug_log.retention_days = retention_days.positive? ? retention_days : 90
+
# Handle OmniAuth/OIDC errors gracefully (must be before OmniAuth middleware)
require_relative "../app/middleware/omniauth_error_handler"
config.middleware.use OmniauthErrorHandler
diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml
index 590a4dd6a..85a25e193 100644
--- a/config/locales/views/settings/en.yml
+++ b/config/locales/views/settings/en.yml
@@ -6,6 +6,42 @@ en:
renewal: "Your contribution continues on %{date}."
cancellation: "Your contribution ends on %{date}."
settings:
+ debugs:
+ show:
+ page_title: "Debug"
+ title: "Debug event log"
+ subtitle: "Meaningful operational events for super admins. Newest first."
+ empty: "No debug events found."
+ missing_value: "-"
+ filters:
+ all: "All"
+ category: "Category"
+ level: "Level"
+ source: "Source"
+ provider: "Provider"
+ start_date: "From"
+ end_date: "To"
+ family_id: "Family ID"
+ account_id: "Account ID"
+ user_id: "User ID"
+ account_provider_id: "Account provider ID"
+ submit: "Filter"
+ reset: "Reset"
+ table:
+ time: "Time"
+ level: "Level"
+ category: "Category"
+ source: "Source"
+ message: "Message"
+ context: "Context"
+ metadata: "Metadata"
+ view_metadata: "View"
+ context:
+ provider: "provider=%{value}"
+ family: "family=%{value}"
+ account: "account=%{value}"
+ user: "user=%{value}"
+ account_provider: "account_provider=%{value}"
llm_usages:
show:
page_title: "LLM Usage & Costs"
@@ -219,6 +255,7 @@ en:
api_keys_label: API Key
appearance_label: Appearance
bank_sync_label: Bank sync
+ debug_label: Debug
settings_nav_link_large:
next: Next
previous: Back
diff --git a/config/routes.rb b/config/routes.rb
index 02859c9b7..9ff1b4a79 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -238,6 +238,7 @@ Rails.application.routes.draw do
resource :profile, only: [ :show, :destroy ]
resource :preferences, only: :show
resource :appearance, only: %i[show update]
+ resource :debug, only: :show
resource :hosting, only: %i[show update] do
delete :clear_cache, on: :collection
delete :disconnect_external_assistant, on: :collection
diff --git a/config/schedule.yml b/config/schedule.yml
index c3903a229..5475d7b0a 100644
--- a/config/schedule.yml
+++ b/config/schedule.yml
@@ -31,6 +31,12 @@ clean_data:
queue: "scheduled"
description: "Cleans up old data (e.g., expired merchant associations, expired archived exports)"
+clean_debug_log_entries:
+ cron: "30 3 * * *" # daily at 3:30 AM
+ class: "DebugLogCleanupJob"
+ queue: "scheduled"
+ description: "Deletes debug log entries older than 90 days"
+
clean_inactive_families:
cron: "0 4 * * *" # daily at 4:00 AM
class: "InactiveFamilyCleanerJob"
diff --git a/db/migrate/20260517122500_create_debug_log_entries.rb b/db/migrate/20260517122500_create_debug_log_entries.rb
new file mode 100644
index 000000000..34755fb5a
--- /dev/null
+++ b/db/migrate/20260517122500_create_debug_log_entries.rb
@@ -0,0 +1,35 @@
+class CreateDebugLogEntries < ActiveRecord::Migration[7.2]
+ def up
+ create_table :debug_log_entries, id: :uuid do |t|
+ t.string :category, null: false
+ t.string :level, null: false
+ t.text :message, null: false
+ t.string :source, null: false
+ t.jsonb :metadata, null: false, default: {}
+ t.references :family, type: :uuid, foreign_key: { on_delete: :nullify }, null: true
+ t.references :account, type: :uuid, foreign_key: { on_delete: :nullify }, null: true
+ t.references :user, type: :uuid, foreign_key: { on_delete: :nullify }, null: true
+ t.references :account_provider, type: :uuid, foreign_key: { on_delete: :nullify }, null: true
+ t.string :provider_key
+
+ t.timestamps
+ end
+
+ add_check_constraint :debug_log_entries,
+ "level IN ('debug', 'info', 'warn', 'error')",
+ name: "chk_debug_log_entries_level"
+
+ add_index :debug_log_entries, :created_at
+ add_index :debug_log_entries, :category
+ add_index :debug_log_entries, :level
+ add_index :debug_log_entries, :source
+ add_index :debug_log_entries, :provider_key
+ add_index :debug_log_entries, [ :category, :created_at ]
+ add_index :debug_log_entries, [ :provider_key, :created_at ]
+ end
+
+ def down
+ remove_check_constraint :debug_log_entries, name: "chk_debug_log_entries_level", if_exists: true
+ drop_table :debug_log_entries
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 943e4dfe3..dcd00bdf8 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2026_05_12_211200) do
+ActiveRecord::Schema[7.2].define(version: 2026_05_17_122500) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -76,15 +76,15 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_12_211200) do
t.index ["family_id"], name: "index_account_statements_on_family_id"
t.index ["suggested_account_id", "review_status"], name: "index_account_statements_on_suggested_account_review"
t.index ["suggested_account_id"], name: "index_account_statements_on_suggested_account_id"
- t.check_constraint "byte_size <= 26214400", name: "chk_account_statements_byte_size_max"
- t.check_constraint "byte_size > 0", name: "chk_account_statements_byte_size_positive"
t.check_constraint "account_last4_hint IS NULL OR char_length(account_last4_hint::text) <= 4", name: "chk_account_statements_account_last4_hint_length"
t.check_constraint "account_name_hint IS NULL OR char_length(account_name_hint::text) <= 200", name: "chk_account_statements_account_name_hint_length"
+ t.check_constraint "byte_size <= 26214400", name: "chk_account_statements_byte_size_max"
+ t.check_constraint "byte_size > 0", name: "chk_account_statements_byte_size_positive"
t.check_constraint "char_length(checksum::text) <= 64", name: "chk_account_statements_checksum_length"
t.check_constraint "char_length(content_type::text) <= 100", name: "chk_account_statements_content_type_length"
+ t.check_constraint "char_length(filename::text) <= 255", name: "chk_account_statements_filename_length"
t.check_constraint "content_sha256 IS NULL OR content_sha256::text ~ '^[0-9a-f]{64}$'::text", name: "chk_account_statements_content_sha256"
t.check_constraint "currency IS NULL OR char_length(currency::text) <= 3", name: "chk_account_statements_currency_length"
- t.check_constraint "char_length(filename::text) <= 255", name: "chk_account_statements_filename_length"
t.check_constraint "institution_name_hint IS NULL OR char_length(institution_name_hint::text) <= 200", name: "chk_account_statements_institution_hint_length"
t.check_constraint "match_confidence IS NULL OR match_confidence >= 0::numeric AND match_confidence <= 1::numeric", name: "chk_account_statements_match_confidence"
t.check_constraint "parser_confidence IS NULL OR parser_confidence >= 0::numeric AND parser_confidence <= 1::numeric", name: "chk_account_statements_parser_confidence"
@@ -474,6 +474,33 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_12_211200) do
t.index ["enrichable_type", "enrichable_id"], name: "index_data_enrichments_on_enrichable"
end
+ create_table "debug_log_entries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.string "category", null: false
+ t.string "level", null: false
+ t.text "message", null: false
+ t.string "source", null: false
+ t.jsonb "metadata", default: {}, null: false
+ t.uuid "family_id"
+ t.uuid "account_id"
+ t.uuid "user_id"
+ t.uuid "account_provider_id"
+ t.string "provider_key"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id"], name: "index_debug_log_entries_on_account_id"
+ t.index ["account_provider_id"], name: "index_debug_log_entries_on_account_provider_id"
+ t.index ["category", "created_at"], name: "index_debug_log_entries_on_category_and_created_at"
+ t.index ["category"], name: "index_debug_log_entries_on_category"
+ t.index ["created_at"], name: "index_debug_log_entries_on_created_at"
+ t.index ["family_id"], name: "index_debug_log_entries_on_family_id"
+ t.index ["level"], name: "index_debug_log_entries_on_level"
+ t.index ["provider_key", "created_at"], name: "index_debug_log_entries_on_provider_key_and_created_at"
+ t.index ["provider_key"], name: "index_debug_log_entries_on_provider_key"
+ t.index ["source"], name: "index_debug_log_entries_on_source"
+ t.index ["user_id"], name: "index_debug_log_entries_on_user_id"
+ t.check_constraint "level::text = ANY (ARRAY['debug'::character varying, 'info'::character varying, 'warn'::character varying, 'error'::character varying]::text[])", name: "chk_debug_log_entries_level"
+ end
+
create_table "depositories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@@ -1874,6 +1901,10 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_12_211200) do
add_foreign_key "coinbase_items", "families"
add_foreign_key "coinstats_accounts", "coinstats_items"
add_foreign_key "coinstats_items", "families"
+ add_foreign_key "debug_log_entries", "account_providers", on_delete: :nullify
+ add_foreign_key "debug_log_entries", "accounts", on_delete: :nullify
+ add_foreign_key "debug_log_entries", "families", on_delete: :nullify
+ add_foreign_key "debug_log_entries", "users", on_delete: :nullify
add_foreign_key "enable_banking_accounts", "enable_banking_items"
add_foreign_key "enable_banking_items", "families"
add_foreign_key "entries", "accounts", on_delete: :cascade
diff --git a/test/controllers/settings/debugs_controller_test.rb b/test/controllers/settings/debugs_controller_test.rb
new file mode 100644
index 000000000..7179738b8
--- /dev/null
+++ b/test/controllers/settings/debugs_controller_test.rb
@@ -0,0 +1,68 @@
+require "test_helper"
+
+class Settings::DebugsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ ensure_tailwind_build
+
+ @entry = DebugLogEntry.create!(
+ category: "security_price_fetch",
+ level: "warn",
+ message: "Could not fetch prices",
+ source: "Security::Price::Importer",
+ provider_key: "twelve_data",
+ family: families(:dylan_family),
+ account: accounts(:depository),
+ user: users(:family_admin),
+ metadata: { ticker: "AAPL" }
+ )
+ end
+
+ test "super admins can view debug log" do
+ sign_in users(:sure_support_staff)
+
+ get settings_debug_url
+
+ assert_response :success
+ assert_match "Debug event log", response.body
+ assert_match @entry.message, response.body
+ end
+
+ test "non super admins are redirected" do
+ sign_in users(:family_admin)
+
+ get settings_debug_url
+
+ assert_redirected_to root_url
+ end
+
+ test "filters by provider key" do
+ sign_in users(:sure_support_staff)
+
+ DebugLogEntry.create!(
+ category: "security_price_fetch",
+ level: "warn",
+ message: "Should be filtered out",
+ source: "Security::Price::Importer",
+ provider_key: "finnhub",
+ family: families(:dylan_family),
+ account: accounts(:depository),
+ user: users(:family_admin),
+ metadata: { ticker: "MSFT" }
+ )
+
+ get settings_debug_url, params: { provider_key: "twelve_data" }
+
+ assert_response :success
+ assert_match @entry.message, response.body
+ refute_match "Should be filtered out", response.body
+ end
+
+ test "ignores invalid uuid filters" do
+ sign_in users(:sure_support_staff)
+
+ get settings_debug_url, params: { family_id: "not-a-uuid" }
+
+ assert_response :success
+ assert_match @entry.message, response.body
+ end
+end
diff --git a/test/jobs/debug_log_cleanup_job_test.rb b/test/jobs/debug_log_cleanup_job_test.rb
new file mode 100644
index 000000000..df66ce5be
--- /dev/null
+++ b/test/jobs/debug_log_cleanup_job_test.rb
@@ -0,0 +1,77 @@
+require "test_helper"
+
+class DebugLogCleanupJobTest < ActiveJob::TestCase
+ setup do
+ @original_retention_days = Rails.application.config.x.debug_log.retention_days
+ Rails.application.config.x.debug_log.retention_days = 90
+ end
+
+ teardown do
+ Rails.application.config.x.debug_log.retention_days = @original_retention_days
+ end
+
+ test "deletes entries older than 90 days" do
+ travel_to Time.zone.parse("2026-05-17 12:00:00") do
+ old_entry = DebugLogEntry.create!(
+ category: "old_event",
+ level: "info",
+ message: "old",
+ source: "Test",
+ created_at: 91.days.ago,
+ updated_at: 91.days.ago
+ )
+ boundary_entry = DebugLogEntry.create!(
+ category: "boundary_event",
+ level: "info",
+ message: "boundary",
+ source: "Test",
+ created_at: 90.days.ago,
+ updated_at: 90.days.ago
+ )
+ recent_entry = DebugLogEntry.create!(
+ category: "recent_event",
+ level: "info",
+ message: "recent",
+ source: "Test"
+ )
+
+ assert_difference "DebugLogEntry.count", -1 do
+ DebugLogCleanupJob.perform_now
+ end
+
+ assert_not DebugLogEntry.exists?(old_entry.id)
+ assert DebugLogEntry.exists?(boundary_entry.id)
+ assert DebugLogEntry.exists?(recent_entry.id)
+ end
+ end
+
+ test "uses configured retention days" do
+ Rails.application.config.x.debug_log.retention_days = 30
+
+ travel_to Time.zone.parse("2026-05-17 12:00:00") do
+ old_entry = DebugLogEntry.create!(
+ category: "old_event",
+ level: "info",
+ message: "old",
+ source: "Test",
+ created_at: 31.days.ago,
+ updated_at: 31.days.ago
+ )
+ boundary_entry = DebugLogEntry.create!(
+ category: "boundary_event",
+ level: "info",
+ message: "boundary",
+ source: "Test",
+ created_at: 30.days.ago,
+ updated_at: 30.days.ago
+ )
+
+ assert_difference "DebugLogEntry.count", -1 do
+ DebugLogCleanupJob.perform_now
+ end
+
+ assert_not DebugLogEntry.exists?(old_entry.id)
+ assert DebugLogEntry.exists?(boundary_entry.id)
+ end
+ end
+end
diff --git a/test/models/debug_log_entry_test.rb b/test/models/debug_log_entry_test.rb
new file mode 100644
index 000000000..47cdb6291
--- /dev/null
+++ b/test/models/debug_log_entry_test.rb
@@ -0,0 +1,20 @@
+require "test_helper"
+
+class DebugLogEntryTest < ActiveSupport::TestCase
+ test "capture infers provider key and family from account" do
+ entry = DebugLogEntry.capture(
+ category: "provider_sync",
+ level: "warn",
+ message: "Provider event",
+ source: "Provider::Test",
+ account: accounts(:depository),
+ provider: :twelve_data,
+ metadata: { test: true }
+ )
+
+ assert entry.persisted?
+ assert_equal "twelve_data", entry.provider_key
+ assert_equal accounts(:depository), entry.account
+ assert_equal accounts(:depository).family, entry.family
+ end
+end
diff --git a/test/models/security/price/importer_test.rb b/test/models/security/price/importer_test.rb
index a182a6c4a..547c4eb88 100644
--- a/test/models/security/price/importer_test.rb
+++ b/test/models/security/price/importer_test.rb
@@ -81,6 +81,32 @@ class Security::Price::ImporterTest < ActiveSupport::TestCase
).import_provider_prices
end
+ test "logs provider fetch failures to debug log" do
+ Security::Price.delete_all
+
+ error = Provider::Error.new("Rate limit exceeded")
+
+ @provider.stubs(:fetch_security_prices).returns(provider_error_response(error))
+
+ assert_difference "DebugLogEntry.count", 1 do
+ Security::Price::Importer.new(
+ security: @security,
+ security_provider: @provider,
+ start_date: 2.days.ago.to_date,
+ end_date: Date.current
+ ).import_provider_prices
+ end
+
+ entry = DebugLogEntry.order(:created_at).last
+ assert_equal "security_price_fetch", entry.category
+ assert_equal "warn", entry.level
+ assert_equal "Could not fetch prices for ticker", entry.message
+ assert_equal "security/price/importer", entry.source.underscore
+ assert_equal @security.id, entry.metadata["security_id"]
+ assert_equal @security.ticker, entry.metadata["ticker"]
+ assert_equal "Rate limit exceeded", entry.metadata["provider_error"]
+ end
+
test "writes post-listing prices when holding predates provider history" do
# Regression: a 2018-06-15 trade for a pair the provider only has from
# 2020-01-03 onwards (e.g. BTCEUR on Binance) used to hit the
diff --git a/test/models/security/provided_test.rb b/test/models/security/provided_test.rb
index 521a249ba..0b06d8d79 100644
--- a/test/models/security/provided_test.rb
+++ b/test/models/security/provided_test.rb
@@ -153,6 +153,30 @@ class Security::ProvidedTest < ActiveSupport::TestCase
assert_equal fallback_provider, @security.price_data_provider
end
+ test "import_provider_details logs provider metadata failures to debug log" do
+ provider = mock("provider")
+ provider.stubs(:class).returns(Provider::TwelveData)
+ provider.stubs(:fetch_security_info).returns(
+ provider_error_response(Provider::Error.new("metadata unavailable"))
+ )
+
+ @security.stubs(:price_data_provider).returns(provider)
+
+ assert_difference "DebugLogEntry.count", 1 do
+ @security.import_provider_details
+ end
+
+ entry = DebugLogEntry.order(:created_at).last
+ assert_equal "security_metadata_fetch", entry.category
+ assert_equal "warn", entry.level
+ assert_equal "Failed to get security info", entry.message
+ assert_equal "security", entry.source.underscore
+ assert_equal @security.id, entry.metadata["security_id"]
+ assert_equal @security.ticker, entry.metadata["ticker"]
+ assert_equal "metadata unavailable", entry.metadata["provider_error"]
+ assert_equal "twelve_data", entry.provider_key
+ end
+
# --- provider_status ---
test "provider_status returns provider_unavailable when assigned provider disabled" do