diff --git a/.dassie/Gemfile b/.dassie/Gemfile index e2ae9abe66..8753dc6123 100644 --- a/.dassie/Gemfile +++ b/.dassie/Gemfile @@ -15,6 +15,8 @@ gem 'dalli' gem 'devise' gem 'devise-guests', '~> 0.8' gemspec name: 'hyrax', path: ENV.fetch('HYRAX_ENGINE_PATH', '..') +gem 'google-protobuf', force_ruby_platform: true # required because google-protobuf is not compatible with Alpine linux +gem 'grpc', force_ruby_platform: true # required because google-protobuf is not compatible with Alpine linux gem 'jbuilder', '~> 2.5' gem 'jquery-rails' gem 'pg', '~> 1.3' diff --git a/.dassie/config/analytics.yml b/.dassie/config/analytics.yml index a84eb18463..30941dc868 100644 --- a/.dassie/config/analytics.yml +++ b/.dassie/config/analytics.yml @@ -1,10 +1,15 @@ analytics: + ga4: + analytics_id: <%= ENV['GOOGLE_ANALYTICS_ID'] %> + property_id: <%= ENV['GOOGLE_ANALYTICS_PROPERTY_ID'] %> + account_json: <%= ENV['GOOGLE_ACCOUNT_JSON'] %> + account_json_path: <%= ENV['GOOGLE_ACCOUNT_JSON_PATH'] %> google: analytics_id: <%= ENV['GOOGLE_ANALYTICS_ID'] %> app_name: <%= ENV['GOOGLE_OAUTH_APP_NAME'] %> app_version: <%= ENV['GOOGLE_OAUTH_APP_VERSION'] %> - privkey_value: <%= ENV['GOOGLE_OAUTH_PRIVATE_KEY_VALUE'] %> privkey_path: <%= ENV['GOOGLE_OAUTH_PRIVATE_KEY_PATH'] %> + privkey_value: <%= ENV['GOOGLE_OAUTH_PRIVATE_KEY_VALUE'] %> privkey_secret: <%= ENV['GOOGLE_OAUTH_PRIVATE_KEY_SECRET'] %> client_email: <%= ENV['GOOGLE_OAUTH_CLIENT_EMAIL'] %> matomo: diff --git a/.koppie/Gemfile b/.koppie/Gemfile index e2c4f66361..6b4052b4fb 100644 --- a/.koppie/Gemfile +++ b/.koppie/Gemfile @@ -14,6 +14,8 @@ gem 'coffee-rails', '~> 4.2' gem 'dalli' gem 'devise' gem 'devise-guests', '~> 0.8' +gem 'google-protobuf', force_ruby_platform: true # required because google-protobuf is not compatible with Alpine linux +gem 'grpc', force_ruby_platform: true # required because google-protobuf is not compatible with Alpine linux gemspec name: 'hyrax', path: ENV.fetch('HYRAX_ENGINE_PATH', '..') gem 'jbuilder', '~> 2.5' gem 'jquery-rails' diff --git a/.koppie/config/analytics.yml b/.koppie/config/analytics.yml index a84eb18463..30941dc868 100644 --- a/.koppie/config/analytics.yml +++ b/.koppie/config/analytics.yml @@ -1,10 +1,15 @@ analytics: + ga4: + analytics_id: <%= ENV['GOOGLE_ANALYTICS_ID'] %> + property_id: <%= ENV['GOOGLE_ANALYTICS_PROPERTY_ID'] %> + account_json: <%= ENV['GOOGLE_ACCOUNT_JSON'] %> + account_json_path: <%= ENV['GOOGLE_ACCOUNT_JSON_PATH'] %> google: analytics_id: <%= ENV['GOOGLE_ANALYTICS_ID'] %> app_name: <%= ENV['GOOGLE_OAUTH_APP_NAME'] %> app_version: <%= ENV['GOOGLE_OAUTH_APP_VERSION'] %> - privkey_value: <%= ENV['GOOGLE_OAUTH_PRIVATE_KEY_VALUE'] %> privkey_path: <%= ENV['GOOGLE_OAUTH_PRIVATE_KEY_PATH'] %> + privkey_value: <%= ENV['GOOGLE_OAUTH_PRIVATE_KEY_VALUE'] %> privkey_secret: <%= ENV['GOOGLE_OAUTH_PRIVATE_KEY_SECRET'] %> client_email: <%= ENV['GOOGLE_OAUTH_CLIENT_EMAIL'] %> matomo: diff --git a/app/assets/javascripts/hyrax/analytics_events.js b/app/assets/javascripts/hyrax/analytics_events.js index 8ed3b1fcd5..9af7798bd2 100644 --- a/app/assets/javascripts/hyrax/analytics_events.js +++ b/app/assets/javascripts/hyrax/analytics_events.js @@ -4,39 +4,53 @@ class TrackingTags { } analytics() { - if(this.provider === "matomo") { + switch(this.provider) { + case "matomo": return _paq; - } - else { + case "ga4": + return dataLayer; + default: return _gaq; } } pageView() { - if(this.provider === "matomo") { - return 'trackPageView' - } else { - return '_trackPageview' + switch(this.provider) { + case "matomo": + return 'trackPageView'; + case "ga4": + return 'event'; + default: + return '_trackPageview'; } } trackEvent() { - if(this.provider === "matomo") { - return 'trackEvent' - } else { - return '_trackEvent' + switch(this.provider) { + case "matomo": + return 'trackEvent'; + case "ga4": + return 'event'; + default: + return '_trackEvent'; } } } -function trackPageView() { - window.trackingTags.analytics().push([window.trackingTags.pageView()]); +function trackPageView(provider) { + if(provider !== 'ga4'){ + window.trackingTags.analytics().push([window.trackingTags.pageView()]); + } } -function trackAnalyticsEvents() { +function trackAnalyticsEvents(provider) { $('span.analytics-event').each(function(){ var eventSpan = $(this) - window.trackingTags.analytics().push([window.trackingTags.trackEvent(), eventSpan.data('category'), eventSpan.data('action'), eventSpan.data('name')]); + if(provider !== 'ga4') { + window.trackingTags.analytics().push([window.trackingTags.trackEvent(), eventSpan.data('category'), eventSpan.data('action'), eventSpan.data('name')]); + } else { + gtag('event', eventspan.data('action'), { 'content_type': eventspan.data('category'), 'content_id': eventspan.data('name')}) + } }) } @@ -46,8 +60,8 @@ function setupTracking() { return; } window.trackingTags = new TrackingTags(provider) - trackPageView() - trackAnalyticsEvents() + trackPageView(provider) + trackAnalyticsEvents(provider) } if (typeof Turbolinks !== 'undefined') { @@ -66,10 +80,20 @@ $(document).on('click', '#file_download', function(e) { return; } window.trackingTags = new TrackingTags(provider) - window.trackingTags.analytics().push([trackingTags.trackEvent(), 'file-set', 'file-set-download', $(this).data('label')]); - window.trackingTags.analytics().push([trackingTags.trackEvent(), 'file-set-in-work', 'file-set-in-work-download', $(this).data('work-id')]); - $(this).data('collection-ids').forEach(function (collection) { - window.trackingTags.analytics().push([trackingTags.trackEvent(), 'file-set-in-collection', 'file-set-in-collection-download', collection]); - window.trackingTags.analytics().push([trackingTags.trackEvent(), 'work-in-collection', 'work-in-collection-download', collection]); - }); + + if(provider !== 'ga4') { + window.trackingTags.analytics().push([trackingTags.trackEvent(), 'file-set', 'file-set-download', $(this).data('label')]); + window.trackingTags.analytics().push([trackingTags.trackEvent(), 'file-set-in-work', 'file-set-in-work-download', $(this).data('work-id')]); + $(this).data('collection-ids').forEach(function (collection) { + window.trackingTags.analytics().push([trackingTags.trackEvent(), 'file-set-in-collection', 'file-set-in-collection-download', collection]); + window.trackingTags.analytics().push([trackingTags.trackEvent(), 'work-in-collection', 'work-in-collection-download', collection]); + }); + } else { + gtag('event', 'file-set-download', { 'content-type': 'file-set', 'content-id': $(this).data('label')}) + gtag('event', 'file-set-in-work-download', { 'content-type': 'file-set-in-work', 'content-id': $(this).data('work-id')}) + $(this).data('collection-ids').forEach(function (collection) { + gtag('event', 'file-set-in-collection-download', { 'content-type': 'file-set-in-collection', 'content-id': collection }) + gtag('event', 'work-in-collection-download', { 'content-type': 'work-in-collection', 'content-id': collection }) + }); + } }); diff --git a/app/controllers/concerns/hyrax/singular_subresource_controller.rb b/app/controllers/concerns/hyrax/singular_subresource_controller.rb index b720a0408c..079e8c66a9 100644 --- a/app/controllers/concerns/hyrax/singular_subresource_controller.rb +++ b/app/controllers/concerns/hyrax/singular_subresource_controller.rb @@ -6,12 +6,17 @@ module SingularSubresourceController included do before_action :find_work, only: :work + before_action :find_file_set, only: :file load_and_authorize_resource :work, only: :work - load_and_authorize_resource :file, class: 'FileSet', only: :file, id_param: :id + load_and_authorize_resource :file, only: :file end def find_work - @work = Hyrax::WorkRelation.new.find(params[:id]) + @work = Hyrax.query_service.find_by(id: params[:id]) + end + + def find_file_set + @file = Hyrax.query_service.find_by(id: params[:id]) end end end diff --git a/app/controllers/hyrax/admin/analytics/work_reports_controller.rb b/app/controllers/hyrax/admin/analytics/work_reports_controller.rb index 1f2d817aba..d1a3567600 100644 --- a/app/controllers/hyrax/admin/analytics/work_reports_controller.rb +++ b/app/controllers/hyrax/admin/analytics/work_reports_controller.rb @@ -39,7 +39,7 @@ def show private def accessible_works - models = Hyrax.config.curation_concerns.map { |m| "\"#{m}\"" } + models = Hyrax::ModelRegistry.work_rdf_representations.map { |m| "\"#{m}\"" } if current_user.ability.admin? Hyrax::SolrService.query("has_model_ssim:(#{models.join(' OR ')})", fl: 'title_tesim, id, member_of_collections', @@ -54,15 +54,16 @@ def accessible_works end def accessible_file_sets + file_set_model_clause = "has_model_ssim:\"#{Hyrax::ModelRegistry.file_set_rdf_representations.join('" OR "')}\"" if current_user.ability.admin? Hyrax::SolrService.query( - "has_model_ssim:FileSet", + file_set_model_clause, fl: 'title_tesim, id', rows: 50_000 ) else Hyrax::SolrService.query( - "edit_access_person_ssim:#{current_user} AND has_model_ssim:FileSet", + "edit_access_person_ssim:#{current_user} AND #{file_set_model_clause}", fl: 'title_tesim, id', rows: 50_000 ) diff --git a/app/models/file_download_stat.rb b/app/models/file_download_stat.rb index d1fcf47136..4b8f2abde7 100644 --- a/app/models/file_download_stat.rb +++ b/app/models/file_download_stat.rb @@ -21,7 +21,7 @@ def ga_statistics(start_date, file) # this is called by the parent class def filter(file) - { file_id: file.id } + { file_id: file.id.to_s } end end end diff --git a/app/models/file_view_stat.rb b/app/models/file_view_stat.rb index b330972c53..16e499e79c 100644 --- a/app/models/file_view_stat.rb +++ b/app/models/file_view_stat.rb @@ -6,7 +6,7 @@ class FileViewStat < Hyrax::Statistic class << self # this is called by the parent class def filter(file) - { file_id: file.id } + { file_id: file.id.to_s } end end end diff --git a/app/models/hyrax/statistic.rb b/app/models/hyrax/statistic.rb index 488e407b62..fc8014b380 100644 --- a/app/models/hyrax/statistic.rb +++ b/app/models/hyrax/statistic.rb @@ -25,22 +25,6 @@ def statistics(object, start_date, user_id = nil) combined_stats object, start_date, cache_column, event_type, user_id end - # Hyrax::Download is sent to Hyrax::Analytics.profile as #hyrax__download - # see Legato::ProfileMethods.method_name_from_klass - def ga_statistics(start_date, object) - path = polymorphic_path(object) - profile = Hyrax::Analytics.profile - unless profile - Hyrax.logger.error("Google Analytics profile has not been established. Unable to fetch statistics.") - return [] - end - profile.hyrax__pageview(sort: 'date', - start_date: start_date, - end_date: Date.yesterday, - limit: 10_000) - .for_path(path) - end - def query_works(query) models = Hyrax.config.curation_concerns.map { |m| "\"#{m}\"" } Hyrax::SolrService.query("has_model_ssim:(#{models.join(' OR ')})", fl: query, rows: 100_000) @@ -80,10 +64,10 @@ def combined_stats(object, start_date, object_method, ga_key, user_id = nil) stat_cache_info = cached_stats(object, start_date, object_method) stats = stat_cache_info[:cached_stats] if stat_cache_info[:ga_start_date] < Time.zone.today - ga_stats = ga_statistics(stat_cache_info[:ga_start_date], object) - ga_stats.each do |stat| + page_stats = Hyrax::Analytics.page_statistics(stat_cache_info[:ga_start_date], object) + page_stats.each do |stat| lstat = build_for(object, date: stat[:date], object_method => stat[ga_key], user_id: user_id) - lstat.save unless Date.parse(stat[:date]) == Time.zone.today + lstat.save unless stat[:date].to_date == Time.zone.today stats << lstat end end diff --git a/app/presenters/hyrax/file_usage.rb b/app/presenters/hyrax/file_usage.rb index d1af4bfae0..914f9092dc 100644 --- a/app/presenters/hyrax/file_usage.rb +++ b/app/presenters/hyrax/file_usage.rb @@ -5,7 +5,7 @@ module Hyrax # and prepares it for visualization in /app/views/stats/file.html.erb class FileUsage < StatsUsagePresenter def initialize(id) - self.model = ::FileSet.find(id) + self.model = Hyrax.query_service.find_by(id: id) end alias file model diff --git a/app/presenters/hyrax/stats_usage_presenter.rb b/app/presenters/hyrax/stats_usage_presenter.rb index 9510e188c3..5892a62e97 100644 --- a/app/presenters/hyrax/stats_usage_presenter.rb +++ b/app/presenters/hyrax/stats_usage_presenter.rb @@ -36,6 +36,7 @@ def date_for_analytics end def string_to_date(date_str) + return date_str if date_str.is_a?(Date) Time.zone.parse(date_str) rescue ArgumentError, TypeError nil diff --git a/app/services/hyrax/analytics/ga4.rb b/app/services/hyrax/analytics/ga4.rb new file mode 100644 index 0000000000..fe162e1a7e --- /dev/null +++ b/app/services/hyrax/analytics/ga4.rb @@ -0,0 +1,203 @@ +# frozen_string_literal: true + +require 'oauth2' + +begin + require "google/analytics/data/v1beta" +rescue LoadError + $stderr.puts "Unable to load 'google/analytics/data/v1beta'; this is okay unless you are trying to do analytics reporting." +end + +module Hyrax + module Analytics + # rubocop:disable Metrics/ModuleLength + module Ga4 + extend ActiveSupport::Concern + # rubocop:disable Metrics/BlockLength + class_methods do + def client + @client + end + + def client=(value) + @client = value + end + + # Loads configuration options from config/analytics.yml. You only need PRIVATE_KEY_PATH or + # PRIVATE_KEY_VALUE. VALUE takes precedence. + # Expected structure: + # `analytics:` + # ` ga4:` + # analytics_id: <%= ENV['GOOGLE_ANALYTICS_ID'] %> + # property_id: <%= ENV['GOOGLE_ANALYTICS_PROPERTY_ID'] %> + # account_json: <%= ENV['GOOGLE_ACCOUNT_JSON'] %> + # account_json_path: <%= ENV['GOOGLE_ACCOUNT_JSON_PATH'] %> + # @return [Config] + def config + @config ||= Config.load_from_yaml + end + + class Config + def self.load_from_yaml + filename = Rails.root.join('config', 'analytics.yml') + yaml = YAML.safe_load(ERB.new(File.read(filename)).result) + unless yaml + Hyrax.logger.error("Unable to fetch any keys from #{filename}.") + return new({}) + end + config = yaml.fetch('analytics')&.fetch('ga4', nil) + unless config + Deprecation.warn("Deprecated analytics configuration format found. Please update config/analytics.yml.") + config = yaml.fetch('analytics') + # this has to exist here with a placeholder so it can be set in the Hyrax initializer + # it is only for backward compatibility + config['analytics_id'] = '-' + end + new config + end + + KEYS = %w[analytics_id property_id account_json account_json_path].freeze + REQUIRED_KEYS = %w[analytics_id property_id].freeze + + def initialize(config) + @config = config + end + + # @return [Boolean] are all the required values present? + def valid? + return false unless @config['account_json'].present? || @config['account_json_path'].present? + + REQUIRED_KEYS.all? { |required| @config[required].present? } + end + + def account_json_string + @account_json ||= @config['account_json'] || File.read(@config['account_json_path']) + end + + def account_info + @account_info ||= JSON.parse(account_json_string) + end + + KEYS.each do |key| + # rubocop:disable Style/EvalWithLocation + class_eval %{ def #{key}; @config.fetch('#{key}'); end } + class_eval %{ def #{key}=(value); @config['#{key}'] = value; end } + # rubocop:enable Style/EvalWithLocation + end + end + + def client + self.class.client ||= ::Google::Analytics::Data::V1beta::AnalyticsData::Client.new do |conf| + conf.credentials = config.account_info + end + end + + def property + "properties/#{config.property_id}" + end + + # rubocop:disable Metrics/MethodLength + def to_date_range(period) + case period + when "day" + start_date = Time.zone.today + end_date = Time.zone.today + when "week" + start_date = Time.zone.today - 7.days + end_date = Time.zone.today + when "month" + start_date = Time.zone.today - 1.month + end_date = Time.zone.today + when "year" + start_date = Time.zone.today - 1.year + end_date = Time.zone.today + end + + [start_date, end_date] + end + # rubocop:enable Metrics/MethodLength + + def keyword_conversion(date) + case date + when "last12" + start_date = Time.zone.today - 11.months + end_date = Time.zone.today + + [start_date, end_date] + else + date.split(",") + end + end + + def date_period(period, date) + if period == "range" + date.split(",") + else + to_date_range(period) + end + end + + # Configure analytics_start_date in ENV file + def default_date_range + "#{Hyrax.config.analytics_start_date},#{Time.zone.today + 1.day}" + end + + # The number of events by day for an action + def daily_events(action, date = default_date_range) + date = date.split(",") + EventsDaily.summary(date[0], date[1], action) + end + + # The number of events by day for an action and ID + def daily_events_for_id(id, action, date = default_date_range) + date = date.split(",") + EventsDaily.by_id(date[0], date[1], id, action) + end + + # A list of events sorted by highest event count + def top_events(action, date = default_date_range) + date = date.split(",") + Events.list(date[0], date[1], action) + end + + def unique_visitors(date = default_date_range); end + + def unique_visitors_for_id(id, date = default_date_range); end + + def new_visitors(period = 'month', date = default_date_range) + start_date, end_date = date_period(period, date) + Visits.new(start_date: start_date, end_date: end_date).new_visits + end + + def new_visits_by_day(date = default_date_range, period = 'range') + start_date, end_date = date_period(period, date) + VisitsDaily.new(start_date: start_date, end_date: end_date).new_visits + end + + def returning_visitors(period = 'month', date = default_date_range) + start_date, end_date = date_period(period, date) + Visits.new(start_date: start_date, end_date: end_date).return_visits + end + + def returning_visits_by_day(date = default_date_range, period = 'range') + start_date, end_date = date_period(period, date) + VisitsDaily.new(start_date: start_date, end_date: end_date).return_visits + end + + def total_visitors(period = 'month', date = default_date_range) + start_date, end_date = date_period(period, date) + Visits.new(start_date: start_date, end_date: end_date).total_visits + end + + def page_statistics(start_date, object) + visits = VisitsDaily.new(start_date: start_date, end_date: Date.yesterday) + visits.add_filter(dimension: 'contentId', values: [object.id.to_s]) + visits.total_visits + end + end + # rubocop:enable Metrics/BlockLength + end + # rubocop:enable Metrics/ModuleLength + end +end +# rubocop:enable Metrics/ModuleLength diff --git a/app/services/hyrax/analytics/ga4/base.rb b/app/services/hyrax/analytics/ga4/base.rb new file mode 100644 index 0000000000..0cc1bbfb79 --- /dev/null +++ b/app/services/hyrax/analytics/ga4/base.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true +module Hyrax + module Analytics + module Ga4 + class Base + attr_reader :start_date, :end_date, :dimensions, :metrics + + def filters + @filters ||= {} + end + + def filters=(value) + value + end + + def add_filter(dimension:, values:) + # reset any cached results + @results = nil + filters[dimension] ||= [] + filters[dimension] += values + end + + def results + @results ||= Hyrax::Analytics.client.run_report(report).rows + end + + def report + ::Google::Analytics::Data::V1beta::RunReportRequest.new( + property: Hyrax::Analytics.property, + metrics: metrics, + date_ranges: [{ start_date: start_date.iso8601, end_date: end_date.iso8601 }], + dimensions: dimensions, + dimension_filter: dimension_filter + ) + end + + def dimension_filter + return nil if filters.blank? + { + and_group: { + expressions: dimension_expressions + } + } + end + + def dimension_expressions + filters.map do |dimension, values| + { + filter: { + field_name: dimension, + in_list_filter: { values: values.uniq } + } + } + end + end + + def results_array(target_type = nil) + r = {} + # prefill dates so that all dates at least have 0 + (start_date..end_date).each do |date| + r[date] = 0 + end + results.each do |result| + date = unwrap_dimension(metric: result, dimension: 0) + type = unwrap_dimension(metric: result, dimension: 1) + next if date.nil? || type.nil? + next if target_type && type != target_type + date = date.to_date + r[date] += unwrap_metric(result) + end + Hyrax::Analytics::Results.new(r.to_a) + end + + protected + + def unwrap_dimension(metric:, dimension: 0) + metric.dimension_values[dimension]&.value + end + + def unwrap_metric(metric) + metric.metric_values.first.value.to_i + end + end + end + end +end diff --git a/app/services/hyrax/analytics/ga4/events.rb b/app/services/hyrax/analytics/ga4/events.rb new file mode 100644 index 0000000000..7453ed0941 --- /dev/null +++ b/app/services/hyrax/analytics/ga4/events.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +module Hyrax + module Analytics + module Ga4 + class Events < Hyrax::Analytics::Ga4::Base + def initialize(start_date:, + end_date:, + dimensions: [{ name: 'eventName' }, { name: 'contentType' }, { name: 'contentId' }], + metrics: [{ name: 'eventCount' }]) + super + @start_date = start_date.to_date + @end_date = end_date.to_date + @dimensions = dimensions + @metrics = metrics + end + + def self.list(start_date, end_date, action) + events = Events.new(start_date: start_date, end_date: end_date) + events.add_filter(dimension: 'eventName', values: [action]) + events.top_result_array + end + + def top_result_array + results.map { |r| [unwrap_dimension(metric: r, dimension: 2), unwrap_metric(r)] }.sort_by { |r| r[1] } + end + end + end + end +end diff --git a/app/services/hyrax/analytics/ga4/events_daily.rb b/app/services/hyrax/analytics/ga4/events_daily.rb new file mode 100644 index 0000000000..91da94555a --- /dev/null +++ b/app/services/hyrax/analytics/ga4/events_daily.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true +module Hyrax + module Analytics + module Ga4 + class EventsDaily < Hyrax::Analytics::Ga4::Base + def initialize(start_date:, + end_date:, + dimensions: [{ name: 'date' }, { name: 'eventName' }, { name: 'contentType' }, { name: 'contentId' }], + metrics: [{ name: 'eventCount' }]) + super + @start_date = start_date.to_date + @end_date = end_date.to_date + @dimensions = dimensions + @metrics = metrics + end + + # returns a daily number of events for a specific action + def self.summary(start_date, end_date, action) + events_daily = EventsDaily.new( + start_date: start_date, + end_date: end_date + ) + events_daily.add_filter(dimension: 'eventName', values: [action]) + events_daily.results_array + end + + # returns a daily number of events for a specific action + def self.by_id(start_date, end_date, id, action) + events_daily = EventsDaily.new( + start_date: start_date, + end_date: end_date + ) + events_daily.add_filter(dimension: 'contentId', values: [id]) + events_daily.add_filter(dimension: 'eventName', values: [action]) + events_daily.results_array + end + end + end + end +end diff --git a/app/services/hyrax/analytics/ga4/visits.rb b/app/services/hyrax/analytics/ga4/visits.rb new file mode 100644 index 0000000000..9479ef96c6 --- /dev/null +++ b/app/services/hyrax/analytics/ga4/visits.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true +module Hyrax + module Analytics + module Ga4 + class Visits < Hyrax::Analytics::Ga4::Base + def initialize(start_date:, end_date:, dimensions: [{ name: 'newVsReturning' }], metrics: [{ name: 'sessions' }]) + super + @start_date = start_date.to_date + @end_date = end_date.to_date + @dimensions = dimensions + @metrics = metrics + end + + def new_visits + unwrap_metric(results.detect { |r| unwrap_dimension(metric: r) == 'new' }) + end + + def return_visits + unwrap_metric(results.detect { |r| unwrap_dimension(metric: r) == 'returning' }) + end + + def unknown_visits + empty_metrics = results.detect { |r| unwrap_dimension(metric: r) == '' } + not_set_metrics = results.detect { |r| unwrap_dimension(metric: r) == '(not set)' } + unknown = 0 + unknown += unwrap_metric(empty_metrics) if empty_metrics.present? + unknown += unwrap_metric(not_set_metrics) if not_set_metrics.present? + unknown + end + + def total_visits + new_visits + return_visits + unknown_visits + end + end + end + end +end diff --git a/app/services/hyrax/analytics/ga4/visits_daily.rb b/app/services/hyrax/analytics/ga4/visits_daily.rb new file mode 100644 index 0000000000..2194eff7d7 --- /dev/null +++ b/app/services/hyrax/analytics/ga4/visits_daily.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +module Hyrax + module Analytics + module Ga4 + class VisitsDaily < Hyrax::Analytics::Ga4::Base + def initialize(start_date:, end_date:, dimensions: [{ name: 'date' }, { name: 'newVsReturning' }], metrics: [{ name: 'sessions' }]) + super + @start_date = start_date.to_date + @end_date = end_date.to_date + @dimensions = dimensions + @metrics = metrics + end + + def new_visits + results_array('new') + end + + def return_visits + results_array('returning') + end + + def total_visits + results_array + end + end + end + end +end diff --git a/app/services/hyrax/analytics/google.rb b/app/services/hyrax/analytics/google.rb index 294124d825..79540bfca5 100644 --- a/app/services/hyrax/analytics/google.rb +++ b/app/services/hyrax/analytics/google.rb @@ -202,6 +202,22 @@ def total_visitors(period = 'month', date = default_date_range) date = date_period(period, date) Visits.total_visits(profile, date[0], date[1]) end + + # Hyrax::Download is sent to Hyrax::Analytics.profile as #hyrax__download + # see Legato::ProfileMethods.method_name_from_klass + def page_statistics(start_date, object) + path = Rails.application.routes.url_helpers.polymorphic_path(object) + profile = Hyrax::Analytics.profile + unless profile + Hyrax.logger.error("Google Analytics profile has not been established. Unable to fetch statistics.") + return [] + end + profile.hyrax__pageview(sort: 'date', + start_date: start_date, + end_date: Date.yesterday, + limit: 10_000) + .for_path(path) + end end # rubocop:enable Metrics/BlockLength end diff --git a/app/services/hyrax/analytics/matomo.rb b/app/services/hyrax/analytics/matomo.rb index 73ad4edf98..ac9c83c32f 100644 --- a/app/services/hyrax/analytics/matomo.rb +++ b/app/services/hyrax/analytics/matomo.rb @@ -154,6 +154,11 @@ def total_visitors(period = 'month', date = 'today') response["nb_visits_returning"].to_i + response["nb_visits_new"].to_i end + # TODO: implement + def page_statistics(_start_date, _object) + [] + end + def results_array(response, metric) results = [] response.each do |result| diff --git a/app/services/hyrax/analytics/results.rb b/app/services/hyrax/analytics/results.rb index ed64302c6e..abb8b795d5 100644 --- a/app/services/hyrax/analytics/results.rb +++ b/app/services/hyrax/analytics/results.rb @@ -74,6 +74,12 @@ def to_flot fields = [:date, :pageviews] results.map { |row| fields.zip(row).to_h } end + + def each + results.each do |result| + yield({ date: result[0], pageviews: result[1] }) + end + end end end end diff --git a/app/views/hyrax/dashboard/show_admin.html.erb b/app/views/hyrax/dashboard/show_admin.html.erb index 0ae2ecb923..ff183f2446 100644 --- a/app/views/hyrax/dashboard/show_admin.html.erb +++ b/app/views/hyrax/dashboard/show_admin.html.erb @@ -4,33 +4,33 @@
<% if Hyrax.config.analytics_reporting? %> -
-
-
-
-

