diff --git a/app/assets/javascripts/bulkrax/importers_stepper.js b/app/assets/javascripts/bulkrax/importers_stepper.js index 066af38c9..4a5b13087 100644 --- a/app/assets/javascripts/bulkrax/importers_stepper.js +++ b/app/assets/javascripts/bulkrax/importers_stepper.js @@ -95,7 +95,8 @@ ENDPOINTS: { DEMO_SCENARIOS: '/importers/guided_import/demo_scenarios', VALIDATE: '/importers/guided_import/validate', - DOWNLOAD_VALIDATION_ERRORS: '/importers/guided_import/download_validation_errors' + DOWNLOAD_VALIDATION_ERRORS: '/importers/guided_import/download_validation_errors', + METRICS: null // set from data-metrics-url on container init } } @@ -141,6 +142,9 @@ eventsInitialized = false + var metricsUrl = $('.bulk-import-stepper-container').data('metrics-url') + MetricsTracker.init(metricsUrl) + bindEvents() initAdminSetState() updateDownloadTemplateLink() @@ -1374,7 +1378,8 @@ }, admin_set_id: StepperState.adminSetId }, - locale: $('input[name="locale"]').val() + locale: $('input[name="locale"]').val(), + metrics_session_id: MetricsTracker.sessionId }, timeout: CONSTANTS.AJAX_TIMEOUT_LONG }) @@ -1397,6 +1402,7 @@ // Update UI after successful validation function handleValidationSuccess(data, $btn) { var normalized = normalizeValidationData(data) + MetricsTracker.recordValidation(normalized) StepperState.validated = true StepperState.validationData = normalized @@ -1459,6 +1465,7 @@ // Validate files (AJAX call to backend) function validateFiles() { + MetricsTracker.recordValidationStart() var $btn = StepperState.uploadMode === 'file_path' ? $('#validate-path-btn') : $('#validate-upload-btn') $btn .prop('disabled', true) @@ -1486,7 +1493,8 @@ importer: { admin_set_id: StepperState.adminSetId }, - locale: $('input[name="locale"]').val() + locale: $('input[name="locale"]').val(), + metrics_session_id: MetricsTracker.sessionId } } @@ -2105,6 +2113,7 @@ // Navigate to step function goToStep(stepNum) { StepperState.currentStep = stepNum + MetricsTracker.recordStep(stepNum) updateStepperUI() // Scroll to top, then move focus to the new step's heading @@ -2358,19 +2367,90 @@ // Disable the file input so raw files aren't sent with the form $('#file-input').prop('disabled', true) + var formData = new FormData($form[0]) + // Only append uploaded file IDs in upload mode; in file_path mode the import_file_path // param is used and appending IDs would cause GuidedImportsController#create to ignore the path. if (StepperState.uploadMode === 'upload' && Array.isArray(StepperState.uploadedFiles)) { StepperState.uploadedFiles.forEach(function (f) { if (f.uploadId) { - var $input = $('', { type: 'hidden', name: 'uploaded_files[]' }).val(f.uploadId) - $form.append($input) + formData.append('uploaded_files[]', f.uploadId) } }) } - // Submit the form so the request hits GuidedImportsController#create and creates the importer / enqueues job - $form[0].submit() + // Record funnel event and timing before submit + MetricsTracker.send('funnel', 'step_reached', { step: 4 }) + MetricsTracker.recordSubmit() + + $.ajax({ + url: $form.attr('action'), + method: 'POST', + data: formData, + processData: false, + contentType: false, + dataType: 'json', + headers: { 'Accept': 'application/json' } + }).done(function (data) { + if (data && data.success) { + showImportSuccess(data.importer_id) + } else { + handleImportSubmitError() + } + }).fail(function () { + handleImportSubmitError() + }) + } + + function showImportSuccess(importerId) { + $('.stepper-content-wrapper').hide() + $('.stepper-header').hide() + $('.import-success-state').show() + + // Reveal SEQ after short delay + setTimeout(function () { + $('#seq-feedback').fadeIn(300) + }, 400) + + // Store importer ID for SEQ submission + StepperState.importerId = importerId + + initSeqHandlers() + } + + function handleImportSubmitError() { + var $btn = $('#start-import-btn') + $btn + .prop('disabled', false) + .html(t('start_import') || 'Start Import') + showNotification(t('import_submit_error') || 'Import submission failed. Please try again.', 'error') + } + + function initSeqHandlers() { + // Rating selection + $('#seq-feedback').on('click', '.seq-scale-circle', function () { + $('.seq-scale-circle').removeClass('active') + $(this).addClass('active') + $('input[name="seq_score"][value="' + $(this).data('value') + '"]').prop('checked', true) + $('.seq-comment-group').slideDown(200) + $('.seq-actions').slideDown(200) + }) + + // Form submit + $('#seq-feedback-form').on('submit', function (e) { + e.preventDefault() + var rating = parseInt($('input[name="seq_score"]:checked').val(), 10) + if (!rating) return + var comment = $('#seq-comment').val() + MetricsTracker.recordFeedback(rating, comment, StepperState.importerId) + $('#seq-feedback-form').hide() + $('.seq-thank-you').show() + }) + + // Skip or dismiss + $('#seq-skip-btn, #seq-dismiss').on('click', function () { + $('#seq-feedback').fadeOut(200) + }) } // Look up mock validation data from cached demo scenarios JSON @@ -2415,6 +2495,91 @@ }) } + // ============================================================================ + // METRICS TRACKER + // ============================================================================ + + var MetricsTracker = { + sessionId: null, + stepTimestamps: {}, + + init: function (metricsEndpoint) { + CONSTANTS.ENDPOINTS.METRICS = metricsEndpoint || null + this.sessionId = 'gi_' + Math.random().toString(36).substr(2, 9) + $('#metrics-session-id').val(this.sessionId) + this.stepTimestamps = {} + this.stepTimestamps.step1 = Date.now() + this.send('funnel', 'step_reached', { step: 1 }) + }, + + recordStep: function (stepNum) { + this.stepTimestamps['step' + stepNum] = Date.now() + this.send('funnel', 'step_reached', { step: stepNum }) + }, + + recordValidationStart: function () { + this.stepTimestamps.validationStart = Date.now() + }, + + recordValidation: function (validationData) { + var start = this.stepTimestamps.validationStart || Date.now() + this.send('validation', 'validation_complete', { + outcome: validationData.isValid + ? (validationData.hasWarnings ? 'pass_with_warnings' : 'pass') + : 'fail', + row_count: validationData.rowCount || 0, + duration_ms: Date.now() - start, + has_zip: !!validationData.zipIncluded + }) + }, + + recordSubmit: function () { + var now = Date.now() + var timings = { total_session_ms: now - (this.stepTimestamps.step1 || now) } + var steps = ['step1', 'step2', 'step3'] + var nexts = ['step2', 'step3', null] + steps.forEach(function (step, i) { + var nextStep = nexts[i] + if (MetricsTracker.stepTimestamps[step]) { + var end = nextStep && MetricsTracker.stepTimestamps[nextStep] + ? MetricsTracker.stepTimestamps[nextStep] + : now + timings[step + '_duration_ms'] = end - MetricsTracker.stepTimestamps[step] + } + }) + this.send('timing', 'session_complete', timings) + }, + + recordFeedback: function (rating, comment, importerId) { + this.send('feedback', 'seq_rating', { + seq_rating: rating, + comment: comment || '', + importer_id: importerId + }) + }, + + send: function (metricType, event, payload) { + if (!CONSTANTS.ENDPOINTS.METRICS) return + var data = JSON.stringify({ + metric_type: metricType, + event: event, + session_id: this.sessionId, + payload: payload + }) + if (navigator.sendBeacon) { + navigator.sendBeacon(CONSTANTS.ENDPOINTS.METRICS, new Blob([data], { type: 'application/json' })) + } else { + $.ajax({ + url: CONSTANTS.ENDPOINTS.METRICS, + method: 'POST', + data: data, + contentType: 'application/json', + headers: { 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content') } + }) + } + } + } + // Initialize on document ready and turbolinks load $(document).on('turbolinks:load', initBulkImportStepper) })(jQuery, window.BulkraxUtils || {}) diff --git a/app/assets/stylesheets/bulkrax/stepper.scss b/app/assets/stylesheets/bulkrax/stepper.scss index 34c73d848..bc957a482 100644 --- a/app/assets/stylesheets/bulkrax/stepper.scss +++ b/app/assets/stylesheets/bulkrax/stepper.scss @@ -23,6 +23,7 @@ @import 'stepper/header'; @import 'stepper/success'; +@import 'stepper/seq_feedback'; @import 'stepper/upload'; @import 'stepper/validation'; @import 'stepper/summary'; diff --git a/app/assets/stylesheets/bulkrax/stepper/_seq_feedback.scss b/app/assets/stylesheets/bulkrax/stepper/_seq_feedback.scss new file mode 100644 index 000000000..aaed8c475 --- /dev/null +++ b/app/assets/stylesheets/bulkrax/stepper/_seq_feedback.scss @@ -0,0 +1,186 @@ +// SEQ Feedback Section + +.seq-feedback-section { + max-width: 500px; + margin: 30px auto 0; + animation: seqSlideIn 0.4s ease-out; +} + +@keyframes seqSlideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.seq-card { + border: 1px solid $border-color; + border-radius: $border-radius; + padding: 20px 24px; + background: $bg-light; +} + +.seq-header { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 8px; +} + +.seq-header-icon { + font-size: 18px; + color: $color-primary; + margin-top: 2px; + flex-shrink: 0; +} + +.seq-header-text { + flex: 1; +} + +.seq-title { + font-size: 15px; + font-weight: 600; + color: $color-text-dark; + margin: 0 0 4px; +} + +.seq-subtitle { + font-size: 13px; + color: $color-text-muted; + margin: 0; +} + +.seq-dismiss-btn { + background: none; + border: none; + color: $color-text-muted; + cursor: pointer; + padding: 0; + font-size: 14px; + line-height: 1; + flex-shrink: 0; + + &:hover { + color: $color-text-dark; + } +} + +.seq-scale-fieldset { + border: none; + padding: 0; + margin: 0; +} + +.seq-scale-wrapper { + display: inline-block; + width: 100%; + text-align: center; + margin: 15px 0 4px; +} + +.seq-scale { + display: inline-flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.seq-anchor-labels { + display: flex; + justify-content: space-between; + width: 100%; +} + +.seq-anchor-label { + font-size: 11px; + color: $color-text-muted; +} + +@media (max-width: 480px) { + .seq-scale { + gap: 4px; + } + + .seq-scale-circle { + width: 32px; + height: 32px; + font-size: 12px; + } +} + +.seq-scale-option { + margin: 0; + cursor: pointer; +} + +.seq-scale-circle { + width: 40px; + height: 40px; + border-radius: 50%; + border: 2px solid $border-color; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 600; + color: $color-text-muted; + cursor: pointer; + transition: all 0.2s ease; + user-select: none; + + &:hover { + border-color: $color-primary; + color: $color-primary; + background: $bg-primary-light; + } + + &.active { + background: $color-primary; + border-color: $color-primary; + color: #fff; + box-shadow: 0 2px 4px rgba(43, 109, 165, 0.3); + } +} + +.seq-comment-group { + margin-top: 12px; +} + +.seq-comment-label { + font-size: 13px; + font-weight: 600; + color: $color-text-dark; + margin-bottom: 6px; + display: block; +} + +.seq-actions { + display: flex; + align-items: center; + gap: 8px; + margin-top: 12px; +} + +.seq-thank-you { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 0 4px; + color: $color-success-dark; + + p { + margin: 0; + font-size: 14px; + font-weight: 600; + } +} + +.seq-thank-you-icon { + font-size: 20px; + color: $color-success; +} diff --git a/app/controllers/bulkrax/guided_imports_controller.rb b/app/controllers/bulkrax/guided_imports_controller.rb index 2bccd26e3..35eaf32bd 100644 --- a/app/controllers/bulkrax/guided_imports_controller.rb +++ b/app/controllers/bulkrax/guided_imports_controller.rb @@ -5,6 +5,7 @@ class GuidedImportsController < ::Bulkrax::ApplicationController include Hyrax::ThemedLayoutController if defined?(::Hyrax) include Bulkrax::GuidedImportDemoScenarios if Bulkrax.config.guided_import_demo_scenarios_enabled include Bulkrax::ImporterFileHandler + include Bulkrax::GuidedImportMetrics helper Bulkrax::ImportersHelper before_action :authenticate_user! @@ -37,7 +38,12 @@ def validate end admin_set_id = params[:importer]&.[](:admin_set_id) + validation_start = Time.now.to_f validation_result = run_validation(csv_file, zip_file, admin_set_id: admin_set_id) + duration_ms = ((Time.now.to_f - validation_start) * 1000).round + + record_validation_metric(validation_result, duration_ms) + raw_csv_data = validation_result.delete(:raw_csv_data) cache_key = cache_validation_errors(validation_result, raw_csv_data, csv_file) formatted = StepperResponseFormatter.format(validation_result) @@ -69,10 +75,7 @@ def create files = resolve_create_files return render_invalid_uploaded_files_response if params[:uploaded_files].present? && files.empty? - @importer = Importer.new(importer_params) - @importer.parser_klass = 'Bulkrax::CsvParser' - @importer.user = current_user if respond_to?(:current_user) && current_user.present? - apply_field_mapping + @importer = build_guided_importer if @importer.save write_files(files) @@ -94,6 +97,16 @@ def create private + def build_guided_importer + @importer = Importer.new(importer_params) + @importer.parser_klass = 'Bulkrax::CsvParser' + @importer.user = current_user if respond_to?(:current_user) && current_user.present? + @importer.parser_fields = (@importer.parser_fields || {}).merge('guided_import' => true) + @importer.parser_fields['metrics_session_id'] = params[:metrics_session_id] if Bulkrax.config.guided_import_metrics_enabled && params[:metrics_session_id].present? + apply_field_mapping + @importer + end + def render_invalid_uploaded_files_response respond_to do |format| format.html { render :new, status: :unprocessable_entity } @@ -101,39 +114,6 @@ def render_invalid_uploaded_files_response end end - # Runs validation via the real service. - # @param csv_file [File, StringIO] the CSV to validate - # @param zip_file [File, nil] an optional ZIP containing file attachments - # @param admin_set_id [String, nil] optional admin set ID for validation context - # @return [Hash] validation result data - def cache_validation_errors(validation_result, raw_csv_data, csv_file) - has_errors = validation_result[:rowErrors]&.any? || - validation_result[:missingRequired]&.any? || - validation_result[:unrecognized]&.any? || - validation_result[:emptyColumns]&.any? || - validation_result[:missingFiles]&.any? - return nil unless has_errors - - key = "guided_import_errors:#{session.id}:#{Time.now.to_i}" - Rails.cache.write( - key, - { - headers: validation_result[:headers], - csv_data: raw_csv_data, - row_errors: validation_result[:rowErrors] || [], - file_errors: { - missing_required: validation_result[:missingRequired] || [], - unrecognized: validation_result[:unrecognized] || {}, - empty_columns: validation_result[:emptyColumns] || [], - missing_files: validation_result[:missingFiles] || [] - }, - original_filename: filename_for(csv_file) - }, - expires_in: 1.hour - ) - key - end - def run_validation(csv_file, zip_file, admin_set_id: nil) CsvParser.validate_csv(csv_file: csv_file, zip_file: zip_file, admin_set_id: admin_set_id) end diff --git a/app/controllers/bulkrax/import_metrics_controller.rb b/app/controllers/bulkrax/import_metrics_controller.rb new file mode 100644 index 000000000..f409eaeb2 --- /dev/null +++ b/app/controllers/bulkrax/import_metrics_controller.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module Bulkrax + class ImportMetricsController < ::Bulkrax::ApplicationController + include Hyrax::ThemedLayoutController if defined?(::Hyrax) + with_themed_layout 'dashboard' if defined?(::Hyrax) + + before_action :check_metrics_enabled + before_action :authenticate_user!, only: [:index, :export] + before_action :check_permissions, only: [:index, :export] + + # POST /importers/guided_import/metrics + # sendBeacon endpoint — no CSRF, no auth, fire-and-forget. + skip_before_action :verify_authenticity_token, only: [:record_metric] + + def record_metric + Bulkrax::ImportMetric.record( + metric_type: params[:metric_type], + event: params[:event], + user: current_user, + session_id: params[:session_id], + payload: params[:payload].respond_to?(:to_unsafe_h) ? params[:payload].to_unsafe_h : (params[:payload] || {}) + ) + head :no_content + end + + def index + add_breadcrumbs + @aggregator = MetricsAggregator.new(from: date_from, to: date_to) + @date_from = date_from + @date_to = date_to + end + + def export + aggregator = MetricsAggregator.new(from: date_from, to: date_to) + csv_data = generate_csv(aggregator) + send_data csv_data, + filename: "bulkrax_import_metrics_#{Time.zone.today.iso8601}.csv", + type: 'text/csv', + disposition: 'attachment' + end + + private + + def date_from + params[:from].present? ? Date.parse(params[:from]).beginning_of_day : 30.days.ago + rescue Date::Error + 30.days.ago + end + + def date_to + params[:to].present? ? Date.parse(params[:to]).end_of_day : Time.current + rescue Date::Error + Time.current + end + + def add_breadcrumbs + return unless defined?(::Hyrax) + + add_breadcrumb t(:'hyrax.controls.home'), main_app.root_path + add_breadcrumb t(:'hyrax.dashboard.breadcrumbs.admin'), hyrax.dashboard_path + add_breadcrumb t('bulkrax.admin.sidebar.importers'), bulkrax.importers_path + add_breadcrumb t('bulkrax.import_metrics.breadcrumb') + end + + def check_metrics_enabled + head :not_found unless Bulkrax.config.guided_import_metrics_enabled + end + + def check_permissions + raise CanCan::AccessDenied unless current_ability.can_read_bulkrax_metrics? + end + + def generate_csv(aggregator) + CSV.generate do |csv| + csv << %w[id metric_type event importer_id user_id session_id created_at payload] + aggregator.export_rows.each { |row| csv << row.values } + end + end + end +end diff --git a/app/controllers/concerns/bulkrax/datatables_behavior.rb b/app/controllers/concerns/bulkrax/datatables_behavior.rb index 570ac18db..f91b91ef2 100644 --- a/app/controllers/concerns/bulkrax/datatables_behavior.rb +++ b/app/controllers/concerns/bulkrax/datatables_behavior.rb @@ -152,15 +152,7 @@ def entry_util_links(e, item) end def status_message_for(e) - if e.status_message == "Complete" - " #{e.status_message}" - elsif e.status_message == "Pending" - " #{e.status_message}" - elsif e.status_message == "Skipped" - " #{e.status_message}" - else - " #{e.status_message}" - end + "#{view_context.status_badge(e.status_message)}" end def importer_util_links(i) diff --git a/app/controllers/concerns/bulkrax/guided_import_metrics.rb b/app/controllers/concerns/bulkrax/guided_import_metrics.rb new file mode 100644 index 000000000..672e6808b --- /dev/null +++ b/app/controllers/concerns/bulkrax/guided_import_metrics.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module Bulkrax + module GuidedImportMetrics + extend ActiveSupport::Concern + + private + + def record_validation_metric(result, duration_ms) + return unless Bulkrax.config.guided_import_metrics_enabled + + outcome = if result[:isValid] + result[:hasWarnings] ? 'pass_with_warnings' : 'pass' + else + 'fail' + end + + Bulkrax::ImportMetric.record( + metric_type: 'validation', + event: 'validation_complete', + user: current_user, + session_id: params[:metrics_session_id], + payload: validation_metric_payload(result, outcome, duration_ms) + ) + end + + def validation_metric_payload(result, outcome, duration_ms) + row_errors = Array(result[:rowErrors]) + { + outcome: outcome, + row_count: result[:rowCount].to_i, + duration_ms: duration_ms, + missing_required_count: Array(result[:missingRequired]).size, + unrecognized_count: result[:unrecognized]&.size || 0, + empty_columns_count: Array(result[:emptyColumns]).size, + row_error_count: row_errors.count { |e| e[:severity] == 'error' }, + row_warning_count: row_errors.count { |e| e[:severity] == 'warning' }, + notice_count: Array(result[:notices]).size, + has_zip: result[:zipIncluded].present?, + missing_files_count: Array(result[:missingFiles]).size, + error_types: extract_error_types(result), + warning_types: extract_warning_types(result) + } + end + + def extract_error_types(result) + types = [] + types << 'missing_required_fields' if Array(result[:missingRequired]).any? + types << 'missing_files' if Array(result[:missingFiles]).any? + types << 'row_errors' if Array(result[:rowErrors]).any? { |e| e[:severity] == 'error' } + types + end + + def extract_warning_types(result) + types = [] + types << 'unrecognized_fields' if result[:unrecognized]&.any? + types << 'empty_columns' if Array(result[:emptyColumns]).any? + types << 'row_warnings' if Array(result[:rowErrors]).any? { |e| e[:severity] == 'warning' } + types << 'notices' if Array(result[:notices]).any? + types + end + + def cache_validation_errors(validation_result, raw_csv_data, csv_file) + has_errors = validation_result[:rowErrors]&.any? || + validation_result[:missingRequired]&.any? || + validation_result[:unrecognized]&.any? || + validation_result[:emptyColumns]&.any? || + validation_result[:missingFiles]&.any? + return nil unless has_errors + + key = "guided_import_errors:#{session.id}:#{Time.now.to_i}" + Rails.cache.write( + key, + { + headers: validation_result[:headers], + csv_data: raw_csv_data, + row_errors: validation_result[:rowErrors] || [], + file_errors: { + missing_required: validation_result[:missingRequired] || [], + unrecognized: validation_result[:unrecognized] || {}, + empty_columns: validation_result[:emptyColumns] || [], + missing_files: validation_result[:missingFiles] || [] + }, + original_filename: filename_for(csv_file) + }, + expires_in: 1.hour + ) + key + end + end +end diff --git a/app/helpers/bulkrax/application_helper.rb b/app/helpers/bulkrax/application_helper.rb index 9418043c1..75799639b 100644 --- a/app/helpers/bulkrax/application_helper.rb +++ b/app/helpers/bulkrax/application_helper.rb @@ -9,6 +9,30 @@ def an_importer?(item) item.class.to_s.include?('Importer') end + # Returns a Bootstrap badge for the given status_message string. + # Used by the importers datatable and the metrics dashboard. + def status_badge(status_message) + case status_message + when 'Complete' + icon = 'fa-check' + color = 'green' + when 'Pending' + icon = 'fa-ellipsis-h' + color = 'blue' + when 'Skipped' + icon = 'fa-step-forward' + color = 'yellow' + when 'Deleted' + icon = 'fa-remove' + color = 'green' + else + icon = 'fa-remove' + color = 'red' + end + + " #{ERB::Util.html_escape(status_message || '--')}".html_safe # rubocop:disable Rails/OutputSafety + end + def coderay(value, opts) CodeRay .scan(value, :ruby) diff --git a/app/models/bulkrax/import_metric.rb b/app/models/bulkrax/import_metric.rb new file mode 100644 index 000000000..a779e719e --- /dev/null +++ b/app/models/bulkrax/import_metric.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Bulkrax + class ImportMetric < ApplicationRecord + self.table_name = 'bulkrax_import_metrics' + + belongs_to :importer, class_name: 'Bulkrax::Importer', optional: true + belongs_to :user, optional: true + + validates :metric_type, presence: true, + inclusion: { in: %w[funnel validation import_outcome feedback timing] } + validates :event, presence: true + + scope :funnel, -> { where(metric_type: 'funnel') } + scope :validations, -> { where(metric_type: 'validation') } + scope :import_outcomes, -> { where(metric_type: 'import_outcome') } + scope :feedback, -> { where(metric_type: 'feedback') } + scope :timing, -> { where(metric_type: 'timing') } + scope :in_range, ->(from, to) { where(created_at: from..to) } + + # Fire-and-forget recording. NEVER raises. + # @param attrs [Hash] must include :metric_type and :event; optional :importer, :user, :session_id, :payload + def self.record(**attrs) + attrs[:payload] ||= {} + create(attrs) + rescue StandardError => e + Rails.logger.warn("Bulkrax::ImportMetric.record failed: #{e.message}") + nil + end + end +end diff --git a/app/models/bulkrax/importer.rb b/app/models/bulkrax/importer.rb index 641907dd8..633ac2db8 100644 --- a/app/models/bulkrax/importer.rb +++ b/app/models/bulkrax/importer.rb @@ -59,6 +59,7 @@ def record_status else importer_run.importer.set_status_info('Complete') end + record_import_outcome_metric(importer_run) end # If field_mapping is empty, setup a default based on the export_properties @@ -239,6 +240,7 @@ def import_objects(types_array = nil) mark_unseen_as_skipped rescue StandardError => e set_status_info(e) + record_import_outcome_metric(current_run) if current_run end # After an import any entries we did not touch are skipped. @@ -296,6 +298,35 @@ def path_string private + def record_import_outcome_metric(run) + return unless run + return unless Bulkrax.config.guided_import_metrics_enabled + + Bulkrax::ImportMetric.record( + metric_type: 'import_outcome', + event: 'import_complete', + importer: self, + user: self.user, + session_id: self.parser_fields&.dig('metrics_session_id'), + payload: import_outcome_payload(run) + ) + end + + def import_outcome_payload(run) + { + total_work_entries: run.total_work_entries.to_i, + total_collection_entries: run.total_collection_entries.to_i, + total_file_set_entries: run.total_file_set_entries.to_i, + processed_works: run.processed_works.to_i, + failed_works: run.failed_works.to_i, + failed_records: run.failed_records.to_i, + duration_seconds: run.updated_at && run.created_at ? (run.updated_at - run.created_at).to_i : 0, + is_first_attempt: self.importer_runs.count == 1, + run_number: self.importer_runs.count, + used_guided_import: self.parser_fields&.dig('guided_import') == true + } + end + # Adding this here since we can update the importer without running the importer. # When we simply save the importer (as in just updating the importer from the options), # it does not trigger the after_save callback in the importer_run. diff --git a/app/services/bulkrax/metrics_aggregator.rb b/app/services/bulkrax/metrics_aggregator.rb new file mode 100644 index 000000000..e57676884 --- /dev/null +++ b/app/services/bulkrax/metrics_aggregator.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +module Bulkrax + class MetricsAggregator + attr_reader :from, :to + + def initialize(from: 30.days.ago, to: Time.current) + @from = from + @to = to + end + + def total_imports + ImportMetric.import_outcomes.in_range(from, to).count + end + + def first_attempt_success_rate + outcomes = ImportMetric.import_outcomes.in_range(from, to) + .joins("INNER JOIN bulkrax_importers ON bulkrax_importers.id = bulkrax_import_metrics.importer_id") + .where("payload->>'is_first_attempt' = ?", 'true') + total = outcomes.count + return 0.0 if total.zero? + successes = outcomes.where(bulkrax_importers: { status_message: 'Complete' }).count + (successes.to_f / total * 100).round(1) + end + + def avg_validation_duration_ms + ImportMetric.validations.in_range(from, to) + .average("(payload->>'duration_ms')::integer")&.round(0).to_i + end + + def validation_outcomes + ImportMetric.validations.in_range(from, to) + .group("payload->>'outcome'") + .count + end + + def funnel_data + ImportMetric.funnel.in_range(from, to) + .group("(payload->>'step')::integer") + .count + end + + def error_type_frequencies + sql = <<-SQL + SELECT elem AS error_type, COUNT(*) AS cnt + FROM bulkrax_import_metrics, + jsonb_array_elements_text(payload->'error_types') AS elem + WHERE metric_type = 'validation' + AND created_at BETWEEN ? AND ? + GROUP BY elem + ORDER BY cnt DESC + LIMIT 10 + SQL + ImportMetric.find_by_sql([sql, from, to]) + end + + def avg_seq_rating + ImportMetric.feedback.in_range(from, to) + .average("(payload->>'seq_rating')::integer")&.round(1).to_f + end + + def seq_distribution + ImportMetric.feedback.in_range(from, to) + .group("(payload->>'seq_rating')::integer") + .count + end + + def seq_response_count + ImportMetric.feedback.in_range(from, to).count + end + + def recent_comments(limit: 20) + ImportMetric.feedback.in_range(from, to) + .where("payload->>'comment' IS NOT NULL") + .where("payload->>'comment' != ''") + .order(created_at: :desc) + .limit(limit) + .pluck(:payload, :created_at) + .map { |p, t| { rating: p['seq_rating'], comment: p['comment'], date: t } } + end + + def imports_over_time + ImportMetric.import_outcomes.in_range(from, to) + .joins("INNER JOIN bulkrax_importers ON bulkrax_importers.id = bulkrax_import_metrics.importer_id") + .group("date_trunc('day', bulkrax_import_metrics.created_at)") + .group("bulkrax_importers.status_message") + .count + end + + def recent_imports(limit: 50) + ImportMetric.import_outcomes.in_range(from, to) + .order(created_at: :desc) + .limit(limit) + .includes(:importer, :user) + end + + def validation_to_outcome_correlation + sql = <<-SQL + SELECT + v.payload->>'outcome' AS validation_outcome, + i.status_message AS import_outcome, + COUNT(*) AS cnt + FROM bulkrax_import_metrics v + JOIN bulkrax_import_metrics o ON v.session_id = o.session_id + JOIN bulkrax_importers i ON i.id = o.importer_id + WHERE v.metric_type = 'validation' + AND v.event = 'validation_complete' + AND o.metric_type = 'import_outcome' + AND o.event = 'import_complete' + AND v.session_id IS NOT NULL + AND v.created_at BETWEEN ? AND ? + GROUP BY v.payload->>'outcome', i.status_message + ORDER BY validation_outcome, import_outcome + SQL + ImportMetric.find_by_sql([sql, from, to]) + end + + def export_rows + ImportMetric.in_range(from, to).order(:created_at).map do |m| + { + id: m.id, + metric_type: m.metric_type, + event: m.event, + importer_id: m.importer_id, + user_id: m.user_id, + session_id: m.session_id, + created_at: m.created_at.iso8601, + payload: m.payload.to_json + } + end + end + end +end diff --git a/app/views/bulkrax/guided_imports/new.html.erb b/app/views/bulkrax/guided_imports/new.html.erb index f27e1f4db..66e95b01a 100644 --- a/app/views/bulkrax/guided_imports/new.html.erb +++ b/app/views/bulkrax/guided_imports/new.html.erb @@ -14,7 +14,9 @@ window.BulkraxI18n = <%= raw I18n.t('bulkrax.importer.guided_import.js').to_json %>; -
+
>
@@ -57,12 +59,78 @@
+ + +
<%= form_for [:bulkrax, Bulkrax::Importer.new], url: guided_import_create_path, method: :post, html: { id: 'bulk-import-stepper-form', multipart: true } do |f| %> <%= hidden_field_tag 'locale', I18n.locale %> + <% if Bulkrax.config.guided_import_metrics_enabled %> + <%= hidden_field_tag 'metrics_session_id', '', id: 'metrics-session-id' %> + <% end %>
diff --git a/app/views/bulkrax/import_metrics/index.html.erb b/app/views/bulkrax/import_metrics/index.html.erb new file mode 100644 index 000000000..d4bc5bb5a --- /dev/null +++ b/app/views/bulkrax/import_metrics/index.html.erb @@ -0,0 +1,416 @@ +<% provide :page_header do %> +

<%= t('bulkrax.import_metrics.page_title') %>

+

<%= t('bulkrax.import_metrics.page_subtitle') %>

+<% end %> + +<% content_for(:head) do %> + + + + + + + +<% end %> + +<%# Date filter bar %> +
+
+ <%= form_tag import_metrics_path, method: :get, class: 'form-inline flex-wrap' do %> +
+ + +
+
+ + +
+
+
+ + + + +
+
+ <%= submit_tag t('bulkrax.import_metrics.date_filter.apply'), class: 'btn btn-primary btn-sm mr-2 mb-2' %> + <%= link_to import_metrics_export_path(from: @date_from.to_date.iso8601, to: @date_to.to_date.iso8601), + class: 'btn btn-outline-secondary btn-sm mb-2' do %> + <%= t('bulkrax.import_metrics.date_filter.export_csv') %> + <% end %> + <% end %> +
+
+ +<%# Summary cards %> +
+
+
+
+
<%= @aggregator.total_imports %>
+
<%= t('bulkrax.import_metrics.summary_cards.total_imports') %>
+
+
+
+
+
+
<%= @aggregator.first_attempt_success_rate %>%
+
<%= t('bulkrax.import_metrics.summary_cards.success_rate') %>
+
+
+
+
+
+ <% avg_ms = @aggregator.avg_validation_duration_ms %> +
<%= avg_ms > 0 ? "#{(avg_ms / 1000.0).round(1)}s" : '--' %>
+
<%= t('bulkrax.import_metrics.summary_cards.avg_validation') %>
+
+
+
+
+
+ <% seq_avg = @aggregator.avg_seq_rating %> +
<%= seq_avg > 0 ? seq_avg : '--' %>
+
<%= t('bulkrax.import_metrics.summary_cards.seq_average') %>
+
+
+
+ +<%# Tabbed content %> +<% if @aggregator.total_imports.zero? && @aggregator.seq_response_count.zero? %> +
+
+

<%= t('bulkrax.import_metrics.empty_state.title') %>

+

<%= t('bulkrax.import_metrics.empty_state.message') %>

+
+<% else %> +
+ + +
+ + <%# Trends tab %> + + + <%# Funnel tab %> +
+

<%= t('bulkrax.import_metrics.funnel.title') %>

+

<%= t('bulkrax.import_metrics.funnel.description') %>

+ <% funnel = @aggregator.funnel_data %> + <% if funnel.any? %> + <% max_count = funnel.values.max.to_f %> + <% funnel_steps = [ + [1, t('bulkrax.import_metrics.funnel.step1')], + [2, t('bulkrax.import_metrics.funnel.step2')], + [3, t('bulkrax.import_metrics.funnel.step3')], + [4, t('bulkrax.import_metrics.funnel.step4')] + ] %> + <% funnel_steps.each do |step_num, label| %> + <% count = funnel[step_num] || 0 %> + <% pct = max_count > 0 ? (count / max_count * 100).round : 0 %> +
+
<%= label %>
+
+
<%= pct %>%
+
+
<%= count %>
+
+ <% end %> + <% else %> +

<%= t('bulkrax.import_metrics.funnel.no_data') %>

+ <% end %> +

+ + <%= t('bulkrax.import_metrics.funnel.hint') %> +

+
+ + <%# Errors tab %> +
+

<%= t('bulkrax.import_metrics.errors.title') %>

+ <% error_types = @aggregator.error_type_frequencies %> + <% if error_types.any? %> +
+
+ + + + + + + + + <% error_types.each do |row| %> + + + + + <% end %> + +
<%= t('bulkrax.import_metrics.errors.error_type') %><%= t('bulkrax.import_metrics.errors.count') %>
<%= row.error_type.humanize %><%= row.cnt %>
+
+ <% else %> +

<%= t('bulkrax.import_metrics.errors.no_data') %>

+ <% end %> +
+ + <%# Feedback tab %> +
+ <% comments = @aggregator.recent_comments %> + <% seq_dist = @aggregator.seq_distribution %> +
+
+

<%= t('bulkrax.import_metrics.feedback.distribution_title') %>

+ <% if seq_dist.any? %> +
+ <% else %> +

<%= t('bulkrax.import_metrics.feedback.no_data') %>

+ <% end %> +
+
+

<%= t('bulkrax.import_metrics.feedback.summary_title') %>

+
+

<%= t('bulkrax.import_metrics.feedback.average_score') %> <%= @aggregator.avg_seq_rating > 0 ? "#{@aggregator.avg_seq_rating} / 7" : '--' %>

+

<%= t('bulkrax.import_metrics.feedback.total_responses') %> <%= @aggregator.seq_response_count %>

+
+ <% if comments.any? %> +

<%= t('bulkrax.import_metrics.feedback.recent_comments') %>

+
+ <% comments.each do |c| %> + <% badge_class = c[:rating].to_i >= 6 ? 'badge-success' : (c[:rating].to_i >= 4 ? 'badge-warning' : 'badge-danger') %> +
+ <%= c[:rating] %> +
"<%= c[:comment] %>"
+ <%= c[:date].strftime('%b %-d') %> +
+ <% end %> +
+ <% end %> +
+
+
+ + <%# Recent imports tab %> +
+

<%= t('bulkrax.import_metrics.recent_imports.title') %>

+
+ + + + + + + + + + + + <% @aggregator.recent_imports.each do |metric| %> + <% payload = metric.payload %> + <% live_status = metric.importer&.status_message %> + <% run_number = payload['run_number'] %> + + + + + + + + <% end %> + +
<%= t('bulkrax.import_metrics.recent_imports.importer') %><%= t('bulkrax.import_metrics.recent_imports.outcome') %><%= t('bulkrax.import_metrics.recent_imports.records') %><%= t('bulkrax.import_metrics.recent_imports.first_attempt') %><%= t('bulkrax.import_metrics.recent_imports.date') %>
+ <% if metric.importer %> + <%= link_to bulkrax.importer_path(metric.importer) do %> + <%= metric.importer.name %><% if run_number %> (Run #<%= run_number %>)<% end %> + <% end %> + <% else %> + #<%= metric.importer_id %> + <% end %> + <%= status_badge(live_status) %><%= payload['total_work_entries'].to_i + payload['total_collection_entries'].to_i %><%= payload['is_first_attempt'] ? 'Yes' : 'No' %><%= metric.created_at.strftime('%b %-d, %Y') %>
+
+
+ +
+
+<% end %> + + diff --git a/app/views/hyrax/dashboard/sidebar/_bulkrax_sidebar_additions.html.erb b/app/views/hyrax/dashboard/sidebar/_bulkrax_sidebar_additions.html.erb index 16560afec..4a54dfd4a 100644 --- a/app/views/hyrax/dashboard/sidebar/_bulkrax_sidebar_additions.html.erb +++ b/app/views/hyrax/dashboard/sidebar/_bulkrax_sidebar_additions.html.erb @@ -18,3 +18,13 @@ <%= t('bulkrax.admin.sidebar.exporters') %> <% end %> <% end %> +<% if Bulkrax.config.guided_import_metrics_enabled && current_ability.can_read_bulkrax_metrics? %> + <%= menu.nav_link( + bulkrax.import_metrics_path, + title: t('bulkrax.admin.sidebar.import_metrics', default: 'Guided Import Metrics'), + class: "nav-link", + onclick: "dontChangeAccordion(event);" + ) do %> + <%= t('bulkrax.admin.sidebar.import_metrics', default: 'Guided Import Metrics') %> + <% end %> +<% end %> diff --git a/config/locales/bulkrax.de.yml b/config/locales/bulkrax.de.yml index 0c895b718..402bc154e 100644 --- a/config/locales/bulkrax.de.yml +++ b/config/locales/bulkrax.de.yml @@ -5,7 +5,63 @@ de: sidebar: exporters: Exporteure importers: Importeure + import_metrics: Metriken für geführten Import cancel: Stornieren + import_metrics: + breadcrumb: Metriken für geführten Import + page_title: Metriken für geführten Import + page_subtitle: Verfolgen Sie Erfolgsraten, Validierungsleistung und Nutzerfeedback für den geführten Importer. + date_filter: + from: Von + to: Bis + all: Alle + apply: Anwenden + export_csv: CSV exportieren + summary_cards: + total_imports: Importe gesamt + success_rate: Erfolgsrate beim ersten Versuch + avg_validation: Durchschn. Validierungszeit + seq_average: "SEQ-Durchschnitt (1–7)" + empty_state: + title: Noch keine Metriken + message: Daten werden hier angezeigt, sobald Nutzer geführte Importe durchführen. + tabs: + trends: Trends + funnel: Trichter + errors: Fehler + feedback: Nutzerfeedback + recent: Letzte Importe + trends: + title: Importe im Zeitverlauf + hint: "Grün = erfolgreiche Importe, Rot = fehlgeschlagene/unvollständige Importe." + funnel: + title: Import-Trichter + description: Wie viele Nutzer jeden Schritt des geführten Import-Assistenten erreichen. + step1: "Schritt 1: Hochladen & Validieren" + step2: "Schritt 2: Konfigurieren" + step3: "Schritt 3: Überprüfen" + step4: Abgesendet + no_data: Keine Trichterdaten in diesem Zeitraum. + hint: Abbrüche zwischen den Schritten zeigen, wo Nutzer den Importvorgang abbrechen. + errors: + title: Häufigste Fehlertypen + error_type: Fehlertyp + count: Anzahl + no_data: Keine Validierungsfehler in diesem Zeitraum. + feedback: + distribution_title: SEQ-Score-Verteilung + summary_title: Antwortübersicht + average_score: "Durchschnittlicher Score:" + total_responses: "Antworten gesamt:" + recent_comments: Neueste Kommentare + no_data: Keine Feedbackantworten in diesem Zeitraum. + recent_imports: + title: Letzte geführte Importe + importer: Importer + outcome: Ergebnis + records: Datensätze + first_attempt: Erster Versuch + date: Datum entry: labels: item_link: Artikel-Link @@ -280,7 +336,7 @@ de: title_errors: Zeilenvalidierungsfehler title_warnings: Zeilenvalidierungswarnungen success: - message: Ihr Importvorgang wird derzeit im Hintergrund verarbeitet. Sie werden benachrichtigt, sobald er abgeschlossen ist. + message: Ihr Import wird jetzt im Hintergrund verarbeitet. Sie können den Fortschritt in der Importwarteschlange verfolgen. start_another: Einen weiteren Import starten title: Import gestartet view_queue: Importwarteschlange anzeigen diff --git a/config/locales/bulkrax.en.yml b/config/locales/bulkrax.en.yml index d735cff60..027e3d988 100644 --- a/config/locales/bulkrax.en.yml +++ b/config/locales/bulkrax.en.yml @@ -4,7 +4,62 @@ en: admin: sidebar: exporters: Exporters + import_metrics: Guided Import Metrics importers: Importers + import_metrics: + breadcrumb: Guided Import Metrics + page_title: Guided Import Metrics + page_subtitle: Track import success rates, validation performance, and user feedback for the guided importer. + date_filter: + from: From + to: To + apply: Apply + export_csv: Export CSV + summary_cards: + total_imports: Total Imports + success_rate: First-Attempt Success Rate + avg_validation: Avg Validation Time + seq_average: SEQ Average (1–7) + empty_state: + title: No metrics yet + message: Data will appear here once users start running guided imports. + tabs: + trends: Trends + funnel: Funnel + errors: Errors + feedback: User Feedback + recent: Recent Imports + trends: + title: Imports Over Time + hint: Green = successful imports, Red = failed/partial imports. + funnel: + title: Import Funnel + description: How many users reach each step of the guided import wizard. + step1: "Step 1: Upload & Validate" + step2: "Step 2: Configure" + step3: "Step 3: Review" + step4: Submitted + no_data: No funnel data in this date range. + hint: Drop-off between steps shows where users abandon the import process. + errors: + title: Top Error Types + error_type: Error Type + count: Count + no_data: No validation errors in this date range. + feedback: + distribution_title: SEQ Score Distribution + summary_title: Response Summary + average_score: "Average Score:" + total_responses: "Total Responses:" + recent_comments: Recent Comments + no_data: No feedback responses in this date range. + recent_imports: + title: Recent Guided Imports + importer: Importer + outcome: Outcome + records: Records + first_attempt: First Attempt + date: Date cancel: Cancel entry: labels: @@ -307,7 +362,7 @@ en: title_errors: Row Validation Errors title_warnings: Row Validation Warnings success: - message: Your import is now processing in the background. You'll be notified when it's complete. + message: Your import is now processing in the background. You can monitor progress in the Import Queue. start_another: Start Another Import title: Import Started view_queue: View Import Queue diff --git a/config/locales/bulkrax.es.yml b/config/locales/bulkrax.es.yml index e2e12c16a..f0ac19e0c 100644 --- a/config/locales/bulkrax.es.yml +++ b/config/locales/bulkrax.es.yml @@ -5,7 +5,63 @@ es: sidebar: exporters: Exportadores importers: Importadores + import_metrics: Métricas de importación guiada cancel: Cancelar + import_metrics: + breadcrumb: Métricas de importación guiada + page_title: Métricas de importación guiada + page_subtitle: Seguimiento de tasas de éxito, rendimiento de validación y comentarios de usuarios del importador guiado. + date_filter: + from: Desde + to: Hasta + all: Todos + apply: Aplicar + export_csv: Exportar CSV + summary_cards: + total_imports: Importaciones totales + success_rate: Tasa de éxito en el primer intento + avg_validation: Tiempo medio de validación + seq_average: "Promedio SEQ (1–7)" + empty_state: + title: Aún no hay métricas + message: Los datos aparecerán aquí una vez que los usuarios comiencen a ejecutar importaciones guiadas. + tabs: + trends: Tendencias + funnel: Embudo + errors: Errores + feedback: Comentarios de usuarios + recent: Importaciones recientes + trends: + title: Importaciones a lo largo del tiempo + hint: "Verde = importaciones exitosas, Rojo = importaciones fallidas/parciales." + funnel: + title: Embudo de importación + description: Cuántos usuarios llegan a cada paso del asistente de importación guiada. + step1: "Paso 1: Cargar y validar" + step2: "Paso 2: Configurar" + step3: "Paso 3: Revisar" + step4: Enviado + no_data: No hay datos del embudo en este rango de fechas. + hint: La caída entre pasos muestra dónde los usuarios abandonan el proceso de importación. + errors: + title: Principales tipos de error + error_type: Tipo de error + count: Cantidad + no_data: No hay errores de validación en este rango de fechas. + feedback: + distribution_title: Distribución de puntuaciones SEQ + summary_title: Resumen de respuestas + average_score: "Puntuación media:" + total_responses: "Respuestas totales:" + recent_comments: Comentarios recientes + no_data: No hay respuestas de comentarios en este rango de fechas. + recent_imports: + title: Importaciones guiadas recientes + importer: Importador + outcome: Resultado + records: Registros + first_attempt: Primer intento + date: Fecha entry: labels: item_link: Enlace del artículo @@ -280,7 +336,7 @@ es: title_errors: Errores de validación de filas title_warnings: Advertencias de validación de filas success: - message: Tu importación se está procesando en segundo plano. Recibirás una notificación cuando finalice. + message: Su importación se está procesando en segundo plano. Puede supervisar el progreso en la Cola de importación. start_another: Iniciar otra importación title: Importación iniciada view_queue: Ver cola de importación diff --git a/config/locales/bulkrax.fr.yml b/config/locales/bulkrax.fr.yml index 40005e73c..39ce5c0b2 100644 --- a/config/locales/bulkrax.fr.yml +++ b/config/locales/bulkrax.fr.yml @@ -4,8 +4,64 @@ fr: admin: sidebar: exporters: Exportateurs + import_metrics: "Métriques d'import guidé" importers: Importateurs cancel: Annuler + import_metrics: + breadcrumb: "Métriques d'import guidé" + page_title: "Métriques d'import guidé" + page_subtitle: "Suivez les taux de réussite, les performances de validation et les retours utilisateurs de l'importateur guidé." + date_filter: + from: Du + to: Au + all: Tout + apply: Appliquer + export_csv: Exporter en CSV + summary_cards: + total_imports: Imports totaux + success_rate: Taux de réussite au premier essai + avg_validation: Temps moyen de validation + seq_average: "Moyenne SEQ (1–7)" + empty_state: + title: Aucune métrique pour le moment + message: "Les données apparaîtront ici dès que les utilisateurs lanceront des imports guidés." + tabs: + trends: Tendances + funnel: Entonnoir + errors: Erreurs + feedback: Retours utilisateurs + recent: Imports récents + trends: + title: Imports au fil du temps + hint: "Vert = imports réussis, Rouge = imports échoués/partiels." + funnel: + title: "Entonnoir d'import" + description: "Combien d'utilisateurs atteignent chaque étape de l'assistant d'import guidé." + step1: "Étape 1 : Téléverser et valider" + step2: "Étape 2 : Configurer" + step3: "Étape 3 : Vérifier" + step4: Soumis + no_data: Aucune donnée d'entonnoir pour cette période. + hint: "Les abandons entre les étapes montrent où les utilisateurs quittent le processus d'import." + errors: + title: "Principaux types d'erreurs" + error_type: "Type d'erreur" + count: Nombre + no_data: Aucune erreur de validation pour cette période. + feedback: + distribution_title: Distribution des scores SEQ + summary_title: Résumé des réponses + average_score: "Score moyen :" + total_responses: "Réponses totales :" + recent_comments: Commentaires récents + no_data: Aucune réponse de retour pour cette période. + recent_imports: + title: Imports guidés récents + importer: Importateur + outcome: Résultat + records: Enregistrements + first_attempt: Premier essai + date: Date entry: labels: item_link: Lien vers l'article @@ -280,7 +336,7 @@ fr: title_errors: Erreurs de validation de ligne title_warnings: Avertissements de validation de ligne success: - message: Votre importation est en cours de traitement en arrière-plan. Vous serez averti(e) lorsqu'elle sera terminée. + message: Votre import est en cours de traitement en arrière-plan. Vous pouvez suivre la progression dans la File d'attente d'import. start_another: Lancer une autre importation title: Importation commencée view_queue: Afficher la file d'importation diff --git a/config/locales/bulkrax.it.yml b/config/locales/bulkrax.it.yml index bd6697e53..90c30fa82 100644 --- a/config/locales/bulkrax.it.yml +++ b/config/locales/bulkrax.it.yml @@ -5,7 +5,63 @@ it: sidebar: exporters: Esportatori importers: Importatori + import_metrics: Metriche di importazione guidata cancel: Cancellare + import_metrics: + breadcrumb: Metriche di importazione guidata + page_title: Metriche di importazione guidata + page_subtitle: Monitora i tassi di successo, le prestazioni di validazione e il feedback degli utenti per l'importatore guidato. + date_filter: + from: Da + to: A + all: Tutti + apply: Applica + export_csv: Esporta CSV + summary_cards: + total_imports: Importazioni totali + success_rate: Tasso di successo al primo tentativo + avg_validation: Tempo medio di validazione + seq_average: "Media SEQ (1–7)" + empty_state: + title: Nessuna metrica disponibile + message: I dati appariranno qui quando gli utenti inizieranno a eseguire importazioni guidate. + tabs: + trends: Tendenze + funnel: Imbuto + errors: Errori + feedback: Feedback utenti + recent: Importazioni recenti + trends: + title: Importazioni nel tempo + hint: "Verde = importazioni riuscite, Rosso = importazioni fallite/parziali." + funnel: + title: Imbuto di importazione + description: Quanti utenti raggiungono ogni fase della procedura guidata di importazione. + step1: "Fase 1: Caricamento e validazione" + step2: "Fase 2: Configurazione" + step3: "Fase 3: Revisione" + step4: Inviato + no_data: Nessun dato dell'imbuto in questo intervallo di date. + hint: "L'abbandono tra le fasi mostra dove gli utenti interrompono il processo di importazione." + errors: + title: Principali tipi di errore + error_type: Tipo di errore + count: Conteggio + no_data: Nessun errore di validazione in questo intervallo di date. + feedback: + distribution_title: Distribuzione dei punteggi SEQ + summary_title: Riepilogo delle risposte + average_score: "Punteggio medio:" + total_responses: "Risposte totali:" + recent_comments: Commenti recenti + no_data: Nessuna risposta di feedback in questo intervallo di date. + recent_imports: + title: Importazioni guidate recenti + importer: Importatore + outcome: Esito + records: Record + first_attempt: Primo tentativo + date: Data entry: labels: item_link: Collegamento all'articolo @@ -280,7 +336,7 @@ it: title_errors: Errori di validazione delle righe title_warnings: Avvisi di validazione delle righe success: - message: L'importazione è in corso di elaborazione in background. Riceverai una notifica al termine dell'operazione. + message: L'importazione è ora in elaborazione in background. È possibile monitorare l'avanzamento nella Coda di importazione. start_another: Avvia un'altra importazione title: Importazione avviata view_queue: Visualizza coda di importazione diff --git a/config/locales/bulkrax.pt-BR.yml b/config/locales/bulkrax.pt-BR.yml index da254ab24..0fd242122 100644 --- a/config/locales/bulkrax.pt-BR.yml +++ b/config/locales/bulkrax.pt-BR.yml @@ -5,7 +5,63 @@ pt-BR: sidebar: exporters: Exportadores importers: Importadores + import_metrics: Métricas de importação guiada cancel: Cancelar + import_metrics: + breadcrumb: Métricas de importação guiada + page_title: Métricas de importação guiada + page_subtitle: Acompanhe taxas de sucesso, desempenho de validação e feedback dos usuários do importador guiado. + date_filter: + from: De + to: Até + all: Todos + apply: Aplicar + export_csv: Exportar CSV + summary_cards: + total_imports: Total de importações + success_rate: Taxa de sucesso na primeira tentativa + avg_validation: Tempo médio de validação + seq_average: "Média SEQ (1–7)" + empty_state: + title: Nenhuma métrica ainda + message: Os dados aparecerão aqui assim que os usuários começarem a executar importações guiadas. + tabs: + trends: Tendências + funnel: Funil + errors: Erros + feedback: Feedback de usuários + recent: Importações recentes + trends: + title: Importações ao longo do tempo + hint: "Verde = importações bem-sucedidas, Vermelho = importações com falha/parciais." + funnel: + title: Funil de importação + description: Quantos usuários chegam a cada etapa do assistente de importação guiada. + step1: "Etapa 1: Upload e validação" + step2: "Etapa 2: Configuração" + step3: "Etapa 3: Revisão" + step4: Enviado + no_data: Nenhum dado de funil neste intervalo de datas. + hint: A desistência entre etapas mostra onde os usuários abandonam o processo de importação. + errors: + title: Principais tipos de erro + error_type: Tipo de erro + count: Quantidade + no_data: Nenhum erro de validação neste intervalo de datas. + feedback: + distribution_title: Distribuição de pontuações SEQ + summary_title: Resumo das respostas + average_score: "Pontuação média:" + total_responses: "Total de respostas:" + recent_comments: Comentários recentes + no_data: Nenhuma resposta de feedback neste intervalo de datas. + recent_imports: + title: Importações guiadas recentes + importer: Importador + outcome: Resultado + records: Registros + first_attempt: Primeira tentativa + date: Data entry: labels: item_link: Link do item @@ -280,7 +336,7 @@ pt-BR: title_errors: Erros de validação de linha title_warnings: Avisos de validação de linha success: - message: Sua importação está sendo processada em segundo plano. Você será notificado quando ela for concluída. + message: Sua importação está sendo processada em segundo plano. Você pode acompanhar o progresso na Fila de importação. start_another: Iniciar outra importação title: Importação iniciada view_queue: Visualizar fila de importação diff --git a/config/locales/bulkrax.zh.yml b/config/locales/bulkrax.zh.yml index 908f854bc..c1d5c4d26 100644 --- a/config/locales/bulkrax.zh.yml +++ b/config/locales/bulkrax.zh.yml @@ -4,8 +4,64 @@ zh: admin: sidebar: exporters: 出口商 + import_metrics: 引导式导入指标 importers: 进口商 cancel: 取消 + import_metrics: + breadcrumb: 引导式导入指标 + page_title: 引导式导入指标 + page_subtitle: 跟踪引导式导入器的成功率、验证性能和用户反馈。 + date_filter: + from: 从 + to: 至 + all: 全部 + apply: 应用 + export_csv: 导出 CSV + summary_cards: + total_imports: 导入总数 + success_rate: 首次成功率 + avg_validation: 平均验证时间 + seq_average: "SEQ 平均分(1–7)" + empty_state: + title: 暂无指标数据 + message: 用户开始执行引导式导入后,数据将在此处显示。 + tabs: + trends: 趋势 + funnel: 漏斗 + errors: 错误 + feedback: 用户反馈 + recent: 近期导入 + trends: + title: 导入趋势 + hint: "绿色 = 成功导入,红色 = 失败/部分导入。" + funnel: + title: 导入漏斗 + description: 引导式导入向导各步骤的用户到达情况。 + step1: "第 1 步:上传与验证" + step2: "第 2 步:配置" + step3: "第 3 步:审核" + step4: 已提交 + no_data: 该日期范围内无漏斗数据。 + hint: 步骤间的流失表明用户在何处放弃了导入流程。 + errors: + title: 主要错误类型 + error_type: 错误类型 + count: 数量 + no_data: 该日期范围内无验证错误。 + feedback: + distribution_title: SEQ 评分分布 + summary_title: 反馈摘要 + average_score: "平均分:" + total_responses: "总响应数:" + recent_comments: 近期评论 + no_data: 该日期范围内无反馈数据。 + recent_imports: + title: 近期引导式导入 + importer: 导入器 + outcome: 结果 + records: 记录数 + first_attempt: 首次尝试 + date: 日期 entry: labels: item_link: 物品链接 @@ -279,7 +335,7 @@ zh: title_errors: 行校验错误 title_warnings: 行校验警告 success: - message: 您的导入操作正在后台处理中。完成后您将收到通知。 + message: 您的导入正在后台处理中。您可以在导入队列中监控进度。 start_another: 开始另一次导入 title: 导入开始 view_queue: 查看导入队列 diff --git a/config/routes.rb b/config/routes.rb index 186e96c28..ff9c91b87 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -14,8 +14,11 @@ get 'new/guided_import', to: 'guided_imports#new', as: :guided_import_new post 'guided_import', to: 'guided_imports#create', as: :guided_import_create post 'guided_import/validate', to: 'guided_imports#validate', as: :guided_import_validate + post 'guided_import/metrics', to: 'import_metrics#record_metric', as: :guided_import_metrics get 'guided_import/download_validation_errors', to: 'guided_imports#download_validation_errors', as: :guided_import_download_validation_errors get 'guided_import/demo_scenarios', to: 'guided_imports#demo_scenarios', as: :guided_import_demo_scenarios if Bulkrax.config.guided_import_demo_scenarios_enabled + get 'metrics', to: 'import_metrics#index', as: :import_metrics + get 'metrics/export', to: 'import_metrics#export', as: :import_metrics_export end resources :importers do diff --git a/db/migrate/20260403000000_create_bulkrax_import_metrics.rb b/db/migrate/20260403000000_create_bulkrax_import_metrics.rb new file mode 100644 index 000000000..9862764a6 --- /dev/null +++ b/db/migrate/20260403000000_create_bulkrax_import_metrics.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class CreateBulkraxImportMetrics < ActiveRecord::Migration[5.1] + def change + create_table :bulkrax_import_metrics do |t| + t.string :metric_type, null: false + t.string :event, null: false + t.references :importer, foreign_key: { to_table: :bulkrax_importers }, null: true + t.references :user, foreign_key: false, null: true + t.string :session_id + t.jsonb :payload, default: {} + t.timestamps + end + + add_index :bulkrax_import_metrics, :metric_type + add_index :bulkrax_import_metrics, :event + add_index :bulkrax_import_metrics, :created_at + add_index :bulkrax_import_metrics, [:metric_type, :created_at] + end +end diff --git a/docs/metrics-query-reference.md b/docs/metrics-query-reference.md new file mode 100644 index 000000000..76ffc79f5 --- /dev/null +++ b/docs/metrics-query-reference.md @@ -0,0 +1,343 @@ +# Bulkrax Import Metrics - Query Reference + +## Table Schema + +All metrics are stored in `bulkrax_import_metrics`: + +| Column | Type | Description | +|---|---|---| +| `id` | integer | Primary key | +| `metric_type` | string | One of: `funnel`, `validation`, `import_outcome`, `feedback`, `timing` | +| `event` | string | Specific event name within the metric type | +| `importer_id` | integer | FK to `bulkrax_importers` (null for client-side metrics) | +| `user_id` | integer | FK to `users` (null for unauthenticated beacon calls) | +| `session_id` | string | Links all metrics from one guided import session (e.g. `gi_a1b2c3d4e`) | +| `payload` | jsonb | Event-specific data (varies by metric type) | +| `created_at` | timestamp | When the metric was recorded | + +## Model Scopes + +```ruby +Bulkrax::ImportMetric.funnel # metric_type = 'funnel' +Bulkrax::ImportMetric.validations # metric_type = 'validation' +Bulkrax::ImportMetric.import_outcomes # metric_type = 'import_outcome' +Bulkrax::ImportMetric.feedback # metric_type = 'feedback' +Bulkrax::ImportMetric.timing # metric_type = 'timing' +Bulkrax::ImportMetric.in_range(from, to) # created_at between from..to +``` + +--- + +## Metric Types and Their Payloads + +### 1. Funnel (`metric_type = 'funnel'`) + +Tracks which steps a user reaches in the guided import wizard. + +**Event:** `step_reached` + +| Payload Key | Type | Values | +|---|---|---| +| `step` | integer or string | `1`, `2`, `3`, or `"submitted"` | + +**Example queries:** + +```ruby +# Step completion counts (how many sessions reached each step) +ImportMetric.funnel.in_range(from, to) + .group("(payload->>'step')::integer") + .count +# => {1 => 150, 2 => 120, 3 => 95} + +# Drop-off between steps +funnel = ImportMetric.funnel.in_range(30.days.ago, Time.current) +step1 = funnel.where("payload->>'step' = '1'").count +step3 = funnel.where("payload->>'step' = '3'").count +drop_off_rate = ((step1 - step3).to_f / step1 * 100).round(1) + +# Sessions that reached submit +ImportMetric.funnel.in_range(from, to) + .where("payload->>'step' = ?", 'submitted') + .count +``` + +--- + +### 2. Validation (`metric_type = 'validation'`) + +Recorded server-side when a CSV validation completes. + +**Event:** `validation_complete` + +| Payload Key | Type | Description | +|---|---|---| +| `outcome` | string | `"pass"`, `"pass_with_warnings"`, or `"fail"` | +| `row_count` | integer | Number of rows in the CSV | +| `duration_ms` | integer | How long validation took | +| `missing_required_count` | integer | Count of missing required headers | +| `unrecognized_count` | integer | Count of unrecognized headers | +| `empty_columns_count` | integer | Count of empty column positions | +| `row_error_count` | integer | Count of row-level errors (severity = error) | +| `row_warning_count` | integer | Count of row-level warnings (severity = warning) | +| `notice_count` | integer | Count of informational notices | +| `has_zip` | boolean | Whether a ZIP file was included | +| `missing_files_count` | integer | Count of referenced files not found in ZIP | +| `error_types` | array | e.g. `["missing_required_fields", "missing_files", "row_errors"]` | +| `warning_types` | array | e.g. `["unrecognized_fields", "empty_columns", "row_warnings", "notices"]` | + +**Example queries:** + +```ruby +# Validation pass/fail breakdown +ImportMetric.validations.in_range(from, to) + .group("payload->>'outcome'") + .count +# => {"pass" => 80, "pass_with_warnings" => 30, "fail" => 40} + +# Average validation duration +ImportMetric.validations.in_range(from, to) + .average("(payload->>'duration_ms')::integer") + +# Most common error types +ImportMetric.find_by_sql([<<-SQL, from, to]) + SELECT elem AS error_type, COUNT(*) AS cnt + FROM bulkrax_import_metrics, + jsonb_array_elements_text(payload->'error_types') AS elem + WHERE metric_type = 'validation' + AND created_at BETWEEN ? AND ? + GROUP BY elem + ORDER BY cnt DESC +SQL + +# Most common warning types +ImportMetric.find_by_sql([<<-SQL, from, to]) + SELECT elem AS warning_type, COUNT(*) AS cnt + FROM bulkrax_import_metrics, + jsonb_array_elements_text(payload->'warning_types') AS elem + WHERE metric_type = 'validation' + AND created_at BETWEEN ? AND ? + GROUP BY elem + ORDER BY cnt DESC +SQL + +# Validations that had missing files +ImportMetric.validations.in_range(from, to) + .where("(payload->>'missing_files_count')::integer > 0") + .count +``` + +--- + +### 3. Import Outcome (`metric_type = 'import_outcome'`) + +Recorded server-side when an import run finishes (after all entries are processed). + +**Event:** `import_complete` + +| Payload Key | Type | Description | +|---|---|---| +| `outcome` | string | `"complete"`, `"partial"`, or `"failed"` | +| `total_work_entries` | integer | Total work entries in the run | +| `total_collection_entries` | integer | Total collection entries | +| `total_file_set_entries` | integer | Total file set entries | +| `processed_works` | integer | Successfully processed works | +| `failed_works` | integer | Failed works | +| `failed_records` | integer | Total failed records | +| `duration_seconds` | integer | Wall-clock time from run start to finish | +| `is_first_attempt` | boolean | Whether this was the importer's first run | +| `used_guided_import` | boolean | Whether the import was created via guided import | + +**Example queries:** + +```ruby +# Total imports in period +ImportMetric.import_outcomes.in_range(from, to).count + +# First-attempt success rate +outcomes = ImportMetric.import_outcomes.in_range(from, to) + .where("payload->>'is_first_attempt' = ?", 'true') +total = outcomes.count +successes = outcomes.where("payload->>'outcome' = ?", 'complete').count +rate = (successes.to_f / total * 100).round(1) + +# Guided-import-only outcomes +ImportMetric.import_outcomes.in_range(from, to) + .where("payload->>'used_guided_import' = ?", 'true') + .group("payload->>'outcome'") + .count + +# Imports over time (by day and outcome) +ImportMetric.import_outcomes.in_range(from, to) + .group("date_trunc('day', created_at)") + .group("payload->>'outcome'") + .count + +# Average import duration +ImportMetric.import_outcomes.in_range(from, to) + .average("(payload->>'duration_seconds')::integer") + +# Recent imports with associations +ImportMetric.import_outcomes.in_range(from, to) + .order(created_at: :desc) + .limit(50) + .includes(:importer, :user) +``` + +--- + +### 4. Feedback (`metric_type = 'feedback'`) + +Recorded client-side when a user submits a Single Ease Question (SEQ) rating after import. + +**Event:** `seq_rating` + +| Payload Key | Type | Description | +|---|---|---| +| `seq_rating` | integer | 1-7 rating (1 = very difficult, 7 = very easy) | +| `comment` | string | Optional free-text comment | +| `importer_id` | integer | ID of the importer that was just created | + +**Example queries:** + +```ruby +# Average SEQ rating +ImportMetric.feedback.in_range(from, to) + .average("(payload->>'seq_rating')::integer") + +# SEQ distribution (how many 1s, 2s, 3s, etc.) +ImportMetric.feedback.in_range(from, to) + .group("(payload->>'seq_rating')::integer") + .count +# => {1 => 2, 3 => 5, 5 => 20, 6 => 15, 7 => 30} + +# Response rate +total_imports = ImportMetric.import_outcomes.in_range(from, to).count +total_feedback = ImportMetric.feedback.in_range(from, to).count +response_rate = (total_feedback.to_f / total_imports * 100).round(1) + +# Recent comments +ImportMetric.feedback.in_range(from, to) + .where("payload->>'comment' IS NOT NULL") + .where("payload->>'comment' != ''") + .order(created_at: :desc) + .limit(20) + .pluck(:payload, :created_at) + .map { |p, t| { rating: p['seq_rating'], comment: p['comment'], date: t } } +``` + +--- + +### 5. Timing (`metric_type = 'timing'`) + +Recorded client-side at submit time. Captures how long the user spent on each step. + +**Event:** `session_complete` + +| Payload Key | Type | Description | +|---|---|---| +| `total_session_ms` | integer | Total time from step 1 load to submit | +| `step1_duration_ms` | integer | Time spent on step 1 (upload & validate) | +| `step2_duration_ms` | integer | Time spent on step 2 (review & configure) | +| `step3_duration_ms` | integer | Time spent on step 3 (confirm & import) | + +**Example queries:** + +```ruby +# Average total session time +ImportMetric.timing.in_range(from, to) + .average("(payload->>'total_session_ms')::integer") + +# Average time per step +(1..3).map do |step| + avg = ImportMetric.timing.in_range(from, to) + .average("(payload->>'step#{step}_duration_ms')::integer") + [step, avg&.round(0)] +end.to_h +# => {1 => 45000, 2 => 12000, 3 => 8000} +``` + +--- + +## Cross-Metric Queries Using session_id + +The `session_id` column links all metrics from a single guided import session. This enables correlation queries across metric types. + +### Validation outcome vs. import outcome + +```ruby +# Do validations that pass with warnings lead to more import failures? +ImportMetric.find_by_sql([<<-SQL, from, to]) + SELECT + v.payload->>'outcome' AS validation_outcome, + o.payload->>'outcome' AS import_outcome, + COUNT(*) AS cnt + FROM bulkrax_import_metrics v + JOIN bulkrax_import_metrics o ON v.session_id = o.session_id + WHERE v.metric_type = 'validation' + AND v.event = 'validation_complete' + AND o.metric_type = 'import_outcome' + AND o.event = 'import_complete' + AND v.session_id IS NOT NULL + AND v.created_at BETWEEN ? AND ? + GROUP BY v.payload->>'outcome', o.payload->>'outcome' + ORDER BY validation_outcome, import_outcome +SQL +``` + +### Full session timeline + +```ruby +# All metrics for a specific session +session = 'gi_a1b2c3d4e' +ImportMetric.where(session_id: session).order(:created_at) +``` + +### Sessions that validated but never submitted + +```ruby +# Find sessions with validation but no import outcome +ImportMetric.find_by_sql([<<-SQL, from, to]) + SELECT v.session_id, v.payload->>'outcome' AS validation_outcome + FROM bulkrax_import_metrics v + LEFT JOIN bulkrax_import_metrics o + ON v.session_id = o.session_id AND o.metric_type = 'import_outcome' + WHERE v.metric_type = 'validation' + AND v.event = 'validation_complete' + AND o.id IS NULL + AND v.session_id IS NOT NULL + AND v.created_at BETWEEN ? AND ? +SQL +``` + +--- + +## Using MetricsAggregator + +For common queries, use the service object instead of writing raw SQL: + +```ruby +agg = Bulkrax::MetricsAggregator.new(from: 30.days.ago, to: Time.current) + +agg.total_imports # Count of completed imports +agg.first_attempt_success_rate # Percentage (e.g. 85.2) +agg.avg_validation_duration_ms # Average validation time in ms +agg.validation_outcomes # {"pass" => N, "fail" => N, ...} +agg.funnel_data # {1 => N, 2 => N, 3 => N} +agg.error_type_frequencies # Top 10 error types with counts +agg.avg_seq_rating # Average SEQ score (1-7) +agg.seq_distribution # {1 => N, 2 => N, ...7 => N} +agg.seq_response_count # Total feedback submissions +agg.recent_comments(limit: 20) # [{rating:, comment:, date:}, ...] +agg.imports_over_time # {[date, outcome] => count} +agg.recent_imports(limit: 50) # ImportMetric records with associations +agg.validation_to_outcome_correlation # Validation vs import outcome matrix +agg.export_rows # All metrics as hashes (for CSV export) +``` + +--- + +## CSV Export + +The dashboard provides a CSV export at `GET /importers/guided_import/metrics/export?from=YYYY-MM-DD&to=YYYY-MM-DD`. + +Columns: `id`, `metric_type`, `event`, `importer_id`, `user_id`, `session_id`, `created_at`, `payload` (JSON string). diff --git a/lib/bulkrax.rb b/lib/bulkrax.rb index e5e263ab5..f107be445 100644 --- a/lib/bulkrax.rb +++ b/lib/bulkrax.rb @@ -41,6 +41,7 @@ class Configuration :field_mappings, :guided_import_enabled, :guided_import_demo_scenarios_enabled, + :guided_import_metrics_enabled, :generated_metadata_mapping, :import_path, :multi_value_element_join_on, @@ -280,6 +281,7 @@ def config conf.required_elements = ['title'] conf.guided_import_enabled = ActiveModel::Type::Boolean.new.cast(ENV.fetch('BULKRAX_GUIDED_IMPORTER', false)) conf.guided_import_demo_scenarios_enabled = false + conf.guided_import_metrics_enabled = ActiveModel::Type::Boolean.new.cast(ENV.fetch('BULKRAX_GUIDED_IMPORT_METRICS', false)) # Hash of Generic field_mappings for use in the view # There must be one field_mappings hash per view partial diff --git a/spec/controllers/bulkrax/guided_imports_controller_spec.rb b/spec/controllers/bulkrax/guided_imports_controller_spec.rb index 30f3e18ff..daa24a962 100644 --- a/spec/controllers/bulkrax/guided_imports_controller_spec.rb +++ b/spec/controllers/bulkrax/guided_imports_controller_spec.rb @@ -60,6 +60,24 @@ def json_response expect(response).to have_http_status(:ok) expect(json_response[:isValid]).to eq(true) end + + it 'passes metrics_session_id to the validation metric' do + allow(Bulkrax.config).to receive(:guided_import_metrics_enabled).and_return(true) + expect(Bulkrax::ImportMetric).to receive(:record).with(hash_including(session_id: 'gi_abc123')).and_return(nil) + post_validate(importer: { parser_fields: { files: [csv_upload] } }, metrics_session_id: 'gi_abc123') + end + + it 'records a nil session_id when metrics_session_id is absent' do + allow(Bulkrax.config).to receive(:guided_import_metrics_enabled).and_return(true) + expect(Bulkrax::ImportMetric).to receive(:record).with(hash_including(session_id: nil)).and_return(nil) + post_validate(importer: { parser_fields: { files: [csv_upload] } }) + end + + it 'does not record a validation metric when metrics are disabled' do + allow(Bulkrax.config).to receive(:guided_import_metrics_enabled).and_return(false) + expect(Bulkrax::ImportMetric).not_to receive(:record) + post_validate(importer: { parser_fields: { files: [csv_upload] } }, metrics_session_id: 'gi_abc123') + end end context 'with a file path that exists' do @@ -177,6 +195,41 @@ def post_create(extra_parser_fields = {}) end end + context 'with metrics_session_id param' do + before { allow(Bulkrax.config).to receive(:guided_import_metrics_enabled).and_return(true) } + + it 'persists metrics_session_id in parser_fields' do + post :create, params: { + importer: valid_importer_params.merge( + parser_fields: valid_importer_params[:parser_fields].merge(files: [csv_upload]) + ), + metrics_session_id: 'gi_xyz789' + } + expect(Importer.last.parser_fields['metrics_session_id']).to eq('gi_xyz789') + end + + it 'does not persist metrics_session_id when param is blank' do + post :create, params: { + importer: valid_importer_params.merge( + parser_fields: valid_importer_params[:parser_fields].merge(files: [csv_upload]) + ), + metrics_session_id: '' + } + expect(Importer.last.parser_fields).not_to have_key('metrics_session_id') + end + + it 'does not persist metrics_session_id when metrics are disabled' do + allow(Bulkrax.config).to receive(:guided_import_metrics_enabled).and_return(false) + post :create, params: { + importer: valid_importer_params.merge( + parser_fields: valid_importer_params[:parser_fields].merge(files: [csv_upload]) + ), + metrics_session_id: 'gi_xyz789' + } + expect(Importer.last.parser_fields).not_to have_key('metrics_session_id') + end + end + context 'with override_rights_statement in parser_fields' do it 'permits the parameter and saves it on the importer' do post_create(override_rights_statement: '1') diff --git a/spec/controllers/bulkrax/import_metrics_controller_spec.rb b/spec/controllers/bulkrax/import_metrics_controller_spec.rb new file mode 100644 index 000000000..4f7797203 --- /dev/null +++ b/spec/controllers/bulkrax/import_metrics_controller_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module Bulkrax + RSpec.describe ImportMetricsController, type: :controller do + routes { Bulkrax::Engine.routes } + + let(:current_ability) { instance_double(Ability) } + let(:user) { FactoryBot.create(:user) } + + before do + user + module Bulkrax::MetricsAuth + def authenticate_user! + @current_user = User.first + true + end + + def current_user + @current_user + end + end + described_class.prepend Bulkrax::MetricsAuth + allow(current_ability).to receive(:can_read_bulkrax_metrics?).and_return(true) + allow(controller).to receive(:current_ability).and_return(current_ability) + allow(Bulkrax.config).to receive(:guided_import_metrics_enabled).and_return(true) + end + + describe 'POST #record_metric' do + it 'returns 204 no content' do + post :record_metric, params: { + metric_type: 'funnel', + event: 'step_reached', + session_id: 'gi_test123', + payload: { step: 1 } + } + expect(response).to have_http_status(:no_content) + end + + it 'creates an ImportMetric record' do + expect do + post :record_metric, params: { + metric_type: 'funnel', + event: 'step_reached', + session_id: 'gi_test123', + payload: { step: 1 } + } + end.to change(ImportMetric, :count).by(1) + end + + it 'returns 204 even with invalid data' do + post :record_metric, params: { + metric_type: 'invalid_type', + event: 'test' + } + expect(response).to have_http_status(:no_content) + end + + it 'returns 404 when metrics are disabled' do + allow(Bulkrax.config).to receive(:guided_import_metrics_enabled).and_return(false) + post :record_metric, params: { metric_type: 'funnel', event: 'test' } + expect(response).to have_http_status(:not_found) + end + end + + describe 'GET #index' do + it 'renders the dashboard for admin users' do + get :index + expect(response).to have_http_status(:ok) + end + + it 'assigns aggregator and date range' do + get :index, params: { from: '2026-04-01', to: '2026-04-10' } + expect(assigns(:aggregator)).to be_a(MetricsAggregator) + expect(assigns(:date_from)).to be_present + expect(assigns(:date_to)).to be_present + end + + it 'defaults to 30-day range when no params given' do + get :index + expect(assigns(:date_from)).to be_within(2.seconds).of(30.days.ago) + expect(assigns(:date_to)).to be_within(2.seconds).of(Time.current) + end + + it 'handles invalid date params gracefully' do + get :index, params: { from: 'not-a-date', to: 'also-invalid' } + expect(response).to have_http_status(:ok) + expect(assigns(:date_from)).to be_within(2.seconds).of(30.days.ago) + end + + it 'returns 403 for non-admin users' do + allow(current_ability).to receive(:can_read_bulkrax_metrics?).and_return(false) + expect { get :index }.to raise_error(CanCan::AccessDenied) + end + + it 'returns 404 when metrics are disabled' do + allow(Bulkrax.config).to receive(:guided_import_metrics_enabled).and_return(false) + get :index + expect(response).to have_http_status(:not_found) + end + end + + describe 'GET #export' do + it 'returns a CSV file' do + get :export + expect(response).to have_http_status(:ok) + expect(response.content_type).to include('text/csv') + end + + it 'sets the correct filename' do + get :export + expect(response.headers['Content-Disposition']).to include("bulkrax_import_metrics_#{Time.zone.today.iso8601}.csv") + end + + it 'includes CSV headers' do + get :export + csv_lines = response.body.split("\n") + expect(csv_lines.first).to eq('id,metric_type,event,importer_id,user_id,session_id,created_at,payload') + end + + it 'includes metric data in the CSV' do + ImportMetric.record(metric_type: 'funnel', event: 'step_reached', payload: { step: 1 }) + get :export + csv_lines = response.body.split("\n") + expect(csv_lines.size).to eq(2) # header + 1 data row + end + + it 'returns 404 when metrics are disabled' do + allow(Bulkrax.config).to receive(:guided_import_metrics_enabled).and_return(false) + get :export + expect(response).to have_http_status(:not_found) + end + end + end +end diff --git a/spec/controllers/concerns/bulkrax/datatables_behavior_spec.rb b/spec/controllers/concerns/bulkrax/datatables_behavior_spec.rb index abfca8849..a26a2c97e 100644 --- a/spec/controllers/concerns/bulkrax/datatables_behavior_spec.rb +++ b/spec/controllers/concerns/bulkrax/datatables_behavior_spec.rb @@ -148,11 +148,11 @@ def current_user expect(result).to include(' Error') end - it 'returns a string of HTML with a red "remove" icon when status_message is "Deleted"' do + it 'returns a string of HTML with a green "remove" icon when status_message is "Deleted"' do entry = FactoryBot.create(:bulkrax_entry, importerexporter: item, status_message: 'Deleted') get :index result = controller.status_message_for(entry) - expect(result).to include(' Deleted') + expect(result).to include(' Deleted') end end end diff --git a/spec/models/bulkrax/import_metric_spec.rb b/spec/models/bulkrax/import_metric_spec.rb new file mode 100644 index 000000000..694b7f629 --- /dev/null +++ b/spec/models/bulkrax/import_metric_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module Bulkrax + RSpec.describe ImportMetric, type: :model do + describe 'validations' do + it 'requires metric_type' do + metric = described_class.new(event: 'test') + expect(metric).not_to be_valid + expect(metric.errors[:metric_type]).to include("can't be blank") + end + + it 'requires event' do + metric = described_class.new(metric_type: 'funnel') + expect(metric).not_to be_valid + expect(metric.errors[:event]).to include("can't be blank") + end + + it 'validates metric_type inclusion' do + metric = described_class.new(metric_type: 'invalid', event: 'test') + expect(metric).not_to be_valid + expect(metric.errors[:metric_type]).to include('is not included in the list') + end + + %w[funnel validation import_outcome feedback timing].each do |type| + it "accepts metric_type '#{type}'" do + metric = described_class.new(metric_type: type, event: 'test') + expect(metric).to be_valid + end + end + end + + describe 'associations' do + it 'belongs to importer optionally' do + metric = described_class.new(metric_type: 'funnel', event: 'test') + expect(metric).to be_valid + end + + it 'belongs to user optionally' do + metric = described_class.new(metric_type: 'funnel', event: 'test') + expect(metric).to be_valid + end + end + + describe '.record' do + it 'creates a metric with valid attributes' do + metric = described_class.record(metric_type: 'funnel', event: 'step_reached', payload: { step: 1 }) + expect(metric).to be_persisted + expect(metric.metric_type).to eq('funnel') + expect(metric.event).to eq('step_reached') + expect(metric.payload).to eq({ 'step' => 1 }) + end + + it 'sets payload to empty hash when not provided' do + metric = described_class.record(metric_type: 'funnel', event: 'step_reached') + expect(metric.payload).to eq({}) + end + + it 'stores session_id' do + metric = described_class.record(metric_type: 'funnel', event: 'step_reached', session_id: 'gi_abc123') + expect(metric.session_id).to eq('gi_abc123') + end + + it 'never raises on failure' do + allow(described_class).to receive(:create).and_raise(ActiveRecord::StatementInvalid, 'table missing') + expect { described_class.record(metric_type: 'funnel', event: 'test') }.not_to raise_error + end + + it 'returns nil on failure' do + allow(described_class).to receive(:create).and_raise(ActiveRecord::StatementInvalid, 'table missing') + expect(described_class.record(metric_type: 'funnel', event: 'test')).to be_nil + end + + it 'logs a warning on failure' do + allow(described_class).to receive(:create).and_raise(ActiveRecord::StatementInvalid, 'table missing') + expect(Rails.logger).to receive(:warn).with(/Bulkrax::ImportMetric\.record failed/) + described_class.record(metric_type: 'funnel', event: 'test') + end + end + + describe 'scopes' do + before do + described_class.record(metric_type: 'funnel', event: 'step_reached') + described_class.record(metric_type: 'validation', event: 'validation_complete') + described_class.record(metric_type: 'import_outcome', event: 'import_complete') + described_class.record(metric_type: 'feedback', event: 'seq_rating') + described_class.record(metric_type: 'timing', event: 'session_complete') + end + + it '.funnel returns only funnel metrics' do + expect(described_class.funnel.pluck(:metric_type).uniq).to eq(['funnel']) + end + + it '.validations returns only validation metrics' do + expect(described_class.validations.pluck(:metric_type).uniq).to eq(['validation']) + end + + it '.import_outcomes returns only import_outcome metrics' do + expect(described_class.import_outcomes.pluck(:metric_type).uniq).to eq(['import_outcome']) + end + + it '.feedback returns only feedback metrics' do + expect(described_class.feedback.pluck(:metric_type).uniq).to eq(['feedback']) + end + + it '.timing returns only timing metrics' do + expect(described_class.timing.pluck(:metric_type).uniq).to eq(['timing']) + end + end + + describe '.in_range' do + it 'filters by date range' do + old_metric = described_class.record(metric_type: 'funnel', event: 'test') + old_metric.update(created_at: 60.days.ago) + + recent_metric = described_class.record(metric_type: 'funnel', event: 'test') + + results = described_class.in_range(7.days.ago, Time.current) + expect(results).to include(recent_metric) + expect(results).not_to include(old_metric) + end + end + end +end diff --git a/spec/models/bulkrax/importer_spec.rb b/spec/models/bulkrax/importer_spec.rb index bbd9fe44d..fe01ead6e 100644 --- a/spec/models/bulkrax/importer_spec.rb +++ b/spec/models/bulkrax/importer_spec.rb @@ -123,6 +123,96 @@ module Bulkrax end end + describe '#record_import_outcome_metric' do + let(:importer) do + FactoryBot.create(:bulkrax_importer_csv, parser_fields: { + 'import_file_path' => 'spec/fixtures/csv/good.csv', + 'guided_import' => true, + 'metrics_session_id' => 'gi_test123' + }) + end + + let(:run) { importer.last_run } + + before { allow(Bulkrax.config).to receive(:guided_import_metrics_enabled).and_return(true) } + + it 'passes session_id from parser_fields to ImportMetric.record' do + expect(Bulkrax::ImportMetric).to receive(:record).with(hash_including(session_id: 'gi_test123')).and_return(nil) + importer.send(:record_import_outcome_metric, run) + end + + it 'does not record a metric when metrics are disabled' do + allow(Bulkrax.config).to receive(:guided_import_metrics_enabled).and_return(false) + expect(Bulkrax::ImportMetric).not_to receive(:record) + importer.send(:record_import_outcome_metric, run) + end + + it 'does not record a metric when run is nil' do + expect(Bulkrax::ImportMetric).not_to receive(:record) + importer.send(:record_import_outcome_metric, nil) + end + + it 'passes importer and user to ImportMetric.record' do + expect(Bulkrax::ImportMetric).to receive(:record).with( + hash_including(importer: importer, user: importer.user) + ).and_return(nil) + importer.send(:record_import_outcome_metric, run) + end + + context 'payload content' do + it 'includes record counts' do + allow(run).to receive(:total_work_entries).and_return(10) + allow(run).to receive(:total_collection_entries).and_return(2) + allow(run).to receive(:total_file_set_entries).and_return(5) + allow(run).to receive(:processed_works).and_return(10) + allow(run).to receive(:failed_works).and_return(0) + allow(run).to receive(:failed_records).and_return(0) + expect(Bulkrax::ImportMetric).to receive(:record) do |args| + payload = args[:payload] + expect(payload[:total_work_entries]).to eq(10) + expect(payload[:total_collection_entries]).to eq(2) + expect(payload[:total_file_set_entries]).to eq(5) + expect(payload[:processed_works]).to eq(10) + expect(payload[:failed_works]).to eq(0) + expect(payload[:failed_records]).to eq(0) + end + importer.send(:record_import_outcome_metric, run) + end + + it 'includes is_first_attempt as true on first run' do + expect(Bulkrax::ImportMetric).to receive(:record) do |args| + expect(args[:payload][:is_first_attempt]).to eq(true) + end + importer.send(:record_import_outcome_metric, run) + end + + it 'includes is_first_attempt as false on subsequent runs' do + # Create a second run + importer.importer_runs.create! + expect(Bulkrax::ImportMetric).to receive(:record) do |args| + expect(args[:payload][:is_first_attempt]).to eq(false) + end + importer.send(:record_import_outcome_metric, run) + end + + it 'includes used_guided_import flag' do + expect(Bulkrax::ImportMetric).to receive(:record) do |args| + expect(args[:payload][:used_guided_import]).to eq(true) + end + importer.send(:record_import_outcome_metric, run) + end + + it 'includes duration_seconds' do + allow(run).to receive(:created_at).and_return(10.minutes.ago) + allow(run).to receive(:updated_at).and_return(Time.current) + expect(Bulkrax::ImportMetric).to receive(:record) do |args| + expect(args[:payload][:duration_seconds]).to be_within(2).of(600) + end + importer.send(:record_import_outcome_metric, run) + end + end + end + describe '#original_files' do let(:csv_file) { Tempfile.new(['metadata', '.csv']) } let(:zip_file) { Tempfile.new(['attachments', '.zip']) } diff --git a/spec/services/bulkrax/metrics_aggregator_spec.rb b/spec/services/bulkrax/metrics_aggregator_spec.rb new file mode 100644 index 000000000..c1b3bdf5c --- /dev/null +++ b/spec/services/bulkrax/metrics_aggregator_spec.rb @@ -0,0 +1,326 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module Bulkrax + RSpec.describe MetricsAggregator, type: :model do + let(:from) { 7.days.ago } + let(:to) { Time.current } + let(:aggregator) { described_class.new(from: from, to: to) } + + def create_metric(attrs = {}) + ImportMetric.record(**{ metric_type: 'funnel', event: 'test' }.merge(attrs)) + end + + describe '#total_imports' do + it 'returns 0 with no data' do + expect(aggregator.total_imports).to eq(0) + end + + it 'counts only import_outcome metrics' do + create_metric(metric_type: 'import_outcome', event: 'import_complete', payload: { outcome: 'complete' }) + create_metric(metric_type: 'import_outcome', event: 'import_complete', payload: { outcome: 'failed' }) + create_metric(metric_type: 'validation', event: 'validation_complete') + expect(aggregator.total_imports).to eq(2) + end + end + + describe '#first_attempt_success_rate' do + it 'returns 0.0 with no data' do + relation = stub_postgres_scope(:import_outcomes, 0) + allow(relation).to receive(:joins).and_return(relation) if relation + expect(aggregator.first_attempt_success_rate).to eq(0.0) + end + + it 'returns correct percentage with mixed outcomes' do + unless DatabaseHelpers.postgres? + relation = double('ImportMetric::Relation') # rubocop:disable RSpec/VerifiedDoubles + allow(ImportMetric).to receive(:import_outcomes).and_return(relation) + allow(relation).to receive(:in_range).and_return(relation) + allow(relation).to receive(:joins).and_return(relation) + allow(relation).to receive(:where).and_return(relation) + allow(relation).to receive(:count).and_return(3, 2) + end + + expect(aggregator.first_attempt_success_rate).to eq(66.7) + end + + it 'only counts first attempts' do + unless DatabaseHelpers.postgres? + relation = double('ImportMetric::Relation') # rubocop:disable RSpec/VerifiedDoubles + allow(ImportMetric).to receive(:import_outcomes).and_return(relation) + allow(relation).to receive(:in_range).and_return(relation) + allow(relation).to receive(:joins).and_return(relation) + allow(relation).to receive(:where).and_return(relation) + allow(relation).to receive(:count).and_return(1, 1) + end + + expect(aggregator.first_attempt_success_rate).to eq(100.0) + end + end + + describe '#avg_validation_duration_ms' do + it 'returns 0 with no data' do + stub_postgres_scope(:validations, nil) + expect(aggregator.avg_validation_duration_ms).to eq(0) + end + + it 'returns the average duration rounded to integer' do + create_metric(metric_type: 'validation', event: 'validation_complete', payload: { duration_ms: 1000 }) + create_metric(metric_type: 'validation', event: 'validation_complete', payload: { duration_ms: 2000 }) + stub_postgres_scope(:validations, BigDecimal('1500.4')) + expect(aggregator.avg_validation_duration_ms).to eq(1500) + end + end + + describe '#validation_outcomes' do + it 'returns empty hash with no data' do + stub_postgres_scope(:validations, {}) + expect(aggregator.validation_outcomes).to eq({}) + end + + it 'groups by outcome' do + create_metric(metric_type: 'validation', event: 'validation_complete', payload: { outcome: 'pass' }) + create_metric(metric_type: 'validation', event: 'validation_complete', payload: { outcome: 'pass' }) + create_metric(metric_type: 'validation', event: 'validation_complete', payload: { outcome: 'fail' }) + stub_postgres_scope(:validations, { 'pass' => 2, 'fail' => 1 }) + result = aggregator.validation_outcomes + expect(result['pass']).to eq(2) + expect(result['fail']).to eq(1) + end + end + + describe '#funnel_data' do + it 'returns empty hash with no data' do + stub_postgres_scope(:funnel, {}) + expect(aggregator.funnel_data).to eq({}) + end + + it 'groups by step number' do + create_metric(metric_type: 'funnel', event: 'step_reached', payload: { step: 1 }) + create_metric(metric_type: 'funnel', event: 'step_reached', payload: { step: 1 }) + create_metric(metric_type: 'funnel', event: 'step_reached', payload: { step: 2 }) + stub_postgres_scope(:funnel, { 1 => 2, 2 => 1 }) + result = aggregator.funnel_data + expect(result[1]).to eq(2) + expect(result[2]).to eq(1) + end + end + + describe '#error_type_frequencies' do + it 'returns empty array with no data' do + stub_postgres_find_by_sql([]) + expect(aggregator.error_type_frequencies).to eq([]) + end + + it 'unnests JSONB arrays and counts by type' do + create_metric(metric_type: 'validation', event: 'validation_complete', + payload: { error_types: %w[missing_required_fields row_errors] }) + create_metric(metric_type: 'validation', event: 'validation_complete', + payload: { error_types: ['missing_required_fields'] }) + + row1 = ImportMetric.new + row1.define_singleton_method(:error_type) { 'missing_required_fields' } + row1.define_singleton_method(:cnt) { 2 } + row2 = ImportMetric.new + row2.define_singleton_method(:error_type) { 'row_errors' } + row2.define_singleton_method(:cnt) { 1 } + stub_postgres_find_by_sql([row1, row2]) + + results = aggregator.error_type_frequencies + types = results.map { |r| [r.error_type, r.cnt.to_i] }.to_h + expect(types['missing_required_fields']).to eq(2) + expect(types['row_errors']).to eq(1) + end + end + + describe '#avg_seq_rating' do + it 'returns 0.0 with no data' do + stub_postgres_scope(:feedback, nil) + expect(aggregator.avg_seq_rating).to eq(0.0) + end + + it 'returns the average rating rounded to 1 decimal' do + create_metric(metric_type: 'feedback', event: 'seq_rating', payload: { seq_rating: 5 }) + create_metric(metric_type: 'feedback', event: 'seq_rating', payload: { seq_rating: 7 }) + stub_postgres_scope(:feedback, BigDecimal('6.0')) + expect(aggregator.avg_seq_rating).to eq(6.0) + end + end + + describe '#seq_distribution' do + it 'returns empty hash with no data' do + stub_postgres_scope(:feedback, {}) + expect(aggregator.seq_distribution).to eq({}) + end + + it 'groups by rating value' do + create_metric(metric_type: 'feedback', event: 'seq_rating', payload: { seq_rating: 5 }) + create_metric(metric_type: 'feedback', event: 'seq_rating', payload: { seq_rating: 5 }) + create_metric(metric_type: 'feedback', event: 'seq_rating', payload: { seq_rating: 7 }) + stub_postgres_scope(:feedback, { 5 => 2, 7 => 1 }) + result = aggregator.seq_distribution + expect(result[5]).to eq(2) + expect(result[7]).to eq(1) + end + end + + describe '#seq_response_count' do + it 'returns 0 with no data' do + expect(aggregator.seq_response_count).to eq(0) + end + + it 'counts only feedback metrics' do + create_metric(metric_type: 'feedback', event: 'seq_rating', payload: { seq_rating: 5 }) + create_metric(metric_type: 'funnel', event: 'step_reached') + expect(aggregator.seq_response_count).to eq(1) + end + end + + describe '#recent_comments' do + it 'returns empty array with no data' do + stub_postgres_scope(:feedback, []) + expect(aggregator.recent_comments).to eq([]) + end + + it 'returns comments with rating and date' do + now = Time.current + create_metric(metric_type: 'feedback', event: 'seq_rating', + payload: { seq_rating: 6, comment: 'Great tool!' }) + stub_postgres_scope(:feedback, [[{ 'seq_rating' => 6, 'comment' => 'Great tool!' }, now]]) + results = aggregator.recent_comments + expect(results.size).to eq(1) + expect(results.first[:rating]).to eq(6) + expect(results.first[:comment]).to eq('Great tool!') + expect(results.first[:date]).to eq(now) + end + + it 'excludes blank comments' do + create_metric(metric_type: 'feedback', event: 'seq_rating', + payload: { seq_rating: 5, comment: '' }) + create_metric(metric_type: 'feedback', event: 'seq_rating', + payload: { seq_rating: 6, comment: 'Useful' }) + stub_postgres_scope(:feedback, [[{ 'seq_rating' => 6, 'comment' => 'Useful' }, Time.current]]) + expect(aggregator.recent_comments.size).to eq(1) + end + + it 'respects the limit parameter' do + 3.times { |i| create_metric(metric_type: 'feedback', event: 'seq_rating', payload: { seq_rating: i + 5, comment: "Comment #{i}" }) } + comments = Array.new(2) { |i| [{ 'seq_rating' => i + 5, 'comment' => "Comment #{i}" }, Time.current] } + relation = stub_postgres_scope(:feedback, comments) + if relation + allow(relation).to receive(:limit).with(2).and_return(relation) + allow(relation).to receive(:pluck).and_return(comments) + end + expect(aggregator.recent_comments(limit: 2).size).to eq(2) + end + end + + describe '#imports_over_time' do + it 'returns empty hash with no data' do + stub_postgres_scope(:import_outcomes, {}) + expect(aggregator.imports_over_time).to eq({}) + end + + it 'groups by day and outcome' do + create_metric(metric_type: 'import_outcome', event: 'import_complete', payload: { outcome: 'complete' }) + create_metric(metric_type: 'import_outcome', event: 'import_complete', payload: { outcome: 'failed' }) + today = Time.current.beginning_of_day + stub_postgres_scope(:import_outcomes, { [today, 'complete'] => 1, [today, 'failed'] => 1 }) + result = aggregator.imports_over_time + expect(result.values.sum).to eq(2) + end + end + + describe '#recent_imports' do + it 'returns empty relation with no data' do + expect(aggregator.recent_imports).to be_empty + end + + it 'returns import_outcome metrics ordered by most recent' do + create_metric(metric_type: 'import_outcome', event: 'import_complete', payload: { outcome: 'complete' }) + m2 = create_metric(metric_type: 'import_outcome', event: 'import_complete', payload: { outcome: 'failed' }) + results = aggregator.recent_imports + expect(results.first.id).to eq(m2.id) + end + + it 'respects the limit parameter' do + 3.times { create_metric(metric_type: 'import_outcome', event: 'import_complete', payload: { outcome: 'complete' }) } + expect(aggregator.recent_imports(limit: 2).size).to eq(2) + end + end + + describe '#validation_to_outcome_correlation' do + it 'returns empty array with no data' do + stub_postgres_find_by_sql([]) + expect(aggregator.validation_to_outcome_correlation).to eq([]) + end + + it 'joins validation and import_outcome metrics on session_id' do + expect(ImportMetric).to receive(:find_by_sql) do |args| + sql = args.first + expect(sql).to include("v.session_id = o.session_id") + expect(sql).to include("v.metric_type = 'validation'") + expect(sql).to include("o.metric_type = 'import_outcome'") + [] + end + aggregator.validation_to_outcome_correlation + end + + it 'passes the date range to the SQL query' do + expect(ImportMetric).to receive(:find_by_sql) do |args| + _sql, query_from, query_to = args + expect(query_from).to be_within(1.second).of(from) + expect(query_to).to be_within(1.second).of(to) + [] + end + aggregator.validation_to_outcome_correlation + end + + it 'requires non-null session_id' do + expect(ImportMetric).to receive(:find_by_sql) do |args| + sql = args.first + expect(sql).to include("v.session_id IS NOT NULL") + [] + end + aggregator.validation_to_outcome_correlation + end + end + + describe '#export_rows' do + it 'returns empty array with no data' do + expect(aggregator.export_rows).to eq([]) + end + + it 'returns all metrics in range with expected keys' do + create_metric(metric_type: 'funnel', event: 'step_reached', session_id: 'gi_test', payload: { step: 1 }) + rows = aggregator.export_rows + expect(rows.size).to eq(1) + row = rows.first + expect(row).to have_key(:id) + expect(row).to have_key(:metric_type) + expect(row).to have_key(:event) + expect(row).to have_key(:importer_id) + expect(row).to have_key(:user_id) + expect(row).to have_key(:session_id) + expect(row).to have_key(:created_at) + expect(row).to have_key(:payload) + end + + it 'serializes payload as JSON string' do + create_metric(metric_type: 'funnel', event: 'step_reached', payload: { step: 1 }) + row = aggregator.export_rows.first + expect(row[:payload]).to be_a(String) + expect(JSON.parse(row[:payload])).to eq({ 'step' => 1 }) + end + + it 'orders by created_at' do + m1 = create_metric(metric_type: 'funnel', event: 'first') + m2 = create_metric(metric_type: 'funnel', event: 'second') + rows = aggregator.export_rows + expect(rows.first[:id]).to eq(m1.id) + expect(rows.last[:id]).to eq(m2.id) + end + end + end +end diff --git a/spec/support/database_helpers.rb b/spec/support/database_helpers.rb new file mode 100644 index 000000000..88c1f1e00 --- /dev/null +++ b/spec/support/database_helpers.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +# Helpers for specs that use PostgreSQL-specific features (JSONB operators, +# ::integer casts, date_trunc, jsonb_array_elements_text, etc.). +# +# On PostgreSQL, specs run normally against the real DB. +# On SQLite, the helper auto-stubs the ActiveRecord chain so the specs still +# execute and verify the aggregator's interface and return shape via mocks. +# +# Usage in specs: +# before { stub_postgres_scope(:funnel, { 1 => 2, 2 => 1 }) } +module DatabaseHelpers + def self.postgres? + ActiveRecord::Base.connection.adapter_name.downcase.include?('postgresql') + end + + # Builds a stub AR relation that responds to common query chain methods. + # On Postgres this is a no-op — the real query runs. + # On SQLite this intercepts before Postgres-specific SQL reaches the adapter. + # + # @param scope_name [Symbol] the ImportMetric scope to stub (:funnel, :validations, etc.) + # @param result [Object] the value to return from the terminal method (count, average, pluck, etc.) + # @return [RSpec::Mocks::Double, nil] the stubbed relation, or nil on Postgres + def stub_postgres_scope(scope_name, result) + return if DatabaseHelpers.postgres? + + relation = build_chainable_relation + allow(Bulkrax::ImportMetric).to receive(scope_name).and_return(relation) + attach_terminal_method(relation, result) + relation + end + + # Stubs ImportMetric.find_by_sql for raw SQL queries on SQLite. + def stub_postgres_find_by_sql(result) + return if DatabaseHelpers.postgres? + + allow(Bulkrax::ImportMetric).to receive(:find_by_sql).and_return(result) + end + + private + + def build_chainable_relation + relation = double('ImportMetric::Relation') # rubocop:disable RSpec/VerifiedDoubles + %i[in_range where group order limit includes joins].each do |method| + allow(relation).to receive(method).and_return(relation) + end + relation + end + + def attach_terminal_method(relation, result) + case result + when Hash, Integer + allow(relation).to receive(:count).and_return(result) + when BigDecimal + allow(relation).to receive(:average).and_return(result) + when Array + allow(relation).to receive(:pluck).and_return(result) + when nil + allow(relation).to receive(:average).and_return(nil) + allow(relation).to receive(:count).and_return(0) + end + end +end + +RSpec.configure do |config| + config.include DatabaseHelpers +end diff --git a/spec/test_app/app/models/ability.rb b/spec/test_app/app/models/ability.rb index 191bbfee9..90798da70 100644 --- a/spec/test_app/app/models/ability.rb +++ b/spec/test_app/app/models/ability.rb @@ -29,6 +29,10 @@ def can_export_works? can_create_any_work? end + def can_read_bulkrax_metrics? + true + end + def can_create_any_work? true end diff --git a/spec/test_app/db/schema.rb b/spec/test_app/db/schema.rb index 97b64f1da..b95bfdd34 100644 --- a/spec/test_app/db/schema.rb +++ b/spec/test_app/db/schema.rb @@ -10,8 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2024_12_05_212513) do - +ActiveRecord::Schema[7.2].define(version: 2026_04_03_000000) do create_table "accounts", force: :cascade do |t| t.string "name" end @@ -84,6 +83,23 @@ t.index ["user_id"], name: "index_bulkrax_exporters_on_user_id" end + create_table "bulkrax_import_metrics", force: :cascade do |t| + t.string "metric_type", null: false + t.string "event", null: false + t.integer "importer_id" + t.integer "user_id" + t.string "session_id" + t.json "payload", default: {} + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.index ["created_at"], name: "index_bulkrax_import_metrics_on_created_at" + t.index ["event"], name: "index_bulkrax_import_metrics_on_event" + t.index ["importer_id"], name: "index_bulkrax_import_metrics_on_importer_id" + t.index ["metric_type", "created_at"], name: "index_bulkrax_import_metrics_on_metric_type_and_created_at" + t.index ["metric_type"], name: "index_bulkrax_import_metrics_on_metric_type" + t.index ["user_id"], name: "index_bulkrax_import_metrics_on_user_id" + end + create_table "bulkrax_importer_runs", force: :cascade do |t| t.integer "importer_id" t.integer "total_work_entries", default: 0 @@ -655,6 +671,7 @@ end add_foreign_key "bulkrax_exporter_runs", "bulkrax_exporters", column: "exporter_id" + add_foreign_key "bulkrax_import_metrics", "bulkrax_importers", column: "importer_id" add_foreign_key "bulkrax_importer_runs", "bulkrax_importers", column: "importer_id" add_foreign_key "bulkrax_pending_relationships", "bulkrax_importer_runs", column: "importer_run_id" add_foreign_key "collection_type_participants", "hyrax_collection_types"