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? %> +
+ + + + + + + + + + + + + + <% @debug_log_entries.each do |entry| %> + + + + + + + + + + <% end %> + +
<%= t(".table.time") %><%= t(".table.level") %><%= t(".table.category") %><%= t(".table.source") %><%= t(".table.message") %><%= t(".table.context") %><%= t(".table.metadata") %>
<%= 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 %> +
+
+ <% 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