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 %>
+
+
<%= t('bulkrax.import_metrics.trends.title') %>
+
+
+
+ <%= t('bulkrax.import_metrics.trends.hint') %>
+
+
+
+ <%# 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 %>
+
+
<%= 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? %>
+
+
+
+
+
+ | <%= t('bulkrax.import_metrics.errors.error_type') %> |
+ <%= t('bulkrax.import_metrics.errors.count') %> |
+
+
+
+ <% error_types.each do |row| %>
+
+ | <%= row.error_type.humanize %> |
+ <%= row.cnt %> |
+
+ <% end %>
+
+
+
+ <% 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') %>
+
+ <% end %>
+
+
+
+
+ <%# Recent imports tab %>
+
+
<%= t('bulkrax.import_metrics.recent_imports.title') %>
+
+
+
+
+ | <%= 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') %> |
+
+
+
+ <% @aggregator.recent_imports.each do |metric| %>
+ <% payload = metric.payload %>
+ <% live_status = metric.importer&.status_message %>
+ <% run_number = payload['run_number'] %>
+
+ |
+ <% 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 %>
+
+
+
+
+
+
+
+<% 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 @@
<% 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 %>
+
+ <% 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"