Add super_admin debug event log (#1816)

* Add super-admin debug event log

* Address debug log review feedback

* Whitelist debug filter params

* Make debug log retention configurable
This commit is contained in:
Sure Admin (bot)
2026-05-17 16:55:01 +02:00
committed by GitHub
parent 2df10ca4ef
commit 70fc52769d
18 changed files with 632 additions and 12 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
{}

View File

@@ -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

View File

@@ -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" },

View File

@@ -0,0 +1,113 @@
<%= content_for :page_title, t(".page_title") %>
<div class="space-y-6">
<div class="bg-container rounded-xl shadow-border-xs p-4">
<h1 class="text-lg font-semibold text-primary"><%= t(".title") %></h1>
<p class="text-sm text-secondary mt-1"><%= t(".subtitle") %></p>
</div>
<div class="bg-container rounded-xl shadow-border-xs p-4">
<%= 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| %>
<div>
<%= 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" %>
</div>
<div>
<%= 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" %>
</div>
<div>
<%= 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" %>
</div>
<div>
<%= 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" %>
</div>
<div>
<%= 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" %>
</div>
<div>
<%= 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" %>
</div>
<div>
<%= 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" %>
</div>
<div>
<%= 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" %>
</div>
<div>
<%= 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" %>
</div>
<div>
<%= 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" %>
</div>
<div class="flex gap-2 xl:col-span-2">
<%= 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") %>
</div>
<% end %>
</div>
<div class="bg-container rounded-xl shadow-border-xs overflow-hidden">
<% if @debug_log_entries.any? %>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-surface-default border-b border-primary">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-secondary uppercase"><%= t(".table.time") %></th>
<th class="px-4 py-3 text-left text-xs font-medium text-secondary uppercase"><%= t(".table.level") %></th>
<th class="px-4 py-3 text-left text-xs font-medium text-secondary uppercase"><%= t(".table.category") %></th>
<th class="px-4 py-3 text-left text-xs font-medium text-secondary uppercase"><%= t(".table.source") %></th>
<th class="px-4 py-3 text-left text-xs font-medium text-secondary uppercase"><%= t(".table.message") %></th>
<th class="px-4 py-3 text-left text-xs font-medium text-secondary uppercase"><%= t(".table.context") %></th>
<th class="px-4 py-3 text-left text-xs font-medium text-secondary uppercase"><%= t(".table.metadata") %></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<% @debug_log_entries.each do |entry| %>
<tr>
<td class="px-4 py-3 text-sm text-primary whitespace-nowrap"><%= l(entry.created_at, format: :long) %></td>
<td class="px-4 py-3 text-sm text-primary whitespace-nowrap"><%= entry.level %></td>
<td class="px-4 py-3 text-sm text-primary whitespace-nowrap"><%= entry.category %></td>
<td class="px-4 py-3 text-sm text-primary font-mono"><%= entry.source %></td>
<td class="px-4 py-3 text-sm text-primary"><%= entry.message %></td>
<td class="px-4 py-3 text-xs text-secondary font-mono whitespace-nowrap align-top">
<div><%= t(".context.provider", value: entry.provider_key.presence || t(".missing_value")) %></div>
<div><%= t(".context.family", value: entry.family_id || t(".missing_value")) %></div>
<div><%= t(".context.account", value: entry.account_id || t(".missing_value")) %></div>
<div><%= t(".context.user", value: entry.user_id || t(".missing_value")) %></div>
<div><%= t(".context.account_provider", value: entry.account_provider_id || t(".missing_value")) %></div>
</td>
<td class="px-4 py-3 text-xs text-secondary align-top">
<% if entry.metadata.present? %>
<details>
<summary class="cursor-pointer"><%= t(".table.view_metadata") %></summary>
<pre class="mt-2 whitespace-pre-wrap break-words"><%= JSON.pretty_generate(entry.metadata) %></pre>
</details>
<% else %>
<%= t(".missing_value") %>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<div class="p-8 text-center text-secondary"><%= t(".empty") %></div>
<% end %>
</div>
<% if @pagy.pages > 1 %>
<div>
<%= render "shared/pagination", pagy: @pagy %>
</div>
<% end %>
</div>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

39
db/schema.rb generated
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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