ahoy_analytics 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +163 -0
- data/Rakefile +6 -0
- data/app/assets/ahoy_analytics/build/assets/Combination-BpSXUjp9.js +41 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-5KyfCxh6.css +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-dashboard-uOXx8zYZ.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-layout-ClAft5OU.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-tracker-B3f8P98z.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-ui-DMSkNqd6.js +90 -0
- data/app/assets/ahoy_analytics/build/assets/behaviors-panel-ChNGYbdH.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/button-JVCrlR4s.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/cable-DO-7y1-E.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/createLucideIcon-BGzacY2v.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/date-range-dialog-DWDp3cLG.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/details-button-NqKfSGEG.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/devices-panel-cXvlmNBY.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/dialog-path-BBPNlB4Z.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/dropdown-menu-Adj3O5fh.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/filter-dialog-BN-rf4lp.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/index-B1K1NTKT.js +3 -0
- data/app/assets/ahoy_analytics/build/assets/index-BcHeb-Rh.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/index-DzpzLoG4.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/index-vX97OY1J.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/input-e4v_v0kE.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/jsx-runtime-u17CrQMm.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/last-load-context-De5uA95L.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/list-table-ChHEzzF9.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/live-Cp2MHECh.js +2 -0
- data/app/assets/ahoy_analytics/build/assets/locations-panel-BaISRmaQ.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/mercator-BnxX5RzL.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/pages-panel-Bh25L8mP.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/panel-tabs-B2kvGFJx.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/query-context-B-PgE00D.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/remote-details-dialog-DDTcKaM5.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/show-CCRicksg.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/simple-tabs-D6G6Bs0k.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/site-context-BNteYRlR.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/sources-panel-DyB21hxD.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/top-bar-FSiLBjq6.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/top-stats-context-DU15P9jS.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/use-debounce-VBpXQRL8.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/user-context-DbYteluY.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/visitor-globe-BWLDihid.js +4789 -0
- data/app/assets/ahoy_analytics/build/assets/visitor-graph-uKXjLvcu.js +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/brave.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/chrome.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/chromium.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/duckduckgo.svg +2151 -0
- data/app/assets/ahoy_analytics/images/icon/browser/edge.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/fallback.svg +5 -0
- data/app/assets/ahoy_analytics/images/icon/browser/firefox.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/opera.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/safari.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/browser/samsung-internet.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/uc.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/vivaldi.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/yandex.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/android.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/chrome_os.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/fallback.svg +5 -0
- data/app/assets/ahoy_analytics/images/icon/os/fedora.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/freebsd.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/gnu_linux.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/ios.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/ipad_os.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/mac.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/ubuntu.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/windows.png +0 -0
- data/app/assets/stylesheets/ahoy_analytics/application.css +15 -0
- data/app/channels/ahoy_analytics/analytics_channel.rb +9 -0
- data/app/controllers/ahoy_analytics/analytics_controller.rb +9 -0
- data/app/controllers/ahoy_analytics/application_controller.rb +8 -0
- data/app/controllers/ahoy_analytics/assets_controller.rb +46 -0
- data/app/controllers/ahoy_analytics/base_controller.rb +285 -0
- data/app/controllers/ahoy_analytics/behaviors_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/devices_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/export_controller.rb +12 -0
- data/app/controllers/ahoy_analytics/live_controller.rb +9 -0
- data/app/controllers/ahoy_analytics/locations_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/main_graph_controller.rb +10 -0
- data/app/controllers/ahoy_analytics/pages_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/referrers_controller.rb +34 -0
- data/app/controllers/ahoy_analytics/search_terms_controller.rb +47 -0
- data/app/controllers/ahoy_analytics/sources_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/top_stats_controller.rb +13 -0
- data/app/controllers/concerns/ahoy_analytics/set_current_request.rb +17 -0
- data/app/frontend/components/analytics/hex-highlights.tsx +165 -0
- data/app/frontend/components/analytics/hex-land-layer.tsx +61 -0
- data/app/frontend/components/analytics/metric-card.tsx +138 -0
- data/app/frontend/components/analytics/sessions-by-location.tsx +62 -0
- data/app/frontend/components/analytics/visitor-globe.tsx +424 -0
- data/app/frontend/components/ui/accordion.tsx +64 -0
- data/app/frontend/components/ui/alert.tsx +66 -0
- data/app/frontend/components/ui/avatar.tsx +53 -0
- data/app/frontend/components/ui/badge.tsx +46 -0
- data/app/frontend/components/ui/button.tsx +62 -0
- data/app/frontend/components/ui/calendar.tsx +212 -0
- data/app/frontend/components/ui/card.tsx +91 -0
- data/app/frontend/components/ui/checkbox.tsx +32 -0
- data/app/frontend/components/ui/dropdown-menu.tsx +255 -0
- data/app/frontend/components/ui/input.tsx +21 -0
- data/app/frontend/components/ui/label.tsx +22 -0
- data/app/frontend/components/ui/popover.tsx +46 -0
- data/app/frontend/components/ui/select.tsx +183 -0
- data/app/frontend/components/ui/separator.tsx +26 -0
- data/app/frontend/components/ui/sheet.tsx +139 -0
- data/app/frontend/components/ui/sidebar.tsx +726 -0
- data/app/frontend/components/ui/skeleton.tsx +13 -0
- data/app/frontend/components/ui/sonner.tsx +33 -0
- data/app/frontend/components/ui/tooltip.tsx +59 -0
- data/app/frontend/data/countries-110m.json +1 -0
- data/app/frontend/data/globe-data.json +1 -0
- data/app/frontend/entrypoints/analytics-tracker.ts +680 -0
- data/app/frontend/entrypoints/analytics-ui.tsx +26 -0
- data/app/frontend/entrypoints/analytics.css +77 -0
- data/app/frontend/layouts/analytics-layout.tsx +28 -0
- data/app/frontend/lib/cable.ts +13 -0
- data/app/frontend/lib/geocode.ts +65 -0
- data/app/frontend/lib/utils.ts +6 -0
- data/app/frontend/pages/admin/analytics/api.ts +221 -0
- data/app/frontend/pages/admin/analytics/hooks/use-debounce.ts +36 -0
- data/app/frontend/pages/admin/analytics/last-load-context.tsx +29 -0
- data/app/frontend/pages/admin/analytics/lib/base-path.ts +28 -0
- data/app/frontend/pages/admin/analytics/lib/dialog-path.ts +242 -0
- data/app/frontend/pages/admin/analytics/lib/number-formatter.ts +100 -0
- data/app/frontend/pages/admin/analytics/live.tsx +608 -0
- data/app/frontend/pages/admin/analytics/query-context.tsx +61 -0
- data/app/frontend/pages/admin/analytics/show.tsx +40 -0
- data/app/frontend/pages/admin/analytics/site-context.tsx +22 -0
- data/app/frontend/pages/admin/analytics/top-stats-context.tsx +37 -0
- data/app/frontend/pages/admin/analytics/types.ts +161 -0
- data/app/frontend/pages/admin/analytics/ui/analytics-dashboard.tsx +60 -0
- data/app/frontend/pages/admin/analytics/ui/behaviors-panel.tsx +456 -0
- data/app/frontend/pages/admin/analytics/ui/date-range-dialog.tsx +173 -0
- data/app/frontend/pages/admin/analytics/ui/details-button.tsx +33 -0
- data/app/frontend/pages/admin/analytics/ui/devices-panel.tsx +474 -0
- data/app/frontend/pages/admin/analytics/ui/filter-dialog.tsx +558 -0
- data/app/frontend/pages/admin/analytics/ui/list-table.tsx +346 -0
- data/app/frontend/pages/admin/analytics/ui/locations-panel.tsx +566 -0
- data/app/frontend/pages/admin/analytics/ui/pages-panel.tsx +207 -0
- data/app/frontend/pages/admin/analytics/ui/panel-tabs.tsx +65 -0
- data/app/frontend/pages/admin/analytics/ui/remote-details-dialog.tsx +356 -0
- data/app/frontend/pages/admin/analytics/ui/simple-tabs.tsx +54 -0
- data/app/frontend/pages/admin/analytics/ui/sources-panel.tsx +771 -0
- data/app/frontend/pages/admin/analytics/ui/top-bar.tsx +793 -0
- data/app/frontend/pages/admin/analytics/ui/visitor-graph.tsx +891 -0
- data/app/frontend/pages/admin/analytics/user-context.tsx +22 -0
- data/app/frontend/styles/shared.css +156 -0
- data/app/helpers/ahoy_analytics/application_helper.rb +96 -0
- data/app/jobs/ahoy_analytics/application_job.rb +4 -0
- data/app/jobs/ahoy_analytics/update_job.rb +12 -0
- data/app/mailers/ahoy_analytics/application_mailer.rb +6 -0
- data/app/models/ahoy/event/filters.rb +7 -0
- data/app/models/ahoy/event.rb +9 -0
- data/app/models/ahoy/visit/cache_key.rb +15 -0
- data/app/models/ahoy/visit/constants.rb +11 -0
- data/app/models/ahoy/visit/devices.rb +144 -0
- data/app/models/ahoy/visit/export.rb +24 -0
- data/app/models/ahoy/visit/filters.rb +286 -0
- data/app/models/ahoy/visit/imports.rb +36 -0
- data/app/models/ahoy/visit/locations.rb +276 -0
- data/app/models/ahoy/visit/metrics.rb +473 -0
- data/app/models/ahoy/visit/ordering.rb +110 -0
- data/app/models/ahoy/visit/pages.rb +533 -0
- data/app/models/ahoy/visit/pagination.rb +17 -0
- data/app/models/ahoy/visit/ranges.rb +227 -0
- data/app/models/ahoy/visit/series.rb +177 -0
- data/app/models/ahoy/visit/sources.rb +418 -0
- data/app/models/ahoy/visit/url_labels.rb +32 -0
- data/app/models/ahoy/visit.rb +143 -0
- data/app/models/ahoy_analytics/application_record.rb +5 -0
- data/app/models/ahoy_analytics/current.rb +8 -0
- data/app/models/ahoy_analytics/funnel.rb +16 -0
- data/app/models/ahoy_analytics/imported_entry_page.rb +5 -0
- data/app/models/ahoy_analytics/imported_exit_page.rb +5 -0
- data/app/models/ahoy_analytics/imported_page.rb +5 -0
- data/app/models/ahoy_analytics/live_stats.rb +152 -0
- data/app/models/ahoy_analytics/setting.rb +19 -0
- data/app/models/analytics/source_catalog.rb +48 -0
- data/app/views/layouts/ahoy_analytics/application.html.erb +15 -0
- data/config/routes.rb +21 -0
- data/config/vite.json +22 -0
- data/db/migrate/20251006104056_create_ahoy_visits_and_events.rb +62 -0
- data/db/migrate/20251006105012_add_analytics_fields_to_ahoy_visits.rb +11 -0
- data/db/migrate/20251012090000_create_analytics_funnels_and_imports.rb +52 -0
- data/db/migrate/20251013021500_add_analytics_indexes.rb +14 -0
- data/lib/ahoy_analytics/ahoy_store.rb +429 -0
- data/lib/ahoy_analytics/asset_manifest.rb +56 -0
- data/lib/ahoy_analytics/device_bucket.rb +39 -0
- data/lib/ahoy_analytics/engine.rb +55 -0
- data/lib/ahoy_analytics/maxmind_geo.rb +77 -0
- data/lib/ahoy_analytics/version.rb +3 -0
- data/lib/ahoy_analytics.rb +52 -0
- data/lib/generators/ahoy_analytics/install/install_generator.rb +111 -0
- data/lib/generators/ahoy_analytics/install/templates/initializer.rb +28 -0
- data/lib/tasks/ahoy_analytics_tasks.rake +4 -0
- metadata +352 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Ahoy::Store < Ahoy::DatabaseStore
|
|
4
|
+
# Allow Ahoy::DatabaseStore to persist custom columns we added to ahoy_visits
|
|
5
|
+
def visit_columns
|
|
6
|
+
super + %i[hostname screen_size browser_version os_version]
|
|
7
|
+
end
|
|
8
|
+
# Enrich visits with geo and site info, then broadcast realtime update
|
|
9
|
+
def track_visit(data)
|
|
10
|
+
data = data.with_indifferent_access
|
|
11
|
+
attrs = data.dup
|
|
12
|
+
|
|
13
|
+
# Hostname from request or landing_page URL
|
|
14
|
+
req = AhoyAnalytics::Current.request
|
|
15
|
+
if req
|
|
16
|
+
attrs[:hostname] ||= req.host
|
|
17
|
+
host = attrs[:hostname].presence || req.host
|
|
18
|
+
|
|
19
|
+
# Prefer the real landing page (first page URL), never the Ahoy API endpoints.
|
|
20
|
+
# If landing_page is blank OR looks like an internal endpoint (e.g. /ahoy/visits),
|
|
21
|
+
# replace with the Referer header which points to the actual page URL.
|
|
22
|
+
begin
|
|
23
|
+
lp = attrs[:landing_page].to_s
|
|
24
|
+
if lp.blank? || internal_path?(lp)
|
|
25
|
+
attrs[:landing_page] = req.referer if req.referer.present?
|
|
26
|
+
end
|
|
27
|
+
rescue StandardError
|
|
28
|
+
# never block tracking
|
|
29
|
+
end
|
|
30
|
+
# Best-effort referrer domain if not set
|
|
31
|
+
if req.referer.present?
|
|
32
|
+
begin
|
|
33
|
+
ref_host = URI.parse(req.referer).host
|
|
34
|
+
# Replicate Plausible: ignore local and internal referrers entirely
|
|
35
|
+
if local_host?(ref_host) || same_site_host?(ref_host, host)
|
|
36
|
+
attrs[:referrer] = nil if attrs[:referrer].to_s == req.referer
|
|
37
|
+
attrs[:referring_domain] = nil
|
|
38
|
+
else
|
|
39
|
+
attrs[:referring_domain] ||= ref_host
|
|
40
|
+
end
|
|
41
|
+
rescue URI::InvalidURIError
|
|
42
|
+
# ignore
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Build candidate IP list from request headers if available; otherwise, try the data payload
|
|
47
|
+
candidates = []
|
|
48
|
+
xff = nil
|
|
49
|
+
%w[HTTP_CF_CONNECTING_IP HTTP_TRUE_CLIENT_IP HTTP_X_REAL_IP].each do |h|
|
|
50
|
+
v = req.get_header(h)
|
|
51
|
+
candidates << v if v.present?
|
|
52
|
+
end
|
|
53
|
+
xff = req.get_header("HTTP_X_FORWARDED_FOR").to_s
|
|
54
|
+
candidates.concat(xff.split(",").map(&:strip)) if xff.present?
|
|
55
|
+
candidates << req.get_header("REMOTE_ADDR")
|
|
56
|
+
candidates << (req.ip rescue nil)
|
|
57
|
+
candidates << (req.remote_ip rescue nil)
|
|
58
|
+
# API path may not attach a controller/request to the store; use ip provided by Ahoy data
|
|
59
|
+
ip_from_data = data[:ip].to_s.presence
|
|
60
|
+
candidates << ip_from_data if ip_from_data
|
|
61
|
+
candidates = candidates.compact.uniq
|
|
62
|
+
|
|
63
|
+
client_ip = nil
|
|
64
|
+
record = nil
|
|
65
|
+
|
|
66
|
+
if defined?(AhoyAnalytics::MaxmindGeo)
|
|
67
|
+
client_ip = candidates.find { |ip| AhoyAnalytics::MaxmindGeo.valid_ip?(ip) }
|
|
68
|
+
# Secondary heuristic: some stacks append client IP at the RIGHT of XFF
|
|
69
|
+
if client_ip.nil? && xff.present?
|
|
70
|
+
xff.split(",").map(&:strip).reverse_each do |ip|
|
|
71
|
+
if AhoyAnalytics::MaxmindGeo.valid_ip?(ip)
|
|
72
|
+
client_ip = ip
|
|
73
|
+
break
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
record = client_ip ? AhoyAnalytics::MaxmindGeo.lookup(client_ip) : nil
|
|
79
|
+
|
|
80
|
+
if record
|
|
81
|
+
attrs[:country] ||= record[:country_iso]
|
|
82
|
+
attrs[:region] ||= record[:subdivisions]&.first
|
|
83
|
+
attrs[:city] ||= record[:city]
|
|
84
|
+
attrs[:latitude] ||= record[:latitude]
|
|
85
|
+
attrs[:longitude] ||= record[:longitude]
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Cloudflare country fallback is useful regardless of MaxMind availability
|
|
90
|
+
begin
|
|
91
|
+
cc = req.get_header("HTTP_CF_IPCOUNTRY").to_s.upcase.presence
|
|
92
|
+
attrs[:country] ||= cc if cc
|
|
93
|
+
rescue StandardError
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
else
|
|
97
|
+
# No request object available (common for API-created visits)
|
|
98
|
+
# Fallback: extract hostname from landing_page URL
|
|
99
|
+
begin
|
|
100
|
+
lp = (attrs[:landing_page] || data[:landing_page]).to_s
|
|
101
|
+
if lp.present?
|
|
102
|
+
attrs[:hostname] ||= URI.parse(lp).host
|
|
103
|
+
end
|
|
104
|
+
rescue URI::InvalidURIError
|
|
105
|
+
# ignore
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Prefer UA-derived device bucket like Plausible
|
|
110
|
+
begin
|
|
111
|
+
ua = (AhoyAnalytics::Current.request&.user_agent || attrs[:user_agent]).to_s
|
|
112
|
+
bucket = AhoyAnalytics::DeviceBucket.classify(ua)
|
|
113
|
+
attrs[:screen_size] = bucket if bucket.present?
|
|
114
|
+
rescue StandardError
|
|
115
|
+
# ignore UA parsing errors
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Extract browser_version and os_version using DeviceDetector (Plausible-style major.minor)
|
|
119
|
+
begin
|
|
120
|
+
ua_string = (AhoyAnalytics::Current.request&.user_agent || attrs[:user_agent]).to_s
|
|
121
|
+
if ua_string.present? && defined?(DeviceDetector)
|
|
122
|
+
detector = DeviceDetector.new(ua_string)
|
|
123
|
+
# Browser version: take major.minor only (e.g., "120.0.6099" → "120.0")
|
|
124
|
+
if detector.full_version.present?
|
|
125
|
+
attrs[:browser_version] = major_minor_version(detector.full_version)
|
|
126
|
+
end
|
|
127
|
+
# OS version: take major.minor only (e.g., "10.15.7" → "10.15")
|
|
128
|
+
if detector.os_full_version.present?
|
|
129
|
+
attrs[:os_version] = major_minor_version(detector.os_full_version)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
rescue StandardError
|
|
133
|
+
# never block tracking
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
result = super(attrs)
|
|
137
|
+
|
|
138
|
+
# Post-create cleanup in case Ahoy overwrote attrs from request
|
|
139
|
+
begin
|
|
140
|
+
token = data[:visit_token]
|
|
141
|
+
v = token.present? ? ::Ahoy::Visit.find_by(visit_token: token) : nil
|
|
142
|
+
if v
|
|
143
|
+
# Ensure hostname is set
|
|
144
|
+
req_host = AhoyAnalytics::Current.request&.host
|
|
145
|
+
if v.hostname.blank? && req_host.present?
|
|
146
|
+
v.update_column(:hostname, req_host)
|
|
147
|
+
end
|
|
148
|
+
# Clear self-referrals if referring_domain equals the site host
|
|
149
|
+
site_host = v.hostname.presence || req_host
|
|
150
|
+
if local_host?(v.referring_domain) || same_site_host?(v.referring_domain, site_host)
|
|
151
|
+
v.update_columns(referrer: nil, referring_domain: nil)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
rescue StandardError
|
|
155
|
+
# never block tracking
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
result
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Capture screen_size into visit on first event we see it
|
|
162
|
+
def track_event(data)
|
|
163
|
+
data = data.with_indifferent_access
|
|
164
|
+
|
|
165
|
+
# Pre-dedupe: eliminate duplicate pageview events for the same visit & page
|
|
166
|
+
begin
|
|
167
|
+
req = AhoyAnalytics::Current.request
|
|
168
|
+
name = data[:name].to_s
|
|
169
|
+
if name == "pageview"
|
|
170
|
+
props = data[:properties].to_h.with_indifferent_access
|
|
171
|
+
page_value = props[:page]
|
|
172
|
+
token = data[:visit_token]
|
|
173
|
+
event_time = begin
|
|
174
|
+
t = data[:time]
|
|
175
|
+
# Ahoy accepts integer seconds; tolerate float or nil
|
|
176
|
+
t.present? ? Time.at(t.to_f).in_time_zone : Time.current
|
|
177
|
+
rescue StandardError
|
|
178
|
+
Time.current
|
|
179
|
+
end
|
|
180
|
+
# Suppress phantom root pageviews: if "/" is first and a non-root pageview
|
|
181
|
+
# arrives shortly after for the same visit, drop the root event.
|
|
182
|
+
begin
|
|
183
|
+
if token.present? && page_value.present? && page_value.to_s != "/"
|
|
184
|
+
if (v = ::Ahoy::Visit.find_by(visit_token: token))
|
|
185
|
+
phantom = ::Ahoy::Event
|
|
186
|
+
.where(visit_id: v.id, name: "pageview")
|
|
187
|
+
.where("time BETWEEN ? AND ?", event_time - 2.seconds, event_time + 0.seconds)
|
|
188
|
+
.where("ahoy_events.properties->>'page' = '/'")
|
|
189
|
+
.where("coalesce(ahoy_events.properties->>'referrer','') = ''")
|
|
190
|
+
.order(time: :desc)
|
|
191
|
+
.first
|
|
192
|
+
if phantom
|
|
193
|
+
phantom.delete
|
|
194
|
+
# landing_page should reflect the actual entry URL
|
|
195
|
+
url_now = props[:url].to_s
|
|
196
|
+
v.update_column(:landing_page, url_now) if url_now.present?
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
rescue StandardError
|
|
201
|
+
# never block tracking
|
|
202
|
+
end
|
|
203
|
+
if token.present? && page_value.present?
|
|
204
|
+
if (v = ::Ahoy::Visit.find_by(visit_token: token))
|
|
205
|
+
# If a pageview for the same page exists within ±1s, treat it as duplicate and skip insert
|
|
206
|
+
dup_exists = ::Ahoy::Event
|
|
207
|
+
.where(visit_id: v.id, name: "pageview")
|
|
208
|
+
.where("time BETWEEN ? AND ?", event_time - 1.second, event_time + 1.second)
|
|
209
|
+
.where("ahoy_events.properties->>'page' = ?", page_value.to_s)
|
|
210
|
+
.exists?
|
|
211
|
+
|
|
212
|
+
if dup_exists
|
|
213
|
+
# Best-effort landing_page correction even when skipping insert
|
|
214
|
+
begin
|
|
215
|
+
url = props[:url].to_s
|
|
216
|
+
if url.present? && v.landing_page.present?
|
|
217
|
+
lp_path = begin
|
|
218
|
+
URI.parse(v.landing_page).path
|
|
219
|
+
rescue URI::InvalidURIError
|
|
220
|
+
v.landing_page.to_s
|
|
221
|
+
end
|
|
222
|
+
url_path = begin
|
|
223
|
+
URI.parse(url).path
|
|
224
|
+
rescue URI::InvalidURIError
|
|
225
|
+
url
|
|
226
|
+
end
|
|
227
|
+
if lp_path.to_s == "/" && url_path.present? && url_path != "/"
|
|
228
|
+
v.update_column(:landing_page, url)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
rescue StandardError
|
|
232
|
+
end
|
|
233
|
+
return ::Ahoy::Event.new # sentinel; callers don't rely on the instance
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
rescue StandardError
|
|
239
|
+
# never block tracking
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
result = super(data)
|
|
243
|
+
|
|
244
|
+
# Extract properties (data is already indifferent access)
|
|
245
|
+
props = data[:properties].to_h.with_indifferent_access
|
|
246
|
+
raw_size = props[:screen_size]
|
|
247
|
+
|
|
248
|
+
event = result.is_a?(::Ahoy::Event) ? result : nil
|
|
249
|
+
|
|
250
|
+
# Primary: set bucket from UA like Plausible
|
|
251
|
+
ua_bucket = begin
|
|
252
|
+
v = event&.visit
|
|
253
|
+
AhoyAnalytics::DeviceBucket.classify(v&.user_agent.to_s)
|
|
254
|
+
rescue StandardError
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
updated = false
|
|
259
|
+
if ua_bucket.present? && event&.visit && event.visit.screen_size.blank?
|
|
260
|
+
event.visit.update_column(:screen_size, ua_bucket)
|
|
261
|
+
updated = true
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Secondary fallback: derive from viewport string (WxH) if UA failed
|
|
265
|
+
unless updated
|
|
266
|
+
token = data[:visit_token]
|
|
267
|
+
fallback_bucket = AhoyAnalytics::DeviceBucket.classify_from_viewport(raw_size)
|
|
268
|
+
if fallback_bucket.present?
|
|
269
|
+
if event&.visit && event.visit.screen_size.blank?
|
|
270
|
+
event.visit.update_column(:screen_size, fallback_bucket)
|
|
271
|
+
updated = true
|
|
272
|
+
elsif token.present?
|
|
273
|
+
if (v = ::Ahoy::Visit.find_by(visit_token: token)) && v.screen_size.blank?
|
|
274
|
+
v.update_column(:screen_size, fallback_bucket)
|
|
275
|
+
updated = true
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Correct landing_page when a visit is implicitly created with the API path
|
|
282
|
+
# (e.g., "/ahoy/events" or "/ahoy/visits"). Prefer the event's page URL.
|
|
283
|
+
begin
|
|
284
|
+
# Prefer URL from the persisted event; fall back to the raw payload when
|
|
285
|
+
# Ahoy returns something other than the event instance (varies by version).
|
|
286
|
+
event_props = event&.properties.to_h.with_indifferent_access
|
|
287
|
+
data_props = data[:properties].to_h.with_indifferent_access
|
|
288
|
+
url = event_props[:url] || data_props[:url]
|
|
289
|
+
|
|
290
|
+
# Find the visit either from the event association or via visit_token
|
|
291
|
+
visit = event&.visit
|
|
292
|
+
if visit.nil?
|
|
293
|
+
token = data[:visit_token]
|
|
294
|
+
visit = ::Ahoy::Visit.find_by(visit_token: token) if token.present?
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
if visit && url.present?
|
|
298
|
+
lp = visit.landing_page.to_s
|
|
299
|
+
needs_fix = lp.blank?
|
|
300
|
+
unless needs_fix
|
|
301
|
+
begin
|
|
302
|
+
path = URI.parse(lp).path
|
|
303
|
+
rescue URI::InvalidURIError
|
|
304
|
+
path = lp.to_s
|
|
305
|
+
end
|
|
306
|
+
needs_fix ||= internal_path?(path)
|
|
307
|
+
# If landing_page is root ('/') but the first pageview we see points
|
|
308
|
+
# to a non-root path, treat that as the true entry page (Plausible-like).
|
|
309
|
+
if !needs_fix && path.to_s == "/"
|
|
310
|
+
begin
|
|
311
|
+
url_path = URI.parse(url).path
|
|
312
|
+
rescue URI::InvalidURIError
|
|
313
|
+
url_path = url.to_s
|
|
314
|
+
end
|
|
315
|
+
needs_fix ||= url_path.present? && url_path != "/"
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
visit.update_column(:landing_page, url) if needs_fix
|
|
319
|
+
# Also clear self-referrals if referring_domain matches the site host derived from URL
|
|
320
|
+
begin
|
|
321
|
+
url_host = URI.parse(url).host
|
|
322
|
+
if url_host.present? && same_site_host?(visit.referring_domain, url_host)
|
|
323
|
+
visit.update_columns(referrer: nil, referring_domain: nil)
|
|
324
|
+
end
|
|
325
|
+
rescue URI::InvalidURIError
|
|
326
|
+
# ignore
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
rescue StandardError
|
|
330
|
+
# Never block ingestion due to correction failures
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Fallback geo enrichment: if visit has no coordinates yet, attempt a one-time lookup
|
|
334
|
+
begin
|
|
335
|
+
if req
|
|
336
|
+
visit = event&.visit
|
|
337
|
+
if visit && visit.latitude.blank? && visit.longitude.blank?
|
|
338
|
+
candidates = []
|
|
339
|
+
%w[HTTP_CF_CONNECTING_IP HTTP_TRUE_CLIENT_IP HTTP_X_REAL_IP].each do |h|
|
|
340
|
+
v = req.get_header(h)
|
|
341
|
+
candidates << v if v.present?
|
|
342
|
+
end
|
|
343
|
+
xff = req.get_header("HTTP_X_FORWARDED_FOR").to_s
|
|
344
|
+
candidates.concat(xff.split(",").map(&:strip)) if xff.present?
|
|
345
|
+
candidates << req.get_header("REMOTE_ADDR")
|
|
346
|
+
candidates << (req.ip rescue nil)
|
|
347
|
+
candidates << (req.remote_ip rescue nil)
|
|
348
|
+
candidates = candidates.compact.uniq
|
|
349
|
+
|
|
350
|
+
if defined?(AhoyAnalytics::MaxmindGeo)
|
|
351
|
+
client_ip = candidates.find { |ip| AhoyAnalytics::MaxmindGeo.valid_ip?(ip) }
|
|
352
|
+
if client_ip.nil? && xff.present?
|
|
353
|
+
xff.split(",").map(&:strip).reverse_each do |ip|
|
|
354
|
+
if AhoyAnalytics::MaxmindGeo.valid_ip?(ip)
|
|
355
|
+
client_ip = ip
|
|
356
|
+
break
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
if client_ip && (geo = AhoyAnalytics::MaxmindGeo.lookup(client_ip))
|
|
362
|
+
visit.update_columns(
|
|
363
|
+
country: visit.country.presence || geo[:country_iso],
|
|
364
|
+
region: visit.region.presence || geo[:subdivisions]&.first,
|
|
365
|
+
city: visit.city.presence || geo[:city],
|
|
366
|
+
latitude: visit.latitude.presence || geo[:latitude],
|
|
367
|
+
longitude: visit.longitude.presence || geo[:longitude]
|
|
368
|
+
)
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Country-only fallback via Cloudflare header
|
|
373
|
+
begin
|
|
374
|
+
if visit.country.blank?
|
|
375
|
+
cc = req.get_header("HTTP_CF_IPCOUNTRY").to_s.upcase.presence
|
|
376
|
+
visit.update_column(:country, cc) if cc
|
|
377
|
+
end
|
|
378
|
+
rescue StandardError
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
rescue StandardError
|
|
383
|
+
# Never block ingestion on geo failures
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
result
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
private
|
|
390
|
+
# Detect internal/system paths that should never be used as landing pages
|
|
391
|
+
def internal_path?(value)
|
|
392
|
+
return false if value.blank?
|
|
393
|
+
path = begin
|
|
394
|
+
URI.parse(value).path
|
|
395
|
+
rescue URI::InvalidURIError
|
|
396
|
+
value.to_s
|
|
397
|
+
end.to_s
|
|
398
|
+
path.start_with?("/ahoy", "/cable", "/rails/", "/assets/", "/up", "/jobs", "/webhooks")
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Compare hostnames for self-referral cleanup (case-insensitive, ignoring www.)
|
|
402
|
+
def same_site_host?(ref_host, site_host)
|
|
403
|
+
return false if ref_host.to_s.strip.empty? || site_host.to_s.strip.empty?
|
|
404
|
+
a = ref_host.to_s.downcase.sub(/^www\./, "")
|
|
405
|
+
b = site_host.to_s.downcase.sub(/^www\./, "")
|
|
406
|
+
a == b
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def local_host?(host)
|
|
410
|
+
h = host.to_s.downcase
|
|
411
|
+
return true if h == "localhost"
|
|
412
|
+
begin
|
|
413
|
+
ip = IPAddr.new(h) rescue nil
|
|
414
|
+
return true if ip && (ip.loopback? || ip.to_s == "0.0.0.0" || ip.to_s == "::1")
|
|
415
|
+
rescue StandardError
|
|
416
|
+
end
|
|
417
|
+
false
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Extract major.minor version only (Plausible-style)
|
|
421
|
+
# "120.0.6099.109" -> "120.0"
|
|
422
|
+
# "10.15.7" -> "10.15"
|
|
423
|
+
# "17" -> "17"
|
|
424
|
+
def major_minor_version(version_string)
|
|
425
|
+
return "" if version_string.blank?
|
|
426
|
+
parts = version_string.to_s.split(".")
|
|
427
|
+
parts.take(2).join(".")
|
|
428
|
+
end
|
|
429
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module AhoyAnalytics
|
|
6
|
+
class AssetManifest
|
|
7
|
+
class MissingManifestError < StandardError; end
|
|
8
|
+
class MissingEntryError < StandardError; end
|
|
9
|
+
|
|
10
|
+
def initialize(path:)
|
|
11
|
+
@path = path
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def entry(entrypoint)
|
|
15
|
+
manifest = read_manifest
|
|
16
|
+
key = resolve_entry_key(manifest, entrypoint)
|
|
17
|
+
return manifest[key] if key
|
|
18
|
+
|
|
19
|
+
raise MissingEntryError, "AhoyAnalytics manifest entry not found for #{entrypoint.inspect}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def read_manifest
|
|
25
|
+
return @manifest if @manifest && @manifest_mtime == manifest_mtime
|
|
26
|
+
|
|
27
|
+
@manifest = JSON.parse(File.read(@path))
|
|
28
|
+
@manifest_mtime = manifest_mtime
|
|
29
|
+
@manifest
|
|
30
|
+
rescue Errno::ENOENT
|
|
31
|
+
raise MissingManifestError, "AhoyAnalytics manifest not found at #{@path}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def manifest_mtime
|
|
35
|
+
File.mtime(@path)
|
|
36
|
+
rescue Errno::ENOENT
|
|
37
|
+
Time.at(0)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def resolve_entry_key(manifest, entrypoint)
|
|
41
|
+
base = entrypoint.to_s.sub(/\.(t|j)sx?\z/, "")
|
|
42
|
+
candidates = [
|
|
43
|
+
"entrypoints/#{base}.tsx",
|
|
44
|
+
"entrypoints/#{base}.ts",
|
|
45
|
+
"entrypoints/#{base}.jsx",
|
|
46
|
+
"entrypoints/#{base}.js",
|
|
47
|
+
"#{base}.tsx",
|
|
48
|
+
"#{base}.ts",
|
|
49
|
+
"#{base}.jsx",
|
|
50
|
+
"#{base}.js"
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
candidates.find { |candidate| manifest.key?(candidate) }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module AhoyAnalytics
|
|
2
|
+
module DeviceBucket
|
|
3
|
+
# Map user agent device types to Plausible-like buckets
|
|
4
|
+
# Using device_detector's device_type values
|
|
5
|
+
MOBILE_TYPES = %w[smartphone feature\ phone portable\ media\ player phablet wearable camera].freeze
|
|
6
|
+
TABLET_TYPES = %w[tablet car\ browser].freeze
|
|
7
|
+
DESKTOP_TYPES = %w[desktop tv console].freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def classify(user_agent)
|
|
12
|
+
return nil if user_agent.to_s.strip.empty?
|
|
13
|
+
type = begin
|
|
14
|
+
dd = DeviceDetector.new(user_agent.to_s)
|
|
15
|
+
dd.device_type.to_s.downcase
|
|
16
|
+
rescue StandardError
|
|
17
|
+
""
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
return "Mobile" if MOBILE_TYPES.include?(type)
|
|
21
|
+
return "Tablet" if TABLET_TYPES.include?(type)
|
|
22
|
+
return "Desktop" if DESKTOP_TYPES.include?(type)
|
|
23
|
+
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Fallback categorization from viewport width to bucket using Plausible's historic thresholds
|
|
28
|
+
# <576 => Mobile, <992 => Tablet, <1440 => Laptop, else Desktop
|
|
29
|
+
# We return only Mobile/Tablet/Desktop to align with current Plausible ingestion.
|
|
30
|
+
def classify_from_viewport(size_string)
|
|
31
|
+
return nil unless size_string.to_s =~ /^(\d+)x(\d+)$/
|
|
32
|
+
width = Regexp.last_match(1).to_i
|
|
33
|
+
return "Mobile" if width < 576
|
|
34
|
+
return "Tablet" if width < 992
|
|
35
|
+
# Historically this range was "Laptop"; normalize to Desktop for parity
|
|
36
|
+
"Desktop"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module AhoyAnalytics
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
isolate_namespace AhoyAnalytics
|
|
4
|
+
|
|
5
|
+
config.autoload_paths << root.join("lib")
|
|
6
|
+
config.eager_load_paths << root.join("lib")
|
|
7
|
+
|
|
8
|
+
initializer "ahoy_analytics.helpers" do
|
|
9
|
+
ActiveSupport.on_load(:action_view) do
|
|
10
|
+
include AhoyAnalytics::ApplicationHelper
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
initializer "ahoy_analytics.ahoy" do
|
|
15
|
+
config.to_prepare do
|
|
16
|
+
Ahoy.api = true
|
|
17
|
+
Ahoy.cookies = :none
|
|
18
|
+
Ahoy.mask_ips = true
|
|
19
|
+
Ahoy.track_bots = false
|
|
20
|
+
Ahoy.geocode = false
|
|
21
|
+
Ahoy.visit_duration = 30.minutes
|
|
22
|
+
Ahoy.quiet = false
|
|
23
|
+
Ahoy.server_side_visits = :when_needed
|
|
24
|
+
|
|
25
|
+
Ahoy.exclude_method = lambda do |controller, request|
|
|
26
|
+
req = request || controller&.request
|
|
27
|
+
return true if req.nil?
|
|
28
|
+
path = req.path.to_s
|
|
29
|
+
|
|
30
|
+
ahoy_path = AhoyAnalytics.config.ahoy_path.to_s
|
|
31
|
+
ahoy_path = "/#{ahoy_path}" unless ahoy_path.start_with?("/")
|
|
32
|
+
return false if path.start_with?(ahoy_path)
|
|
33
|
+
|
|
34
|
+
excluded = Array(AhoyAnalytics.config.tracking_exclude_paths)
|
|
35
|
+
excluded << AhoyAnalytics.config.mount_path if AhoyAnalytics.config.mount_path.present?
|
|
36
|
+
excluded.compact.any? { |prefix| path.start_with?(prefix.to_s) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if defined?(Ahoy::VisitsController)
|
|
40
|
+
Ahoy::VisitsController.skip_forgery_protection
|
|
41
|
+
Ahoy::VisitsController.around_action do |controller, action|
|
|
42
|
+
AhoyAnalytics::Current.set(request: controller.request) { action.call }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
if defined?(Ahoy::EventsController)
|
|
47
|
+
Ahoy::EventsController.skip_forgery_protection
|
|
48
|
+
Ahoy::EventsController.around_action do |controller, action|
|
|
49
|
+
AhoyAnalytics::Current.set(request: controller.request) { action.call }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# MaxMind GeoLite2 City integration (attribution handled in the admin UI)
|
|
4
|
+
# Defaults to db/geo/GeoLite2-City.mmdb unless MAXMIND_DB_PATH is provided
|
|
5
|
+
|
|
6
|
+
require "maxminddb"
|
|
7
|
+
require "ipaddr"
|
|
8
|
+
|
|
9
|
+
module AhoyAnalytics
|
|
10
|
+
DEFAULT_DB_RELATIVE_PATH = "db/geo/GeoLite2-City.mmdb"
|
|
11
|
+
|
|
12
|
+
module MaxmindGeo
|
|
13
|
+
extend self
|
|
14
|
+
|
|
15
|
+
def reader
|
|
16
|
+
path = database_path
|
|
17
|
+
# Reuse the reader if we already opened this exact path
|
|
18
|
+
if defined?(@reader) && @reader && @reader_path == path
|
|
19
|
+
return @reader
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
@reader&.close if defined?(@reader) && @reader.respond_to?(:close)
|
|
23
|
+
@reader = (path && File.exist?(path)) ? MaxMindDB.new(path.to_s) : nil
|
|
24
|
+
@reader_path = path
|
|
25
|
+
@reader
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
Rails.logger.warn("[AhoyAnalytics::MaxmindGeo] failed to open DB: #{e.class}: #{e.message}") if defined?(Rails)
|
|
28
|
+
@reader = nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def database_path
|
|
32
|
+
env_path = ENV["MAXMIND_DB_PATH"]
|
|
33
|
+
return env_path if env_path.present?
|
|
34
|
+
|
|
35
|
+
return unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
36
|
+
|
|
37
|
+
path = Rails.root.join(DEFAULT_DB_RELATIVE_PATH)
|
|
38
|
+
path if path.exist?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def lookup(ip)
|
|
42
|
+
r = reader
|
|
43
|
+
return nil unless r
|
|
44
|
+
return nil unless valid_ip?(ip)
|
|
45
|
+
|
|
46
|
+
result = r.lookup(ip.to_s)
|
|
47
|
+
return nil unless result&.found?
|
|
48
|
+
|
|
49
|
+
{
|
|
50
|
+
country_iso: result.country&.iso_code,
|
|
51
|
+
city: result.city&.name,
|
|
52
|
+
subdivisions: Array(result.subdivisions).map { |s| s.name || s.iso_code }.compact,
|
|
53
|
+
latitude: result.location&.latitude,
|
|
54
|
+
longitude: result.location&.longitude
|
|
55
|
+
}
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
Rails.logger.debug("[AhoyAnalytics::MaxmindGeo] lookup failed for #{ip.inspect}: #{e.class}: #{e.message}") if defined?(Rails)
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Reject private, loopback, link‑local and unspecified addresses
|
|
62
|
+
def valid_ip?(ip)
|
|
63
|
+
ip = ip.to_s
|
|
64
|
+
return false if ip.blank?
|
|
65
|
+
addr = IPAddr.new(ip) rescue nil
|
|
66
|
+
return false unless addr
|
|
67
|
+
|
|
68
|
+
return false if addr.loopback? # 127.0.0.0/8, ::1
|
|
69
|
+
return false if addr.private? # 10/8, 172.16/12, 192.168/16, fc00::/7
|
|
70
|
+
# Link‑local and unspecified
|
|
71
|
+
return false if addr.link_local?
|
|
72
|
+
return false if addr.ipv6? && (addr.to_s == "::")
|
|
73
|
+
return false if addr.ipv4? && (addr.to_s == "0.0.0.0")
|
|
74
|
+
true
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|