mirror of
https://github.com/we-promise/sure.git
synced 2026-05-24 13:04:56 +00:00
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:
55
app/controllers/settings/debugs_controller.rb
Normal file
55
app/controllers/settings/debugs_controller.rb
Normal 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
|
||||
15
app/jobs/debug_log_cleanup_job.rb
Normal file
15
app/jobs/debug_log_cleanup_job.rb
Normal 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
|
||||
88
app/models/debug_log_entry.rb
Normal file
88
app/models/debug_log_entry.rb
Normal 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
|
||||
@@ -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
|
||||
{}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
113
app/views/settings/debugs/show.html.erb
Normal file
113
app/views/settings/debugs/show.html.erb
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
35
db/migrate/20260517122500_create_debug_log_entries.rb
Normal file
35
db/migrate/20260517122500_create_debug_log_entries.rb
Normal 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
39
db/schema.rb
generated
@@ -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
|
||||
|
||||
68
test/controllers/settings/debugs_controller_test.rb
Normal file
68
test/controllers/settings/debugs_controller_test.rb
Normal 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
|
||||
77
test/jobs/debug_log_cleanup_job_test.rb
Normal file
77
test/jobs/debug_log_cleanup_job_test.rb
Normal 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
|
||||
20
test/models/debug_log_entry_test.rb
Normal file
20
test/models/debug_log_entry_test.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user