<%= t(".graph_reports") %>:

- <%= params[:start_date].present? ? params[:start_date].to_date : 1.month.ago.beginning_of_day.to_date %> - - <%= params[:end_date].present? ? params[:end_date].to_date : Time.zone.now.end_of_day.to_date %> -
-
- <%= render "hyrax/admin/analytics/date_range_form", redirect_path: hyrax.dashboard_path %> +
+
+
+
+

<%= t(".graph_reports") %>:

+ <%= params[:start_date].present? ? params[:start_date].to_date : 1.month.ago.beginning_of_day.to_date %> - + <%= params[:end_date].present? ? params[:end_date].to_date : Time.zone.now.end_of_day.to_date %> +
+
+ <%= render "hyrax/admin/analytics/date_range_form", redirect_path: hyrax.dashboard_path %> +
-
-
-
-
- <%= render 'user_activity' %> +
+
+
+ <%= render 'user_activity' %> +
-
<% end %>
- <%= render 'repository_growth' %> + <%= render 'repository_growth' %>
@@ -50,4 +50,3 @@
<%= render 'tabs' %>
- diff --git a/app/views/layouts/_head_tag_content.html.erb b/app/views/layouts/_head_tag_content.html.erb index df6c0f185f..d61ffb04a3 100644 --- a/app/views/layouts/_head_tag_content.html.erb +++ b/app/views/layouts/_head_tag_content.html.erb @@ -29,6 +29,8 @@ signed in %> <% if Hyrax.config.analytics? %> <% if Hyrax.config.analytics_provider == 'google' %> <%= render partial: 'shared/ga', formats: [:html] %> + <% elsif Hyrax.config.analytics_provider == 'ga4' %> + <%= render partial: 'shared/ga4', formats: [:html] %> <% elsif Hyrax.config.analytics_provider == 'matomo' %> <%= render partial: 'shared/matomo', formats: [:html] %> <% end %> diff --git a/app/views/shared/_ga4.html.erb b/app/views/shared/_ga4.html.erb new file mode 100644 index 0000000000..f917bf577f --- /dev/null +++ b/app/views/shared/_ga4.html.erb @@ -0,0 +1,11 @@ + + + + diff --git a/hyrax.gemspec b/hyrax.gemspec index 4018b2def0..28a03b0dd4 100644 --- a/hyrax.gemspec +++ b/hyrax.gemspec @@ -55,6 +55,7 @@ SUMMARY # Pin more tightly because 0.x gems are potentially unstable spec.add_dependency 'flot-rails', '~> 0.0.6' spec.add_dependency 'font-awesome-rails', '~> 4.2' + spec.add_dependency 'google-analytics-data', '~> 0.6' spec.add_dependency 'hydra-derivatives', '~> 3.3' spec.add_dependency 'hydra-editor', '~> 6.0' spec.add_dependency 'hydra-file_characterization', '~> 1.1' diff --git a/lib/generators/hyrax/install_generator.rb b/lib/generators/hyrax/install_generator.rb index c13c72a465..834e552061 100644 --- a/lib/generators/hyrax/install_generator.rb +++ b/lib/generators/hyrax/install_generator.rb @@ -209,5 +209,10 @@ def dotenv gem 'dotenv-rails', '~> 2.8' end end + + def support_analytics + gem 'google-protobuf', force_ruby_platform: true # required because google-protobuf is not compatible with Alpine linux + gem 'grpc', force_ruby_platform: true # required because grpc is not compatible with Alpine linux + end end end diff --git a/lib/generators/hyrax/templates/config/analytics.yml b/lib/generators/hyrax/templates/config/analytics.yml index 9153a323bc..c3229246a7 100644 --- a/lib/generators/hyrax/templates/config/analytics.yml +++ b/lib/generators/hyrax/templates/config/analytics.yml @@ -2,12 +2,17 @@ # You can manually fill in these values or use the ENV variables. # analytics: + ga4: + analytics_id: <%= ENV['GOOGLE_ANALYTICS_ID'] %> + property_id: <%= ENV['GOOGLE_ANALYTICS_PROPERTY_ID'] %> + account_json: <%= ENV['GOOGLE_ACCOUNT_JSON'] %> + account_json_path: <%= ENV['GOOGLE_ACCOUNT_JSON_PATH'] %> google: analytics_id: <%= ENV['GOOGLE_ANALYTICS_ID'] %> app_name: <%= ENV['GOOGLE_OAUTH_APP_NAME'] %> app_version: <%= ENV['GOOGLE_OAUTH_APP_VERSION'] %> - privkey_value: <%= ENV['GOOGLE_OAUTH_PRIVATE_KEY_VALUE'] %> privkey_path: <%= ENV['GOOGLE_OAUTH_PRIVATE_KEY_PATH'] %> + privkey_value: <%= ENV['GOOGLE_OAUTH_PRIVATE_KEY_VALUE'] %> privkey_secret: <%= ENV['GOOGLE_OAUTH_PRIVATE_KEY_SECRET'] %> client_email: <%= ENV['GOOGLE_OAUTH_CLIENT_EMAIL'] %> matomo: diff --git a/spec/models/file_download_stat_spec.rb b/spec/models/file_download_stat_spec.rb index ea6dfcc56a..5263af64da 100644 --- a/spec/models/file_download_stat_spec.rb +++ b/spec/models/file_download_stat_spec.rb @@ -14,32 +14,6 @@ expect(file_stat.downloads).to eq(2) end - describe ".ga_statistic" do - let(:start_date) { 2.days.ago } - let(:expected_path) { Rails.application.routes.url_helpers.hyrax_file_set_path(file) } - - before do - allow(Hyrax::Analytics).to receive(:profile).and_return(profile) - end - context "when a profile is available" do - let(:views) { double } - let(:profile) { double(hyrax__download: views) } - - it "calls the Legato method with the correct path" do - expect(views).to receive(:for_file).with(99) - described_class.ga_statistics(start_date, file) - end - end - - context "when a profile not available" do - let(:profile) { nil } - - it "calls the Legato method with the correct path" do - expect(described_class.ga_statistics(start_date, file)).to be_empty - end - end - end - describe "#statistics" do let(:dates) do ldates = [] @@ -70,7 +44,7 @@ describe "cache empty" do let(:stats) do - expect(described_class).to receive(:ga_statistics).and_return(sample_download_statistics) + expect(Hyrax::Analytics).to receive(:page_statistics).and_return(sample_download_statistics) described_class.statistics(file, Time.zone.today - 4.days) end @@ -82,8 +56,7 @@ expect(stats.map(&:to_flot)).to include(*download_output) # at this point all data should be cached - allow(described_class).to receive(:ga_statistics).with(Time.zone.today, file).and_raise("We should not call Google Analytics All data should be cached!") - + allow(Hyrax::Analytics).to receive(:page_statistics).and_raise("We should not call Google Analytics All data should be cached!") stats2 = described_class.statistics(file, Time.zone.today - 4.days) expect(stats2.map(&:to_flot)).to include(*download_output) end @@ -93,7 +66,7 @@ let!(:file_download_stat) { described_class.create(date: (Time.zone.today - 5.days).to_datetime, file_id: file_id, downloads: "25") } let(:stats) do - expect(described_class).to receive(:ga_statistics).and_return(sample_download_statistics) + expect(Hyrax::Analytics).to receive(:page_statistics).and_return(sample_download_statistics) described_class.statistics(file, Time.zone.today - 5.days) end diff --git a/spec/models/file_view_stat_spec.rb b/spec/models/file_view_stat_spec.rb index e76e5678b9..2ba2d8f0ed 100644 --- a/spec/models/file_view_stat_spec.rb +++ b/spec/models/file_view_stat_spec.rb @@ -46,7 +46,7 @@ describe "cache empty" do let(:stats) do - expect(described_class).to receive(:ga_statistics).and_return(sample_pageview_statistics) + expect(Hyrax::Analytics).to receive(:page_statistics).and_return(sample_pageview_statistics) described_class.statistics(file, Time.zone.today - 4.days, user_id) end @@ -70,7 +70,7 @@ let!(:file_view_stat) { described_class.create(date: (Time.zone.today - 5.days).to_datetime, file_id: file_id, views: "25") } let(:stats) do - expect(described_class).to receive(:ga_statistics).and_return(sample_pageview_statistics) + expect(Hyrax::Analytics).to receive(:page_statistics).and_return(sample_pageview_statistics) described_class.statistics(file, Time.zone.today - 5.days) end diff --git a/spec/models/work_view_stat_spec.rb b/spec/models/work_view_stat_spec.rb index a822b9d70b..033ecc03e9 100644 --- a/spec/models/work_view_stat_spec.rb +++ b/spec/models/work_view_stat_spec.rb @@ -16,32 +16,6 @@ expect(work_stat.user_id).to eq(user_id) end - describe ".ga_statistic" do - let(:start_date) { 2.days.ago } - let(:expected_path) { "/concern/monographs/#{work_id}" } - - before do - allow(Hyrax::Analytics).to receive(:profile).and_return(profile) - end - context "when a profile is available" do - let(:views) { double } - let(:profile) { double(hyrax__pageview: views) } - - it "calls the Legato method with the correct path" do - expect(views).to receive(:for_path).with(expected_path) - described_class.ga_statistics(start_date, work) - end - end - - context "when a profile not available" do - let(:profile) { nil } - - it "calls the Legato method with the correct path" do - expect(described_class.ga_statistics(start_date, work)).to be_empty - end - end - end - describe "#statistics" do let(:dates) do ldates = [] @@ -72,7 +46,7 @@ describe "cache empty" do let(:stats) do - expect(described_class).to receive(:ga_statistics).and_return(sample_work_pageview_statistics) + expect(Hyrax::Analytics).to receive(:page_statistics).and_return(sample_work_pageview_statistics) described_class.statistics(work, Time.zone.today - 4.days, user_id) end @@ -96,7 +70,7 @@ let!(:work_view_stat) { described_class.create(date: (Time.zone.today - 5.days).to_datetime, work_id: work_id, work_views: "25") } let(:stats) do - expect(described_class).to receive(:ga_statistics).and_return(sample_work_pageview_statistics) + expect(Hyrax::Analytics).to receive(:page_statistics).and_return(sample_work_pageview_statistics) described_class.statistics(work, Time.zone.today - 5.days) end