diff --git a/backend/app/controllers/admin/analytics_controller.rb b/backend/app/controllers/admin/analytics_controller.rb new file mode 100644 index 0000000..408c40f --- /dev/null +++ b/backend/app/controllers/admin/analytics_controller.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Admin + class AnalyticsController < Admin::BaseController + def index + @filters = { + from: parse_date(params[:from]) || 7.days.ago.to_date, + to: parse_date(params[:to]) || Time.zone.today, + device: params[:device].presence + } + + scope = AnalyticsPageStat.where(day: @filters[:from]..@filters[:to]) + scope = scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device]) + + click_scope = AnalyticsPageCell.where(day: @filters[:from]..@filters[:to]) + click_scope = click_scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device]) + + clicks_by_path = click_scope.group(:page_path).sum(:click_count) + pageviews_by_path = scope.group(:page_path).sum(:pageview_count) + scroll_samples_by_path = scope.group(:page_path).sum(:scroll_samples) + scroll_sum_by_path = scope.group(:page_path).sum(:scroll_sum_pct) + max_scroll_by_path = scope.group(:page_path).maximum(:max_scroll_pct) + + paths = (pageviews_by_path.keys + clicks_by_path.keys).uniq + @pages = paths.map do |path| + samples = scroll_samples_by_path[path].to_i + { + page_path: path, + pageviews: pageviews_by_path[path].to_i, + clicks: clicks_by_path[path].to_i, + scroll_samples: samples, + scroll_sum: scroll_sum_by_path[path].to_i, + max_scroll: max_scroll_by_path[path].to_i + } + end.sort_by { |r| [-r[:pageviews], -r[:clicks], r[:page_path]] } + end + + def show + @page_path = params[:page_path].to_s + redirect_to admin_analytics_path, alert: t("admin.analytics.missing_path") and return if @page_path.blank? + + @filters = { + from: parse_date(params[:from]) || 7.days.ago.to_date, + to: parse_date(params[:to]) || Time.zone.today, + device: params[:device].presence + } + + cells = AnalyticsPageCell.where(page_path: @page_path, day: @filters[:from]..@filters[:to]) + cells = cells.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device]) + @cells = cells.group(:cell_x, :cell_y).sum(:click_count) + @max_clicks = @cells.values.max.to_i + + stats = AnalyticsPageStat.where(page_path: @page_path, day: @filters[:from]..@filters[:to]) + stats = stats.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device]) + @pageviews = stats.sum(:pageview_count) + @scroll_samples = stats.sum(:scroll_samples) + @scroll_sum = stats.sum(:scroll_sum_pct) + @max_scroll = stats.maximum(:max_scroll_pct).to_i + @avg_scroll = @scroll_samples.positive? ? (@scroll_sum.to_f / @scroll_samples).round : 0 + @grid = AnalyticsPageCell::GRID_SIZE + end + + private + + def parse_date(value) + return nil if value.blank? + + Date.parse(value.to_s) + rescue ArgumentError, TypeError + nil + end + end +end diff --git a/backend/app/controllers/analytics/events_controller.rb b/backend/app/controllers/analytics/events_controller.rb new file mode 100644 index 0000000..33b8bba --- /dev/null +++ b/backend/app/controllers/analytics/events_controller.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Analytics + class EventsController < ActionController::API + def create + payload = parse_payload + result = Analytics::Ingest.new(events: payload, remote_ip: request.remote_ip).call + + if result.rate_limited + return head :too_many_requests + end + + render json: { accepted: result.accepted, rejected: result.rejected }, status: :accepted + end + + private + + def parse_payload + body = request.request_parameters + return body["events"] if body.is_a?(Hash) && body["events"].is_a?(Array) + return body if body.is_a?(Array) + + raw = request.raw_post + return [] if raw.blank? + + parsed = JSON.parse(raw) + return parsed["events"] if parsed.is_a?(Hash) && parsed["events"].is_a?(Array) + return parsed if parsed.is_a?(Array) + + [] + rescue JSON::ParserError + [] + end + end +end diff --git a/backend/app/jobs/analytics/aggregate_job.rb b/backend/app/jobs/analytics/aggregate_job.rb new file mode 100644 index 0000000..eafd9b2 --- /dev/null +++ b/backend/app/jobs/analytics/aggregate_job.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Analytics + class AggregateJob < ApplicationJob + queue_as :default + + def perform(purge: false) + redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")) + lock_key = "analytics:aggregate:lock" + acquired = redis.set(lock_key, "1", nx: true, ex: 120) + return unless acquired + + begin + service = Analytics::Aggregate.new + service.call + service.purge_old! if purge + ensure + redis.del(lock_key) + end + rescue StandardError => e + Rails.logger.error("[analytics] aggregate failed: #{e.class} #{e.message}") + raise + end + end +end diff --git a/backend/app/models/analytics_event.rb b/backend/app/models/analytics_event.rb new file mode 100644 index 0000000..a1bde6c --- /dev/null +++ b/backend/app/models/analytics_event.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AnalyticsEvent < ApplicationRecord + self.table_name = "analytics_events" + + EVENT_TYPES = %w[click scroll pageview].freeze + DEVICES = %w[mobile tablet desktop].freeze + + validates :page_path, presence: true + validates :device, inclusion: { in: DEVICES } + validates :event_type, inclusion: { in: EVENT_TYPES } + validates :occurred_at, presence: true +end diff --git a/backend/app/models/analytics_page_cell.rb b/backend/app/models/analytics_page_cell.rb new file mode 100644 index 0000000..f590204 --- /dev/null +++ b/backend/app/models/analytics_page_cell.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AnalyticsPageCell < ApplicationRecord + self.table_name = "analytics_page_cells" + + GRID_SIZE = 40 + + validates :day, :page_path, :device, :cell_x, :cell_y, presence: true + validates :device, inclusion: { in: AnalyticsEvent::DEVICES } + validates :cell_x, :cell_y, inclusion: { in: 0...(GRID_SIZE) } + validates :click_count, numericality: { greater_than_or_equal_to: 0 } +end diff --git a/backend/app/models/analytics_page_stat.rb b/backend/app/models/analytics_page_stat.rb new file mode 100644 index 0000000..af8d421 --- /dev/null +++ b/backend/app/models/analytics_page_stat.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AnalyticsPageStat < ApplicationRecord + self.table_name = "analytics_page_stats" + + validates :day, :page_path, :device, presence: true + validates :device, inclusion: { in: AnalyticsEvent::DEVICES } + + def avg_scroll_pct + return 0 if scroll_samples.to_i <= 0 + + (scroll_sum_pct.to_f / scroll_samples).round + end +end diff --git a/backend/app/services/analytics/aggregate.rb b/backend/app/services/analytics/aggregate.rb new file mode 100644 index 0000000..d768f49 --- /dev/null +++ b/backend/app/services/analytics/aggregate.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Analytics + class Aggregate + BATCH = 500 + + def call + loop do + ids = AnalyticsEvent.order(:occurred_at).limit(BATCH).pluck(:id) + break if ids.empty? + + events = AnalyticsEvent.where(id: ids).to_a + apply_clicks(events.select { |e| e.event_type == "click" }) + apply_stats(events.reject { |e| e.event_type == "click" }) + AnalyticsEvent.where(id: ids).delete_all + end + end + + def purge_old!(retention: 30.days) + # Raw should already be empty after aggregate; keep as safety net. + AnalyticsEvent.where("occurred_at < ?", retention.ago).delete_all + AnalyticsPageCell.where("day < ?", retention.ago.to_date).delete_all + AnalyticsPageStat.where("day < ?", retention.ago.to_date).delete_all + end + + private + + def apply_clicks(events) + return if events.empty? + + grid = AnalyticsPageCell::GRID_SIZE + grouped = Hash.new(0) + events.each do |e| + next if e.x_pct.nil? || e.y_pct.nil? + + cell_x = [[(e.x_pct.to_f / 100 * grid).floor, grid - 1].min, 0].max + cell_y = [[(e.y_pct.to_f / 100 * grid).floor, grid - 1].min, 0].max + key = [e.occurred_at.in_time_zone.to_date, e.page_path, e.device, cell_x, cell_y] + grouped[key] += 1 + end + + now = Time.current + grouped.each do |(day, page_path, device, cell_x, cell_y), count| + cell = AnalyticsPageCell.find_or_initialize_by( + day: day, page_path: page_path, device: device, cell_x: cell_x, cell_y: cell_y + ) + cell.click_count = cell.click_count.to_i + count + cell.created_at ||= now + cell.updated_at = now + cell.save! + end + end + + def apply_stats(events) + return if events.empty? + + grouped = {} + events.each do |e| + day = e.occurred_at.in_time_zone.to_date + key = [day, e.page_path, e.device] + bucket = grouped[key] ||= { pageviews: 0, scroll_samples: 0, scroll_sum: 0, max_scroll: 0 } + case e.event_type + when "pageview" + bucket[:pageviews] += 1 + when "scroll" + pct = e.scroll_pct.to_f.round + bucket[:scroll_samples] += 1 + bucket[:scroll_sum] += pct + bucket[:max_scroll] = [bucket[:max_scroll], pct].max + end + end + + now = Time.current + grouped.each do |(day, page_path, device), vals| + stat = AnalyticsPageStat.find_or_initialize_by(day: day, page_path: page_path, device: device) + stat.pageview_count = stat.pageview_count.to_i + vals[:pageviews] + stat.scroll_samples = stat.scroll_samples.to_i + vals[:scroll_samples] + stat.scroll_sum_pct = stat.scroll_sum_pct.to_i + vals[:scroll_sum] + stat.max_scroll_pct = [stat.max_scroll_pct.to_i, vals[:max_scroll]].max + stat.created_at ||= now + stat.updated_at = now + stat.save! + end + end + end +end diff --git a/backend/app/services/analytics/ingest.rb b/backend/app/services/analytics/ingest.rb new file mode 100644 index 0000000..3139f4f --- /dev/null +++ b/backend/app/services/analytics/ingest.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +module Analytics + class Ingest + MAX_BATCH = 50 + RATE_LIMIT_PER_MINUTE = 60 + + Result = Struct.new(:accepted, :rejected, :rate_limited, keyword_init: true) + + def initialize(events:, remote_ip:) + @events = Array(events) + @remote_ip = remote_ip.to_s.presence || "unknown" + end + + def call + return Result.new(accepted: 0, rejected: 0, rate_limited: true) if rate_limited? + + slice = @events.first(MAX_BATCH) + accepted = 0 + rejected = 0 + rows = [] + + slice.each do |raw| + attrs = build_attrs(raw) + if attrs + rows << attrs + accepted += 1 + else + rejected += 1 + end + end + + AnalyticsEvent.insert_all(rows) if rows.any? + Analytics::Aggregate.new.call if rows.any? + + Result.new(accepted: accepted, rejected: rejected + (@events.size - slice.size), rate_limited: false) + end + + private + + def rate_limited? + key = "analytics:ingest:#{@remote_ip}" + redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")) + count = redis.incr(key) + redis.expire(key, 60) if count == 1 + count > RATE_LIMIT_PER_MINUTE + rescue StandardError => e + Rails.logger.warn("[analytics] rate limit unavailable: #{e.class}") + false + end + + def build_attrs(raw) + data = raw.is_a?(Hash) ? raw.with_indifferent_access : {} + event_type = data[:type].to_s.presence || data[:event_type].to_s + return nil unless AnalyticsEvent::EVENT_TYPES.include?(event_type) + + device = data[:device].to_s + return nil unless AnalyticsEvent::DEVICES.include?(device) + + page_path = PathNormalizer.normalize(data[:path] || data[:page_path]) + return nil if PathNormalizer.excluded?(page_path) + return nil if page_path.length > 200 + + occurred_at = parse_time(data[:ts] || data[:occurred_at]) || Time.current + attrs = { + page_path: page_path, + device: device, + event_type: event_type, + occurred_at: occurred_at, + x_pct: nil, + y_pct: nil, + scroll_pct: nil, + created_at: Time.current, + updated_at: Time.current + } + + case event_type + when "click" + x = clamp_pct(data[:x] || data[:x_pct]) + y = clamp_pct(data[:y] || data[:y_pct]) + return nil if x.nil? || y.nil? + + attrs[:x_pct] = x + attrs[:y_pct] = y + when "scroll" + scroll = clamp_pct(data[:scroll] || data[:scroll_pct]) + return nil if scroll.nil? + + attrs[:scroll_pct] = scroll + when "pageview" + # no extra fields + end + + attrs + end + + def clamp_pct(value) + return nil if value.nil? + + n = Float(value) + return nil if n.nan? || n.infinite? + return nil if n.negative? || n > 100 + + n.round(2) + rescue ArgumentError, TypeError + nil + end + + def parse_time(value) + return value if value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone) + return Time.zone.at(value.to_i / 1000.0) if value.is_a?(Numeric) || value.to_s.match?(/\A\d{10,13}\z/) + + Time.zone.parse(value.to_s) + rescue ArgumentError, TypeError + nil + end + end +end diff --git a/backend/app/services/analytics/path_normalizer.rb b/backend/app/services/analytics/path_normalizer.rb new file mode 100644 index 0000000..9ae5ab6 --- /dev/null +++ b/backend/app/services/analytics/path_normalizer.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Analytics + class PathNormalizer + UUID_RE = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i + NUMERIC_RE = /\A\d{6,}\z/ + TOKENISH_RE = /\A[A-Za-z0-9_\-]{22,}\z/ + + EXCLUDED_PREFIXES = %w[/admin /regia /analytics /cable /api /webhooks /internal].freeze + + class << self + def normalize(raw_path) + path = raw_path.to_s.split("?", 2).first.to_s + path = "/" if path.blank? + path = "/#{path}" unless path.start_with?("/") + path = path.gsub(%r{/{2,}}, "/").chomp("/") + path = "/" if path.blank? + + parts = path.split("/").map do |segment| + next segment if segment.blank? + next ":id" if UUID_RE.match?(segment) + next ":id" if NUMERIC_RE.match?(segment) + next ":token" if TOKENISH_RE.match?(segment) && segment.match?(/[A-Z]/) + + segment + end + normalized = parts.join("/") + normalized = "/" if normalized.blank? + normalized + end + + def excluded?(raw_or_normalized) + path = normalize(raw_or_normalized) + return true if EXCLUDED_PREFIXES.any? { |p| path == p || path.start_with?("#{p}/") } + return true if path.match?(%r{\A/live/[^/]+}) + return true if path.match?(%r{\A/replay/[^/]+}) + + false + end + end + end +end diff --git a/backend/app/views/admin/analytics/index.html.erb b/backend/app/views/admin/analytics/index.html.erb new file mode 100644 index 0000000..043d5eb --- /dev/null +++ b/backend/app/views/admin/analytics/index.html.erb @@ -0,0 +1,70 @@ +<% content_for :body_class, "admin-body" %> + +
+

