Usa screenshot di pagina come sfondo heatmap al posto dell’iframe.
Cattura periodica client-side (con consenso), storage ActiveStorage e overlay allineato all’immagine. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user