Aggiunge heatmaps first-party (click/scroll) con pagina admin Analytics.

Raccoglie eventi aggregati dietro consenso cookie, senza PII né tracking su admin/regia/live/replay; GA4 resta invariato.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-20 22:50:15 +02:00
co-authored by Cursor
parent cc96c0396a
commit 8a07c5d569
36 changed files with 1247 additions and 14 deletions
@@ -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
+118
View File
@@ -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
@@ -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