<%= t("admin.analytics.index.title") %>

+

<%= t("admin.analytics.index.lead") %>

+
+ +
+ <%= form_with url: admin_analytics_path, method: :get, local: true, class: "admin-filter-form" do %> +
+ + + +
+
+ <%= submit_tag t("admin.analytics.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %> + <%= link_to t("admin.analytics.filters.reset"), admin_analytics_path, class: "admin-btn admin-btn--outline admin-btn--sm" %> +
+ <% end %> +
+ +
+ <% if @pages.any? %> +
+ + + + + + + + + + + + + <% @pages.each do |row| %> + <% avg = row[:scroll_samples].positive? ? (row[:scroll_sum].to_f / row[:scroll_samples]).round : 0 %> + + + + + + + + + <% end %> + +
<%= t("admin.analytics.index.table.path") %><%= t("admin.analytics.index.table.pageviews") %><%= t("admin.analytics.index.table.clicks") %><%= t("admin.analytics.index.table.avg_scroll") %><%= t("admin.analytics.index.table.max_scroll") %>
<%= row[:page_path] %><%= row[:pageviews] %><%= row[:clicks] %><%= avg %>%<%= row[:max_scroll] %>% + <%= link_to t("admin.analytics.index.heatmap"), + admin_analytics_page_path(page_path: row[:page_path], from: @filters[:from], to: @filters[:to], device: @filters[:device]) %> +
+
+ <% else %> +

<%= t("admin.analytics.index.none") %>

+ <% end %> +
diff --git a/backend/app/views/admin/analytics/show.html.erb b/backend/app/views/admin/analytics/show.html.erb new file mode 100644 index 0000000..a3d7434 --- /dev/null +++ b/backend/app/views/admin/analytics/show.html.erb @@ -0,0 +1,115 @@ +<% content_for :body_class, "admin-body" %> + +
+
+

+ <%= link_to t("admin.analytics.show.back"), admin_analytics_path(from: @filters[:from], to: @filters[:to], device: @filters[:device]) %> +

+

<%= t("admin.analytics.show.title") %>

+

<%= @page_path %>

+
+
+ +
+ <%= form_with url: admin_analytics_page_path, method: :get, local: true, class: "admin-filter-form" do %> + <%= hidden_field_tag :page_path, @page_path %> +
+ + + +
+
+ <%= submit_tag t("admin.analytics.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %> +
+ <% end %> +
+ +
+
+

<%= t("admin.analytics.show.summary") %>

+
+
+
<%= t("admin.analytics.index.table.pageviews") %>
+
<%= @pageviews %>
+
+
+
<%= t("admin.analytics.index.table.clicks") %>
+
<%= @cells.values.sum %>
+
+
+
<%= t("admin.analytics.index.table.avg_scroll") %>
+
<%= @avg_scroll %>%
+
+
+
<%= t("admin.analytics.index.table.max_scroll") %>
+
<%= @max_scroll %>%
+
+
+
+ +
+

<%= t("admin.analytics.show.scroll_depth") %>

+ +

+ <%= t("admin.analytics.show.scroll_hint", avg: @avg_scroll, max: @max_scroll) %> +

+
+
+ +
+

<%= t("admin.analytics.show.heatmap_title") %>

+ <% if @cells.any? %> +
+ +
+ + <% else %> +

<%= t("admin.analytics.show.no_clicks") %>

+ <% end %> +
diff --git a/backend/app/views/layouts/admin.html.erb b/backend/app/views/layouts/admin.html.erb index 246e162..0233dfb 100644 --- a/backend/app/views/layouts/admin.html.erb +++ b/backend/app/views/layouts/admin.html.erb @@ -5,7 +5,7 @@ <%= csrf_meta_tags %> - + <% if content_for?(:replay_archive_styles) %> <% end %> @@ -31,6 +31,7 @@ <%= link_to t("admin.layout.nav.billing"), admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %> <%= link_to t("admin.layout.nav.youtube"), admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %> <%= link_to t("admin.layout.nav.sessions"), admin_sessions_path, class: ("active" if controller_name == "sessions") %> + <%= link_to t("admin.layout.nav.analytics"), admin_analytics_path, class: ("active" if controller_name == "analytics") %> <%= link_to t("admin.layout.nav.stream_nodes"), admin_stream_nodes_path, class: ("active" if controller_name == "stream_nodes") %> <%= link_to t("admin.layout.nav.password"), edit_admin_password_path %> <%= button_to t("admin.layout.nav.logout"), admin_logout_path, method: :delete %> diff --git a/backend/app/views/layouts/marketing.html.erb b/backend/app/views/layouts/marketing.html.erb index 614442a..62807eb 100644 --- a/backend/app/views/layouts/marketing.html.erb +++ b/backend/app/views/layouts/marketing.html.erb @@ -26,6 +26,7 @@ - + + diff --git a/backend/app/views/layouts/marketing_live.html.erb b/backend/app/views/layouts/marketing_live.html.erb index ea2fb4e..e24f5c1 100644 --- a/backend/app/views/layouts/marketing_live.html.erb +++ b/backend/app/views/layouts/marketing_live.html.erb @@ -21,6 +21,7 @@ <%= render "shared/marketing_footer" %> - + + diff --git a/backend/app/views/public/pages/cookies.html.erb b/backend/app/views/public/pages/cookies.html.erb index b3d0634..1a458b8 100644 --- a/backend/app/views/public/pages/cookies.html.erb +++ b/backend/app/views/public/pages/cookies.html.erb @@ -101,6 +101,12 @@ <%= t("legal.cookies.s4_2_row3_duration") %> <%= t("legal.cookies.s4_2_row3_provider") %> + + <%= raw t("legal.cookies.s4_2_row4_name_html") %> + <%= t("legal.cookies.s4_2_row4_purpose") %> + <%= t("legal.cookies.s4_2_row4_duration") %> + <%= t("legal.cookies.s4_2_row4_provider") %> + diff --git a/backend/config/locales/admin.de.yml b/backend/config/locales/admin.de.yml index 0a062ab..aec4dda 100644 --- a/backend/config/locales/admin.de.yml +++ b/backend/config/locales/admin.de.yml @@ -10,6 +10,7 @@ de: billing: Abrechnung youtube: YouTube sessions: Sitzungen + analytics: Analytics stream_nodes: Stream-Knoten password: Passwort logout: Abmelden @@ -350,6 +351,35 @@ de: lab: Lab cloud: Hetzner + analytics: + missing_path: Seite auswählen. + filters: + from: Von + to: Bis + device: Gerät + any: Alle + apply: Filtern + reset: Zurücksetzen + index: + title: Website-Analytics + lead: Aggregierte First-Party-Heatmaps und Scrolltiefe, nur mit Statistik-Einwilligung. Keine personenbezogenen Daten. + none: Keine Daten im gewählten Zeitraum. + heatmap: Heatmap + table: + path: Seite + pageviews: Pageviews + clicks: Klicks + avg_scroll: Scroll Ø + max_scroll: Scroll max + show: + title: Seiten-Heatmap + back: "← Zurück zu Analytics" + summary: Übersicht + scroll_depth: Scrolltiefe + scroll_hint: "Durchschnitt %{avg}% · Maximum %{max}%" + heatmap_title: Klickkarte + no_clicks: Keine aggregierten Klicks für diese Seite im Zeitraum. + billing: index: title: Zu berechnende Zahlungen diff --git a/backend/config/locales/admin.en.yml b/backend/config/locales/admin.en.yml index 5dcc89a..ea9d3ba 100644 --- a/backend/config/locales/admin.en.yml +++ b/backend/config/locales/admin.en.yml @@ -10,6 +10,7 @@ en: billing: Billing youtube: YouTube sessions: Sessions + analytics: Analytics stream_nodes: Stream nodes password: Password logout: Log out @@ -350,6 +351,35 @@ en: lab: Lab cloud: Hetzner + analytics: + missing_path: Select a page. + filters: + from: From + to: To + device: Device + any: All + apply: Filter + reset: Reset + index: + title: Site analytics + lead: Aggregated first-party heatmaps and scroll depth, only with analytics consent. No personal data. + none: No data in the selected period. + heatmap: Heatmap + table: + path: Page + pageviews: Pageviews + clicks: Clicks + avg_scroll: Avg scroll + max_scroll: Max scroll + show: + title: Page heatmap + back: "← Back to Analytics" + summary: Summary + scroll_depth: Scroll depth + scroll_hint: "Average %{avg}% · max %{max}%" + heatmap_title: Click map + no_clicks: No aggregated clicks for this page in the period. + billing: index: title: Payments to invoice diff --git a/backend/config/locales/admin.es.yml b/backend/config/locales/admin.es.yml index 3935cc1..5016206 100644 --- a/backend/config/locales/admin.es.yml +++ b/backend/config/locales/admin.es.yml @@ -10,6 +10,7 @@ es: billing: Facturación youtube: YouTube sessions: Sesiones + analytics: Analytics stream_nodes: Nodos stream password: Contraseña logout: Salir @@ -350,6 +351,35 @@ es: lab: Lab cloud: Hetzner + analytics: + missing_path: Selecciona una página. + filters: + from: Desde + to: Hasta + device: Dispositivo + any: Todos + apply: Filtrar + reset: Restablecer + index: + title: Analytics del sitio + lead: Heatmaps y scroll agregados (first-party), solo con consentimiento estadístico. Sin datos personales. + none: No hay datos en el periodo seleccionado. + heatmap: Heatmap + table: + path: Página + pageviews: Pageviews + clicks: Clics + avg_scroll: Scroll medio + max_scroll: Scroll máx + show: + title: Heatmap de página + back: "← Volver a Analytics" + summary: Resumen + scroll_depth: Profundidad de scroll + scroll_hint: "Media %{avg}% · máximo %{max}%" + heatmap_title: Mapa de clics + no_clicks: No hay clics agregados para esta página en el periodo. + billing: index: title: Pagos por facturar diff --git a/backend/config/locales/admin.fr.yml b/backend/config/locales/admin.fr.yml index 05e1f52..4968a0b 100644 --- a/backend/config/locales/admin.fr.yml +++ b/backend/config/locales/admin.fr.yml @@ -10,6 +10,7 @@ fr: billing: Facturation youtube: YouTube sessions: Sessions + analytics: Analytics stream_nodes: Nœuds stream password: Mot de passe logout: Déconnexion @@ -350,6 +351,35 @@ fr: lab: Lab cloud: Hetzner + analytics: + missing_path: Sélectionnez une page. + filters: + from: Du + to: Au + device: Appareil + any: Tous + apply: Filtrer + reset: Réinitialiser + index: + title: Analytics du site + lead: Heatmaps et scroll agrégés (first-party), uniquement avec consentement statistiques. Aucune donnée personnelle. + none: Aucune donnée sur la période sélectionnée. + heatmap: Heatmap + table: + path: Page + pageviews: Pages vues + clicks: Clics + avg_scroll: Scroll moyen + max_scroll: Scroll max + show: + title: Heatmap de page + back: "← Retour aux Analytics" + summary: Résumé + scroll_depth: Profondeur de scroll + scroll_hint: "Moyenne %{avg}% · maximum %{max}%" + heatmap_title: Carte des clics + no_clicks: Aucun clic agrégé pour cette page sur la période. + billing: index: title: Paiements à facturer diff --git a/backend/config/locales/admin.it.yml b/backend/config/locales/admin.it.yml index ec3a980..486856d 100644 --- a/backend/config/locales/admin.it.yml +++ b/backend/config/locales/admin.it.yml @@ -10,6 +10,7 @@ it: billing: Fatturazione youtube: YouTube sessions: Sessioni + analytics: Analytics stream_nodes: Nodi stream password: Password logout: Esci @@ -371,6 +372,35 @@ it: lab: Lab cloud: Hetzner + analytics: + missing_path: Seleziona una pagina. + filters: + from: Da + to: A + device: Device + any: Tutti + apply: Filtra + reset: Azzera + index: + title: Analytics sito + lead: Heatmap e scroll aggregati (first-party), solo con consenso statistico. Nessun dato personale. + none: Nessun dato nel periodo selezionato. + heatmap: Heatmap + table: + path: Pagina + pageviews: Pageview + clicks: Click + avg_scroll: Scroll medio + max_scroll: Scroll max + show: + title: Heatmap pagina + back: "← Torna ad Analytics" + summary: Riepilogo + scroll_depth: Profondità di scroll + scroll_hint: "Media %{avg}% · massimo %{max}%" + heatmap_title: Mappa click + no_clicks: Nessun click aggregato per questa pagina nel periodo. + billing: index: title: Pagamenti da fatturare diff --git a/backend/config/locales/legal.de.yml b/backend/config/locales/legal.de.yml index b0e6032..113de3e 100644 --- a/backend/config/locales/legal.de.yml +++ b/backend/config/locales/legal.de.yml @@ -88,7 +88,7 @@ de: s10_title: 10. Sicherheit s10_body: "Wir setzen angemessene technische und organisatorische Maßnahmen ein (authentifizierter Zugang, verschlüsselte Passwörter, HTTPS-Kommunikation, Trennung der Umgebungen, eingeschränkter Personalzugang). Kein System ist unverwundbar: Wenn Sie einen unbefugten Zugriff vermuten, ändern Sie Ihr Passwort und kontaktieren Sie uns." s11_title: 11. Cookies und ähnliche Technologien - s11_p1_html: "Die Website verwendet notwendige technische Cookies (Login-Sitzung, Sicherheit, Speicherung der Cookie-Präferenzen) und, vorbehaltlich der Einwilligung über das Banner, Google Analytics für aggregierte Statistiken." + s11_p1_html: "Die Website verwendet notwendige technische Cookies (Login-Sitzung, Sicherheit, Speicherung der Cookie-Präferenzen) und, vorbehaltlich der Einwilligung über das Banner, Google Analytics und aggregierte First-Party-Statistiken (Klick-/Scroll-Heatmaps) zur Produktverbesserung." s11_p2_html: "Sie können Ihre Präferenzen jederzeit über den Link „Cookies verwalten“ in der Fußzeile ändern oder die %{cookie_policy_link} einsehen." s11_cookie_policy_link_text: Cookie-Richtlinie s12_title: 12. Änderungen @@ -169,7 +169,7 @@ de: s4_1_row2_duration: 12 Monate s4_1_row2_provider: Match Live TV (First-Party) s4_2_title: 4.2 Analytics (nur mit Ihrer Einwilligung) - s4_2_p1_html: "Wenn Sie „Alle Cookies“ akzeptieren oder die Statistiken im Banner aktivieren, laden wir Google Analytics 4, um zu verstehen, wie die Website genutzt wird (besuchte Seiten, aggregierte Herkunft, Gerät). Die Daten werden von Google Ireland Limited / Google LLC gemäß deren Richtlinien verarbeitet." + s4_2_p1_html: "Wenn Sie „Alle Cookies“ akzeptieren oder die Statistiken im Banner aktivieren, laden wir Google Analytics 4, um zu verstehen, wie die Website genutzt wird (besuchte Seiten, aggregierte Herkunft, Gerät). Die Daten werden von Google Ireland Limited / Google LLC gemäß deren Richtlinien verarbeitet. Mit derselben Einwilligung erfassen wir außerdem aggregierte First-Party-Statistiken (Klicks und Scrolltiefe, ohne Nutzeridentifikation) für interne Heatmaps zur Produktverbesserung und Plattform-Marketing. Wir tracken weder Admin-Bereich, Regie, Live-Player noch Replay-Seiten." s4_2_active_html: "Aktive Mess-ID auf der Website: %{id}" s4_2_inactive: Google Analytics wird nur konfiguriert, wenn der Verantwortliche die Mess-ID auf dem Server einrichtet. table2_col_name: Name @@ -185,6 +185,10 @@ de: s4_2_row3_purpose: Unterscheidet Nutzer (Statistiken) s4_2_row3_duration: 24 Stunden s4_2_row3_provider: Google + s4_2_row4_name_html: "Heatmap / Scroll (First-Party)" + s4_2_row4_purpose: Aggregierte Klick- und Scrolltiefe-Zählungen nach Seite und Gerätetyp (keine Nutzerkennungen) + s4_2_row4_duration: Aggregate bis zu 30 Tage + s4_2_row4_provider: Match Live TV (First-Party) s4_2_p2_html: "Sie können die Einwilligung über das Banner oder die Browsereinstellungen widerrufen. Google-Informationen: %{google_privacy_link}, %{google_optout_link}." s4_2_google_privacy_link_text: Google-Datenschutzerklärung s4_2_google_optout_link_text: Analytics-Deaktivierungs-Add-on diff --git a/backend/config/locales/legal.en.yml b/backend/config/locales/legal.en.yml index 6ad7b16..ca515d6 100644 --- a/backend/config/locales/legal.en.yml +++ b/backend/config/locales/legal.en.yml @@ -88,7 +88,7 @@ en: s10_title: 10. Security s10_body: "We adopt appropriate technical and organisational measures (authenticated access, encrypted passwords, HTTPS communications, environment segregation, restricted staff access). No system is invulnerable: if you suspect unauthorised access, change your password and contact us." s11_title: 11. Cookies and similar technologies - s11_p1_html: "The site uses necessary technical cookies (login session, security, storage of cookie preferences) and, subject to consent via the banner, Google Analytics for aggregated statistics." + s11_p1_html: "The site uses necessary technical cookies (login session, security, storage of cookie preferences) and, subject to consent via the banner, Google Analytics and first-party aggregated statistics (click/scroll heatmaps) to improve the product." s11_p2_html: "You can manage your preferences at any time via the “Manage cookies” link in the footer or consult the %{cookie_policy_link}." s11_cookie_policy_link_text: Cookie policy s12_title: 12. Changes @@ -169,7 +169,7 @@ en: s4_1_row2_duration: 12 months s4_1_row2_provider: Match Live TV (first-party) s4_2_title: 4.2 Analytics (only with your consent) - s4_2_p1_html: "If you accept “All cookies” or enable statistics in the banner, we load Google Analytics 4 to understand how the site is used (pages visited, aggregated origin, device). Data is processed by Google Ireland Limited / Google LLC in accordance with their policies." + s4_2_p1_html: "If you accept “All cookies” or enable statistics in the banner, we load Google Analytics 4 to understand how the site is used (pages visited, aggregated origin, device). Data is processed by Google Ireland Limited / Google LLC in accordance with their policies. With the same consent we also collect first-party aggregated statistics (clicks and scroll depth, without identifying the user) for internal heatmaps used to improve the product and platform marketing. We do not track the admin area, control room, live player or replay pages." s4_2_active_html: "Measurement ID active on the site: %{id}" s4_2_inactive: Google Analytics is only configured when the controller sets the measurement ID on the server. table2_col_name: Name @@ -185,6 +185,10 @@ en: s4_2_row3_purpose: Distinguishes users (statistics) s4_2_row3_duration: 24 hours s4_2_row3_provider: Google + s4_2_row4_name_html: "Heatmap / scroll (first-party)" + s4_2_row4_purpose: Aggregated click and scroll-depth counts by page and device type (no user identifiers) + s4_2_row4_duration: Aggregates kept up to 30 days + s4_2_row4_provider: Match Live TV (first-party) s4_2_p2_html: "You can withdraw consent from the banner or from your browser settings. Google information: %{google_privacy_link}, %{google_optout_link}." s4_2_google_privacy_link_text: Google Privacy Policy s4_2_google_optout_link_text: Analytics opt-out add-on diff --git a/backend/config/locales/legal.es.yml b/backend/config/locales/legal.es.yml index 31e4e99..88eb546 100644 --- a/backend/config/locales/legal.es.yml +++ b/backend/config/locales/legal.es.yml @@ -88,7 +88,7 @@ es: s10_title: 10. Seguridad s10_body: "Adoptamos medidas técnicas y organizativas adecuadas (acceso autenticado, contraseñas cifradas, comunicaciones HTTPS, segregación de entornos, acceso restringido del personal). Ningún sistema es invulnerable: si sospechas un acceso no autorizado, cambia la contraseña y contáctanos." s11_title: 11. Cookies y tecnologías similares - s11_p1_html: "El sitio utiliza cookies técnicas necesarias (sesión de acceso, seguridad, almacenamiento de preferencias de cookies) y, previo consentimiento a través del banner, Google Analytics para estadísticas agregadas." + s11_p1_html: "El sitio utiliza cookies técnicas necesarias (sesión de acceso, seguridad, almacenamiento de preferencias de cookies) y, previo consentimiento a través del banner, Google Analytics y estadísticas agregadas de primera parte (heatmaps de clics/scroll) para mejorar el producto." s11_p2_html: "Puedes gestionar tus preferencias en cualquier momento desde el enlace «Gestionar cookies» en el pie de página o consultar la %{cookie_policy_link}." s11_cookie_policy_link_text: política de cookies s12_title: 12. Modificaciones @@ -169,7 +169,7 @@ es: s4_1_row2_duration: 12 meses s4_1_row2_provider: Match Live TV (propia) s4_2_title: 4.2 Analíticas (solo con tu consentimiento) - s4_2_p1_html: "Si aceptas «Todas las cookies» o activas las estadísticas en el banner, cargamos Google Analytics 4 para entender cómo se usa el sitio (páginas visitadas, procedencia agregada, dispositivo). Los datos son tratados por Google Ireland Limited / Google LLC conforme a sus políticas." + s4_2_p1_html: "Si aceptas «Todas las cookies» o activas las estadísticas en el banner, cargamos Google Analytics 4 para entender cómo se usa el sitio (páginas visitadas, procedencia agregada, dispositivo). Los datos son tratados por Google Ireland Limited / Google LLC conforme a sus políticas. Con el mismo consentimiento también recopilamos estadísticas agregadas de primera parte (clics y profundidad de scroll, sin identificar al usuario) para heatmaps internas de mejora del producto y marketing de la plataforma. No rastreamos el área admin, la regia, el player en directo ni las páginas de replay." s4_2_active_html: "ID de medición activo en el sitio: %{id}" s4_2_inactive: Google Analytics solo se configura cuando el responsable establece el ID de medición en el servidor. table2_col_name: Nombre @@ -185,6 +185,10 @@ es: s4_2_row3_purpose: Distingue a los usuarios (estadísticas) s4_2_row3_duration: 24 horas s4_2_row3_provider: Google + s4_2_row4_name_html: "Heatmap / scroll (propia)" + s4_2_row4_purpose: Conteos agregados de clics y profundidad de scroll por página y tipo de dispositivo (sin identificadores de usuario) + s4_2_row4_duration: Agregados hasta 30 días + s4_2_row4_provider: Match Live TV (propia) s4_2_p2_html: "Puedes revocar el consentimiento desde el banner o desde la configuración del navegador. Información de Google: %{google_privacy_link}, %{google_optout_link}." s4_2_google_privacy_link_text: Política de privacidad de Google s4_2_google_optout_link_text: complemento de exclusión de Analytics diff --git a/backend/config/locales/legal.fr.yml b/backend/config/locales/legal.fr.yml index 25c6ecb..a4f0b10 100644 --- a/backend/config/locales/legal.fr.yml +++ b/backend/config/locales/legal.fr.yml @@ -88,7 +88,7 @@ fr: s10_title: 10. Sécurité s10_body: "Nous adoptons des mesures techniques et organisationnelles appropriées (accès authentifié, mots de passe chiffrés, communications HTTPS, séparation des environnements, accès du staff limité). Aucun système n'est invulnérable : si vous suspectez un accès non autorisé, changez votre mot de passe et contactez-nous." s11_title: 11. Cookies et technologies similaires - s11_p1_html: "Le site utilise des cookies techniques nécessaires (session de connexion, sécurité, mémorisation des préférences cookies) et, sous réserve de consentement via la bannière, Google Analytics pour des statistiques agrégées." + s11_p1_html: "Le site utilise des cookies techniques nécessaires (session de connexion, sécurité, mémorisation des préférences cookies) et, sous réserve de consentement via la bannière, Google Analytics et des statistiques agrégées first-party (heatmaps clics/scroll) pour améliorer le produit." s11_p2_html: "Vous pouvez gérer vos préférences à tout moment via le lien « Gérer les cookies » dans le pied de page ou consulter la %{cookie_policy_link}." s11_cookie_policy_link_text: politique de cookies s12_title: 12. Modifications @@ -169,7 +169,7 @@ fr: s4_1_row2_duration: 12 mois s4_1_row2_provider: Match Live TV (première partie) s4_2_title: 4.2 Analytics (uniquement avec votre consentement) - s4_2_p1_html: "Si vous acceptez « Tous les cookies » ou activez les statistiques dans la bannière, nous chargeons Google Analytics 4 pour comprendre comment le site est utilisé (pages visitées, provenance agrégée, appareil). Les données sont traitées par Google Ireland Limited / Google LLC conformément à leurs politiques." + s4_2_p1_html: "Si vous acceptez « Tous les cookies » ou activez les statistiques dans la bannière, nous chargeons Google Analytics 4 pour comprendre comment le site est utilisé (pages visitées, provenance agrégée, appareil). Les données sont traitées par Google Ireland Limited / Google LLC conformément à leurs politiques. Avec le même consentement, nous collectons aussi des statistiques agrégées first-party (clics et profondeur de scroll, sans identifier l'utilisateur) pour des heatmaps internes d'amélioration produit et marketing plateforme. Nous ne suivons pas l'admin, la régie, le player live ni les pages replay." s4_2_active_html: "ID de mesure actif sur le site : %{id}" s4_2_inactive: Google Analytics n'est configuré que lorsque le responsable définit l'ID de mesure sur le serveur. table2_col_name: Nom @@ -185,6 +185,10 @@ fr: s4_2_row3_purpose: Distingue les utilisateurs (statistiques) s4_2_row3_duration: 24 heures s4_2_row3_provider: Google + s4_2_row4_name_html: "Heatmap / scroll (première partie)" + s4_2_row4_purpose: Compteurs agrégés de clics et de profondeur de scroll par page et type d'appareil (sans identifiant utilisateur) + s4_2_row4_duration: Agrégats conservés jusqu'à 30 jours + s4_2_row4_provider: Match Live TV (première partie) s4_2_p2_html: "Vous pouvez retirer votre consentement depuis la bannière ou depuis les paramètres du navigateur. Informations Google : %{google_privacy_link}, %{google_optout_link}." s4_2_google_privacy_link_text: Politique de confidentialité Google s4_2_google_optout_link_text: module de désactivation Analytics diff --git a/backend/config/locales/legal.it.yml b/backend/config/locales/legal.it.yml index 5891516..401e143 100644 --- a/backend/config/locales/legal.it.yml +++ b/backend/config/locales/legal.it.yml @@ -88,7 +88,7 @@ it: s10_title: 10. Sicurezza s10_body: "Adottiamo misure tecniche e organizzative adeguate (accesso autenticato, password crittografate, comunicazioni HTTPS, segregazione ambienti, limitazione accessi staff). Nessun sistema è invulnerabile: se sospetti un accesso non autorizzato, cambia password e contattaci." s11_title: 11. Cookie e tecnologie simili - s11_p1_html: "Il sito utilizza cookie tecnici necessari (sessione di login, sicurezza, memorizzazione delle preferenze cookie) e, previo consenso tramite il banner, Google Analytics per statistiche aggregate." + s11_p1_html: "Il sito utilizza cookie tecnici necessari (sessione di login, sicurezza, memorizzazione delle preferenze cookie) e, previo consenso tramite il banner, Google Analytics e statistiche aggregate di prima parte (heatmap click/scroll) per migliorare il prodotto." s11_p2_html: "Puoi gestire le preferenze in qualsiasi momento dal link «Gestisci cookie» nel footer o consultare la %{cookie_policy_link}." s11_cookie_policy_link_text: Cookie policy s12_title: 12. Modifiche @@ -169,7 +169,7 @@ it: s4_1_row2_duration: 12 mesi s4_1_row2_provider: Match Live TV (prima parte) s4_2_title: 4.2 Analytics (solo con il tuo consenso) - s4_2_p1_html: "Se accetti «Tutti i cookie» o abiliti le statistiche nel banner, carichiamo Google Analytics 4 per capire come viene usato il sito (pagine visitate, provenienza aggregata, dispositivo). I dati sono trattati da Google Ireland Limited / Google LLC secondo le loro policy." + s4_2_p1_html: "Se accetti «Tutti i cookie» o abiliti le statistiche nel banner, carichiamo Google Analytics 4 per capire come viene usato il sito (pagine visitate, provenienza aggregata, dispositivo). I dati sono trattati da Google Ireland Limited / Google LLC secondo le loro policy. In parallelo, con lo stesso consenso, raccogliamo statistiche aggregate di prima parte (click e profondità di scroll, senza identificare l’utente) per heatmaps interne usate dal titolare a fini di miglioramento del prodotto e marketing della piattaforma. Non tracciamo area admin, regia, player live né replay." s4_2_active_html: "ID misurazione attivo sul sito: %{id}" s4_2_inactive: Google Analytics è configurato solo quando il titolare imposta l’ID misurazione sul server. table2_col_name: Nome @@ -185,6 +185,10 @@ it: s4_2_row3_purpose: Distingue gli utenti (statistiche) s4_2_row3_duration: 24 ore s4_2_row3_provider: Google + s4_2_row4_name_html: "Heatmap / scroll (prima parte)" + s4_2_row4_purpose: Conteggi aggregati di click e profondità di scroll per pagina e tipo di dispositivo (nessun identificativo utente) + s4_2_row4_duration: Aggregati fino a 30 giorni + s4_2_row4_provider: Match Live TV (prima parte) s4_2_p2_html: "Puoi revocare il consenso dal banner o dalle impostazioni del browser. Informazioni Google: %{google_privacy_link}, %{google_optout_link}." s4_2_google_privacy_link_text: Privacy Policy Google s4_2_google_optout_link_text: componente opt-out Analytics diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 0ef7709..c488a1a 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -76,6 +76,8 @@ Rails.application.routes.draw do post "internal/validate_publish", to: "webhooks/mediamtx#validate_publish" + post "analytics/events", to: "analytics/events#create" + namespace :admin do get "login", to: "auth#new", as: :login post "login", to: "auth#create" @@ -127,6 +129,8 @@ Rails.application.routes.draw do delete :clear_kill_switch end end + get "analytics", to: "analytics#index", as: :analytics + get "analytics/page", to: "analytics#show", as: :analytics_page get "youtube/platform", to: "youtube#platform", as: :youtube_platform end diff --git a/backend/db/migrate/20260820200000_create_analytics_tables.rb b/backend/db/migrate/20260820200000_create_analytics_tables.rb new file mode 100644 index 0000000..17221ec --- /dev/null +++ b/backend/db/migrate/20260820200000_create_analytics_tables.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class CreateAnalyticsTables < ActiveRecord::Migration[7.2] + def change + create_table :analytics_events, id: :uuid, default: -> { "gen_random_uuid()" } do |t| + t.string :page_path, null: false + t.string :device, null: false + t.string :event_type, null: false + t.decimal :x_pct, precision: 6, scale: 2 + t.decimal :y_pct, precision: 6, scale: 2 + t.decimal :scroll_pct, precision: 6, scale: 2 + t.datetime :occurred_at, null: false + t.timestamps + end + add_index :analytics_events, :occurred_at + add_index :analytics_events, %i[event_type occurred_at] + + create_table :analytics_page_cells, id: :uuid, default: -> { "gen_random_uuid()" } do |t| + t.date :day, null: false + t.string :page_path, null: false + t.string :device, null: false + t.integer :cell_x, null: false + t.integer :cell_y, null: false + t.integer :click_count, null: false, default: 0 + t.timestamps + end + add_index :analytics_page_cells, + %i[day page_path device cell_x cell_y], + unique: true, + name: "index_analytics_page_cells_unique" + + create_table :analytics_page_stats, id: :uuid, default: -> { "gen_random_uuid()" } do |t| + t.date :day, null: false + t.string :page_path, null: false + t.string :device, null: false + t.integer :pageview_count, null: false, default: 0 + t.integer :scroll_samples, null: false, default: 0 + t.integer :scroll_sum_pct, null: false, default: 0 + t.integer :max_scroll_pct, null: false, default: 0 + t.timestamps + end + add_index :analytics_page_stats, + %i[day page_path device], + unique: true, + name: "index_analytics_page_stats_unique" + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 7b959ca..d25ac60 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_08_20_191000) do +ActiveRecord::Schema[7.2].define(version: 2026_08_20_200000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -51,6 +51,45 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_20_191000) do t.index ["username"], name: "index_admin_accounts_on_username", unique: true end + create_table "analytics_events", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "page_path", null: false + t.string "device", null: false + t.string "event_type", null: false + t.decimal "x_pct", precision: 6, scale: 2 + t.decimal "y_pct", precision: 6, scale: 2 + t.decimal "scroll_pct", precision: 6, scale: 2 + t.datetime "occurred_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["event_type", "occurred_at"], name: "index_analytics_events_on_event_type_and_occurred_at" + t.index ["occurred_at"], name: "index_analytics_events_on_occurred_at" + end + + create_table "analytics_page_cells", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.date "day", null: false + t.string "page_path", null: false + t.string "device", null: false + t.integer "cell_x", null: false + t.integer "cell_y", null: false + t.integer "click_count", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["day", "page_path", "device", "cell_x", "cell_y"], name: "index_analytics_page_cells_unique", unique: true + end + + create_table "analytics_page_stats", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.date "day", null: false + t.string "page_path", null: false + t.string "device", null: false + t.integer "pageview_count", default: 0, null: false + t.integer "scroll_samples", default: 0, null: false + t.integer "scroll_sum_pct", default: 0, null: false + t.integer "max_scroll_pct", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["day", "page_path", "device"], name: "index_analytics_page_stats_unique", unique: true + end + create_table "app_announcements", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "kind", default: "info", null: false t.string "severity", default: "info", null: false diff --git a/backend/lib/tasks/analytics.rake b/backend/lib/tasks/analytics.rake new file mode 100644 index 0000000..03ab1bb --- /dev/null +++ b/backend/lib/tasks/analytics.rake @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +namespace :analytics do + desc "Aggregate raw analytics events into heatmaps/stats" + task aggregate: :environment do + Analytics::AggregateJob.perform_now(purge: false) + end + + desc "Purge analytics aggregates older than 30 days and leftover raw events" + task purge: :environment do + Analytics::Aggregate.new.purge_old! + end +end diff --git a/backend/public/admin.css b/backend/public/admin.css index 37ad354..cfb8672 100644 --- a/backend/public/admin.css +++ b/backend/public/admin.css @@ -935,3 +935,45 @@ body.admin-body { margin-bottom: 1rem; } +.admin-scroll-bar { + position: relative; + height: 14px; + border-radius: 999px; + background: #0d0d12; + border: 1px solid var(--card-border); + overflow: hidden; + margin-bottom: 0.5rem; +} + +.admin-scroll-bar__avg { + height: 100%; + background: linear-gradient(90deg, #1565c0, #43a047); + border-radius: 999px; +} + +.admin-scroll-bar__max { + position: absolute; + top: -2px; + bottom: -2px; + width: 2px; + background: #e53935; + transform: translateX(-1px); +} + +.admin-heatmap-wrap { + overflow: auto; + max-height: 70vh; + border: 1px solid var(--card-border); + border-radius: 10px; + background: #0d0d12; +} + +.admin-heatmap { + display: block; + width: 100%; + height: auto; + max-width: 800px; + margin: 0 auto; +} + + diff --git a/backend/public/cookie-consent.js b/backend/public/cookie-consent.js index 22c76c1..1843257 100644 --- a/backend/public/cookie-consent.js +++ b/backend/public/cookie-consent.js @@ -70,6 +70,9 @@ function applyConsent(consent) { if (consent && consent.analytics) { loadGoogleAnalytics(); + if (typeof window.mltvSiteAnalyticsStart === "function") { + window.mltvSiteAnalyticsStart(); + } } } diff --git a/backend/public/site-analytics.js b/backend/public/site-analytics.js new file mode 100644 index 0000000..be5357f --- /dev/null +++ b/backend/public/site-analytics.js @@ -0,0 +1,181 @@ +/*! Match Live TV first-party site analytics (click + scroll). Requires analytics cookie consent. */ +(function () { + "use strict"; + + var ENDPOINT = "/analytics/events"; + var STORAGE_KEY = "mltv_cookie_consent"; + var COOKIE_NAME = "mltv_cookie_consent"; + var FLUSH_MS = 8000; + var MAX_QUEUE = 40; + + var queue = []; + var maxScroll = 0; + var flushTimer = null; + var started = false; + + function excludedPath(pathname) { + var p = pathname || "/"; + if (p.indexOf("/admin") === 0) return true; + if (p.indexOf("/regia") === 0) return true; + if (p.indexOf("/analytics") === 0) return true; + if (/^\/live\/[^/]+/.test(p)) return true; + if (/^\/replay\/[^/]+/.test(p)) return true; + return false; + } + + function hasAnalyticsConsent() { + try { + var raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + var parsed = JSON.parse(raw); + if (parsed && parsed.analytics) return true; + } + } catch (e) { /* ignore */ } + var match = document.cookie.match( + new RegExp("(?:^|; )" + COOKIE_NAME.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "=([^;]*)") + ); + return !!(match && decodeURIComponent(match[1]) === "all"); + } + + function deviceBucket() { + var w = window.innerWidth || document.documentElement.clientWidth || 1024; + if (w < 768) return "mobile"; + if (w < 1024) return "tablet"; + return "desktop"; + } + + function scrollPct() { + var doc = document.documentElement; + var body = document.body; + var scrollTop = window.pageYOffset || doc.scrollTop || body.scrollTop || 0; + var height = Math.max( + body.scrollHeight || 0, + doc.scrollHeight || 0, + body.offsetHeight || 0, + doc.offsetHeight || 0, + doc.clientHeight || 0 + ); + var view = window.innerHeight || doc.clientHeight || 0; + var maxScrollable = Math.max(height - view, 1); + var pct = ((scrollTop + view) / height) * 100; + if (scrollTop <= 0 && view >= height) return 100; + return Math.max(0, Math.min(100, Math.round(pct))); + } + + function pushEvent(evt) { + if (!started) return; + queue.push(evt); + if (queue.length >= MAX_QUEUE) flush(true); + } + + function flush(useBeacon) { + if (!queue.length || !hasAnalyticsConsent()) { + queue = []; + return; + } + var batch = queue.splice(0, MAX_QUEUE); + var body = JSON.stringify({ events: batch }); + try { + if (useBeacon && navigator.sendBeacon) { + var blob = new Blob([body], { type: "application/json" }); + navigator.sendBeacon(ENDPOINT, blob); + return; + } + } catch (e) { /* fall through */ } + try { + fetch(ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: body, + keepalive: true, + credentials: "same-origin" + }).catch(function () { /* ignore */ }); + } catch (err) { /* ignore */ } + } + + function trackPageview() { + pushEvent({ + type: "pageview", + path: location.pathname, + device: deviceBucket(), + ts: Date.now() + }); + } + + function onClick(event) { + var target = event.target; + if (!(target instanceof Element)) return; + if (target.closest("input, textarea, select, [contenteditable='true']")) return; + + var doc = document.documentElement; + var body = document.body; + var width = Math.max(body.scrollWidth || 0, doc.scrollWidth || 0, window.innerWidth || 1); + var height = Math.max(body.scrollHeight || 0, doc.scrollHeight || 0, window.innerHeight || 1); + var x = event.pageX; + var y = event.pageY; + if (typeof x !== "number" || typeof y !== "number") return; + + pushEvent({ + type: "click", + path: location.pathname, + device: deviceBucket(), + x: Math.max(0, Math.min(100, (x / width) * 100)), + y: Math.max(0, Math.min(100, (y / height) * 100)), + ts: Date.now() + }); + } + + function onScroll() { + var pct = scrollPct(); + if (pct > maxScroll) maxScroll = pct; + } + + function flushScroll() { + if (maxScroll <= 0) return; + pushEvent({ + type: "scroll", + path: location.pathname, + device: deviceBucket(), + scroll: maxScroll, + ts: Date.now() + }); + } + + function scheduleFlush() { + if (flushTimer) return; + flushTimer = setInterval(function () { + flushScroll(); + flush(false); + }, FLUSH_MS); + } + + function start() { + if (started) return; + if (excludedPath(location.pathname)) return; + if (!hasAnalyticsConsent()) return; + started = true; + maxScroll = scrollPct(); + trackPageview(); + document.addEventListener("click", onClick, true); + window.addEventListener("scroll", onScroll, { passive: true }); + document.addEventListener("visibilitychange", function () { + if (document.visibilityState === "hidden") { + flushScroll(); + flush(true); + } + }); + window.addEventListener("pagehide", function () { + flushScroll(); + flush(true); + }); + scheduleFlush(); + } + + window.mltvSiteAnalyticsStart = start; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", start); + } else { + start(); + } +})(); diff --git a/backend/spec/requests/admin/analytics_spec.rb b/backend/spec/requests/admin/analytics_spec.rb new file mode 100644 index 0000000..fb22d6b --- /dev/null +++ b/backend/spec/requests/admin/analytics_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Admin analytics", type: :request do + let!(:admin) { AdminAccount.create!(username: "ops-analytics", password: "Password123") } + + before do + post admin_login_path, params: { username: admin.username, password: "Password123" } + AnalyticsPageStat.create!( + day: Time.zone.today, + page_path: "/prezzi", + device: "desktop", + pageview_count: 12, + scroll_samples: 10, + scroll_sum_pct: 600, + max_scroll_pct: 90 + ) + AnalyticsPageCell.create!( + day: Time.zone.today, + page_path: "/prezzi", + device: "desktop", + cell_x: 10, + cell_y: 15, + click_count: 4 + ) + end + + it "mostra l'elenco pagine" do + get admin_analytics_path + expect(response).to have_http_status(:ok) + expect(response.body).to include("/prezzi") + expect(response.body).to include("12") + end + + it "mostra la heatmap di una pagina" do + get admin_analytics_page_path, params: { page_path: "/prezzi" } + expect(response).to have_http_status(:ok) + expect(response.body).to include("admin-heatmap") + expect(response.body).to include("/prezzi") + end +end diff --git a/backend/spec/requests/analytics/events_spec.rb b/backend/spec/requests/analytics/events_spec.rb new file mode 100644 index 0000000..df679d6 --- /dev/null +++ b/backend/spec/requests/analytics/events_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Analytics ingest", type: :request do + it "accetta eventi validi, normalizza il path e aggrega" do + post "/analytics/events", + params: { + events: [ + { + type: "pageview", + path: "/clubs/461d369a-104c-4d3c-8860-1518b4e95d35", + device: "desktop", + ts: Time.current.to_i * 1000 + }, + { + type: "click", + path: "/clubs/461d369a-104c-4d3c-8860-1518b4e95d35", + device: "desktop", + x: 25, + y: 50, + ts: Time.current.to_i * 1000 + }, + { + type: "scroll", + path: "/clubs/461d369a-104c-4d3c-8860-1518b4e95d35", + device: "desktop", + scroll: 80, + ts: Time.current.to_i * 1000 + } + ] + }, + as: :json + + expect(response).to have_http_status(:accepted) + body = JSON.parse(response.body) + expect(body["accepted"]).to eq(3) + + expect(AnalyticsEvent.count).to eq(0) + expect(AnalyticsPageStat.find_by(page_path: "/clubs/:id").pageview_count).to eq(1) + expect(AnalyticsPageStat.find_by(page_path: "/clubs/:id").max_scroll_pct).to eq(80) + expect(AnalyticsPageCell.where(page_path: "/clubs/:id").sum(:click_count)).to eq(1) + end + + it "rifiuta path esclusi" do + post "/analytics/events", + params: { + events: [ + { type: "pageview", path: "/admin/ops", device: "desktop", ts: Time.current.to_i * 1000 }, + { type: "pageview", path: "/live/461d369a-104c-4d3c-8860-1518b4e95d35", device: "mobile", ts: Time.current.to_i * 1000 } + ] + }, + as: :json + + expect(response).to have_http_status(:accepted) + expect(JSON.parse(response.body)["accepted"]).to eq(0) + expect(AnalyticsEvent.count).to eq(0) + end +end diff --git a/backend/spec/services/analytics/path_normalizer_spec.rb b/backend/spec/services/analytics/path_normalizer_spec.rb new file mode 100644 index 0000000..51090c7 --- /dev/null +++ b/backend/spec/services/analytics/path_normalizer_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Analytics::PathNormalizer do + it "normalizza UUID e token" do + expect(described_class.normalize("/clubs/461d369a-104c-4d3c-8860-1518b4e95d35/billing")) + .to eq("/clubs/:id/billing") + end + + it "esclude admin, regia, live player e replay" do + expect(described_class.excluded?("/admin/sessions")).to be(true) + expect(described_class.excluded?("/regia/abc")).to be(true) + expect(described_class.excluded?("/live/461d369a-104c-4d3c-8860-1518b4e95d35")).to be(true) + expect(described_class.excluded?("/replay/461d369a-104c-4d3c-8860-1518b4e95d35")).to be(true) + expect(described_class.excluded?("/live")).to be(false) + expect(described_class.excluded?("/prezzi")).to be(false) + end +end diff --git a/infra/scripts/install_production_cron.sh b/infra/scripts/install_production_cron.sh index 02b269f..08cbc31 100755 --- a/infra/scripts/install_production_cron.sh +++ b/infra/scripts/install_production_cron.sh @@ -29,6 +29,8 @@ CRON_BLOCK="${MARKER} 30 * * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:cleanup_local >> ${LOG_FILE} 2>&1 0 3 * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:purge_expired >> ${LOG_FILE} 2>&1 0 8 * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:expiry_warnings >> ${LOG_FILE} 2>&1 +15 * * * * mkdir -p ${LOG_DIR} && ${RUNNER} analytics:aggregate >> ${LOG_DIR}/cron-analytics.log 2>&1 +20 4 * * * mkdir -p ${LOG_DIR} && ${RUNNER} analytics:purge >> ${LOG_DIR}/cron-analytics.log 2>&1 */10 * * * * mkdir -p ${LOG_DIR} && /bin/bash ${SCAN_LOGS} >> ${OPS_LOG_FILE} 2>&1 "