diff --git a/backend/app/controllers/admin/analytics_controller.rb b/backend/app/controllers/admin/analytics_controller.rb index 23db51e..34f7a74 100644 --- a/backend/app/controllers/admin/analytics_controller.rb +++ b/backend/app/controllers/admin/analytics_controller.rb @@ -75,7 +75,7 @@ module Admin @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 - @preview_url = preview_url_for(@page_path) + @snapshot = find_snapshot(@page_path, @filters[:device]) end private @@ -88,11 +88,14 @@ module Admin nil end - def preview_url_for(path) - return nil if path.blank? - return nil if path.include?(":") + def find_snapshot(page_path, device) + scope = AnalyticsPageSnapshot.where(page_path: page_path) + if device.present? && AnalyticsEvent::DEVICES.include?(device) + snap = scope.find_by(device: device) + return snap if snap&.image&.attached? + end - "#{MatchLiveTv.app_public_url.chomp('/')}#{path}" + scope.order(captured_at: :desc).detect { |s| s.image.attached? } end end end diff --git a/backend/app/controllers/analytics/snapshots_controller.rb b/backend/app/controllers/analytics/snapshots_controller.rb new file mode 100644 index 0000000..575f1e9 --- /dev/null +++ b/backend/app/controllers/analytics/snapshots_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Analytics + class SnapshotsController < ActionController::API + def create + result = Analytics::SnapshotIngest.new( + path: params[:path] || params[:page_path], + device: params[:device], + width: params[:width], + height: params[:height], + image: params[:image], + remote_ip: request.remote_ip + ).call + + return head :too_many_requests if result.rate_limited + return render json: { ok: false, error: result.error }, status: :unprocessable_content unless result.ok + + render json: { ok: true, skipped: result.skipped }, status: :accepted + end + end +end diff --git a/backend/app/models/analytics_page_snapshot.rb b/backend/app/models/analytics_page_snapshot.rb new file mode 100644 index 0000000..54d6e18 --- /dev/null +++ b/backend/app/models/analytics_page_snapshot.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AnalyticsPageSnapshot < ApplicationRecord + self.table_name = "analytics_page_snapshots" + + has_one_attached :image + + validates :page_path, presence: true + validates :device, inclusion: { in: AnalyticsEvent::DEVICES } + validates :width, :height, numericality: { greater_than: 0 } + validates :captured_at, presence: true +end diff --git a/backend/app/services/analytics/aggregate.rb b/backend/app/services/analytics/aggregate.rb index 86e9a30..c16a0ca 100644 --- a/backend/app/services/analytics/aggregate.rb +++ b/backend/app/services/analytics/aggregate.rb @@ -21,6 +21,10 @@ module Analytics 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 + AnalyticsPageSnapshot.where("captured_at < ?", retention.ago).find_each do |snap| + snap.image.purge if snap.image.attached? + snap.destroy! + end end private diff --git a/backend/app/services/analytics/snapshot_ingest.rb b/backend/app/services/analytics/snapshot_ingest.rb new file mode 100644 index 0000000..5078607 --- /dev/null +++ b/backend/app/services/analytics/snapshot_ingest.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module Analytics + class SnapshotIngest + MAX_BYTES = 1_800_000 + MIN_REFRESH = 12.hours + RATE_LIMIT_PER_HOUR = 20 + + Result = Struct.new(:ok, :skipped, :rate_limited, :error, keyword_init: true) + + def initialize(path:, device:, width:, height:, image:, remote_ip:) + @path = path + @device = device.to_s + @width = width + @height = height + @image = image + @remote_ip = remote_ip.to_s.presence || "unknown" + end + + def call + return Result.new(ok: false, skipped: false, rate_limited: true) if rate_limited? + + page_path = PathNormalizer.normalize(@path) + return Result.new(ok: false, skipped: true, error: "path") if PathNormalizer.excluded?(page_path) + return Result.new(ok: false, skipped: true, error: "device") unless AnalyticsEvent::DEVICES.include?(@device) + return Result.new(ok: false, skipped: true, error: "image") unless valid_image? + + width = Integer(@width) + height = Integer(@height) + return Result.new(ok: false, skipped: true, error: "size") if width < 200 || height < 200 || width > 6000 || height > 20000 + + snap = AnalyticsPageSnapshot.find_or_initialize_by(page_path: page_path, device: @device) + if snap.persisted? && snap.captured_at.present? && snap.captured_at > MIN_REFRESH.ago && snap.image.attached? + return Result.new(ok: true, skipped: true) + end + + snap.width = width + snap.height = height + snap.captured_at = Time.current + snap.save! + snap.image.purge if snap.image.attached? + snap.image.attach( + io: image_io, + filename: "analytics-#{Digest::SHA1.hexdigest(page_path)[0, 12]}-#{@device}.jpg", + content_type: "image/jpeg" + ) + + Result.new(ok: true, skipped: false) + rescue ArgumentError, TypeError, ActiveRecord::RecordInvalid => e + Result.new(ok: false, skipped: true, error: e.class.name) + end + + private + + def rate_limited? + key = "analytics:snapshot:#{@remote_ip}" + redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")) + count = redis.incr(key) + redis.expire(key, 3600) if count == 1 + count > RATE_LIMIT_PER_HOUR + rescue StandardError => e + Rails.logger.warn("[analytics] snapshot rate limit unavailable: #{e.class}") + false + end + + def valid_image? + return false if @image.blank? + return false unless @image.respond_to?(:tempfile) || @image.respond_to?(:read) + return false if @image.respond_to?(:size) && @image.size.to_i > MAX_BYTES + + content_type = @image.content_type.to_s if @image.respond_to?(:content_type) + content_type.blank? || content_type.start_with?("image/") + end + + def image_io + if @image.respond_to?(:tempfile) + @image.tempfile.rewind + @image.tempfile + else + StringIO.new(@image.read) + end + end + end +end diff --git a/backend/app/views/admin/analytics/show.html.erb b/backend/app/views/admin/analytics/show.html.erb index 6196d7c..4c83ff9 100644 --- a/backend/app/views/admin/analytics/show.html.erb +++ b/backend/app/views/admin/analytics/show.html.erb @@ -95,27 +95,43 @@ <% if @cells.any? %>
+ <%= t("admin.analytics.show.snapshot_meta", + device: @snapshot.device, + at: l(@snapshot.captured_at, format: :short)) %> +
<% else %> -<%= t("admin.analytics.show.preview_unavailable") %>
+<%= t("admin.analytics.show.snapshot_missing") %>
+ <% end %> -<%= t("admin.analytics.show.no_points") %>
<% end %> diff --git a/backend/app/views/layouts/admin.html.erb b/backend/app/views/layouts/admin.html.erb index 0233dfb..de565c8 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 %> diff --git a/backend/app/views/layouts/marketing.html.erb b/backend/app/views/layouts/marketing.html.erb index 018e0d4..be4cea3 100644 --- a/backend/app/views/layouts/marketing.html.erb +++ b/backend/app/views/layouts/marketing.html.erb @@ -26,7 +26,7 @@ - +