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.
Files changed (198) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +163 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/ahoy_analytics/build/assets/Combination-BpSXUjp9.js +41 -0
  6. data/app/assets/ahoy_analytics/build/assets/analytics-5KyfCxh6.css +1 -0
  7. data/app/assets/ahoy_analytics/build/assets/analytics-dashboard-uOXx8zYZ.js +1 -0
  8. data/app/assets/ahoy_analytics/build/assets/analytics-layout-ClAft5OU.js +1 -0
  9. data/app/assets/ahoy_analytics/build/assets/analytics-tracker-B3f8P98z.js +1 -0
  10. data/app/assets/ahoy_analytics/build/assets/analytics-ui-DMSkNqd6.js +90 -0
  11. data/app/assets/ahoy_analytics/build/assets/behaviors-panel-ChNGYbdH.js +1 -0
  12. data/app/assets/ahoy_analytics/build/assets/button-JVCrlR4s.js +1 -0
  13. data/app/assets/ahoy_analytics/build/assets/cable-DO-7y1-E.js +1 -0
  14. data/app/assets/ahoy_analytics/build/assets/createLucideIcon-BGzacY2v.js +1 -0
  15. data/app/assets/ahoy_analytics/build/assets/date-range-dialog-DWDp3cLG.js +1 -0
  16. data/app/assets/ahoy_analytics/build/assets/details-button-NqKfSGEG.js +1 -0
  17. data/app/assets/ahoy_analytics/build/assets/devices-panel-cXvlmNBY.js +1 -0
  18. data/app/assets/ahoy_analytics/build/assets/dialog-path-BBPNlB4Z.js +1 -0
  19. data/app/assets/ahoy_analytics/build/assets/dropdown-menu-Adj3O5fh.js +1 -0
  20. data/app/assets/ahoy_analytics/build/assets/filter-dialog-BN-rf4lp.js +1 -0
  21. data/app/assets/ahoy_analytics/build/assets/index-B1K1NTKT.js +3 -0
  22. data/app/assets/ahoy_analytics/build/assets/index-BcHeb-Rh.js +1 -0
  23. data/app/assets/ahoy_analytics/build/assets/index-DzpzLoG4.js +1 -0
  24. data/app/assets/ahoy_analytics/build/assets/index-vX97OY1J.js +1 -0
  25. data/app/assets/ahoy_analytics/build/assets/input-e4v_v0kE.js +1 -0
  26. data/app/assets/ahoy_analytics/build/assets/jsx-runtime-u17CrQMm.js +1 -0
  27. data/app/assets/ahoy_analytics/build/assets/last-load-context-De5uA95L.js +1 -0
  28. data/app/assets/ahoy_analytics/build/assets/list-table-ChHEzzF9.js +1 -0
  29. data/app/assets/ahoy_analytics/build/assets/live-Cp2MHECh.js +2 -0
  30. data/app/assets/ahoy_analytics/build/assets/locations-panel-BaISRmaQ.js +1 -0
  31. data/app/assets/ahoy_analytics/build/assets/mercator-BnxX5RzL.js +1 -0
  32. data/app/assets/ahoy_analytics/build/assets/pages-panel-Bh25L8mP.js +1 -0
  33. data/app/assets/ahoy_analytics/build/assets/panel-tabs-B2kvGFJx.js +1 -0
  34. data/app/assets/ahoy_analytics/build/assets/query-context-B-PgE00D.js +1 -0
  35. data/app/assets/ahoy_analytics/build/assets/remote-details-dialog-DDTcKaM5.js +1 -0
  36. data/app/assets/ahoy_analytics/build/assets/show-CCRicksg.js +1 -0
  37. data/app/assets/ahoy_analytics/build/assets/simple-tabs-D6G6Bs0k.js +1 -0
  38. data/app/assets/ahoy_analytics/build/assets/site-context-BNteYRlR.js +1 -0
  39. data/app/assets/ahoy_analytics/build/assets/sources-panel-DyB21hxD.js +1 -0
  40. data/app/assets/ahoy_analytics/build/assets/top-bar-FSiLBjq6.js +1 -0
  41. data/app/assets/ahoy_analytics/build/assets/top-stats-context-DU15P9jS.js +1 -0
  42. data/app/assets/ahoy_analytics/build/assets/use-debounce-VBpXQRL8.js +1 -0
  43. data/app/assets/ahoy_analytics/build/assets/user-context-DbYteluY.js +1 -0
  44. data/app/assets/ahoy_analytics/build/assets/visitor-globe-BWLDihid.js +4789 -0
  45. data/app/assets/ahoy_analytics/build/assets/visitor-graph-uKXjLvcu.js +1 -0
  46. data/app/assets/ahoy_analytics/images/icon/browser/brave.svg +1 -0
  47. data/app/assets/ahoy_analytics/images/icon/browser/chrome.svg +1 -0
  48. data/app/assets/ahoy_analytics/images/icon/browser/chromium.svg +1 -0
  49. data/app/assets/ahoy_analytics/images/icon/browser/duckduckgo.svg +2151 -0
  50. data/app/assets/ahoy_analytics/images/icon/browser/edge.svg +1 -0
  51. data/app/assets/ahoy_analytics/images/icon/browser/fallback.svg +5 -0
  52. data/app/assets/ahoy_analytics/images/icon/browser/firefox.svg +1 -0
  53. data/app/assets/ahoy_analytics/images/icon/browser/opera.svg +1 -0
  54. data/app/assets/ahoy_analytics/images/icon/browser/safari.png +0 -0
  55. data/app/assets/ahoy_analytics/images/icon/browser/samsung-internet.svg +1 -0
  56. data/app/assets/ahoy_analytics/images/icon/browser/uc.svg +1 -0
  57. data/app/assets/ahoy_analytics/images/icon/browser/vivaldi.svg +1 -0
  58. data/app/assets/ahoy_analytics/images/icon/browser/yandex.png +0 -0
  59. data/app/assets/ahoy_analytics/images/icon/os/android.png +0 -0
  60. data/app/assets/ahoy_analytics/images/icon/os/chrome_os.png +0 -0
  61. data/app/assets/ahoy_analytics/images/icon/os/fallback.svg +5 -0
  62. data/app/assets/ahoy_analytics/images/icon/os/fedora.png +0 -0
  63. data/app/assets/ahoy_analytics/images/icon/os/freebsd.png +0 -0
  64. data/app/assets/ahoy_analytics/images/icon/os/gnu_linux.png +0 -0
  65. data/app/assets/ahoy_analytics/images/icon/os/ios.png +0 -0
  66. data/app/assets/ahoy_analytics/images/icon/os/ipad_os.png +0 -0
  67. data/app/assets/ahoy_analytics/images/icon/os/mac.png +0 -0
  68. data/app/assets/ahoy_analytics/images/icon/os/ubuntu.png +0 -0
  69. data/app/assets/ahoy_analytics/images/icon/os/windows.png +0 -0
  70. data/app/assets/stylesheets/ahoy_analytics/application.css +15 -0
  71. data/app/channels/ahoy_analytics/analytics_channel.rb +9 -0
  72. data/app/controllers/ahoy_analytics/analytics_controller.rb +9 -0
  73. data/app/controllers/ahoy_analytics/application_controller.rb +8 -0
  74. data/app/controllers/ahoy_analytics/assets_controller.rb +46 -0
  75. data/app/controllers/ahoy_analytics/base_controller.rb +285 -0
  76. data/app/controllers/ahoy_analytics/behaviors_controller.rb +14 -0
  77. data/app/controllers/ahoy_analytics/devices_controller.rb +14 -0
  78. data/app/controllers/ahoy_analytics/export_controller.rb +12 -0
  79. data/app/controllers/ahoy_analytics/live_controller.rb +9 -0
  80. data/app/controllers/ahoy_analytics/locations_controller.rb +14 -0
  81. data/app/controllers/ahoy_analytics/main_graph_controller.rb +10 -0
  82. data/app/controllers/ahoy_analytics/pages_controller.rb +14 -0
  83. data/app/controllers/ahoy_analytics/referrers_controller.rb +34 -0
  84. data/app/controllers/ahoy_analytics/search_terms_controller.rb +47 -0
  85. data/app/controllers/ahoy_analytics/sources_controller.rb +14 -0
  86. data/app/controllers/ahoy_analytics/top_stats_controller.rb +13 -0
  87. data/app/controllers/concerns/ahoy_analytics/set_current_request.rb +17 -0
  88. data/app/frontend/components/analytics/hex-highlights.tsx +165 -0
  89. data/app/frontend/components/analytics/hex-land-layer.tsx +61 -0
  90. data/app/frontend/components/analytics/metric-card.tsx +138 -0
  91. data/app/frontend/components/analytics/sessions-by-location.tsx +62 -0
  92. data/app/frontend/components/analytics/visitor-globe.tsx +424 -0
  93. data/app/frontend/components/ui/accordion.tsx +64 -0
  94. data/app/frontend/components/ui/alert.tsx +66 -0
  95. data/app/frontend/components/ui/avatar.tsx +53 -0
  96. data/app/frontend/components/ui/badge.tsx +46 -0
  97. data/app/frontend/components/ui/button.tsx +62 -0
  98. data/app/frontend/components/ui/calendar.tsx +212 -0
  99. data/app/frontend/components/ui/card.tsx +91 -0
  100. data/app/frontend/components/ui/checkbox.tsx +32 -0
  101. data/app/frontend/components/ui/dropdown-menu.tsx +255 -0
  102. data/app/frontend/components/ui/input.tsx +21 -0
  103. data/app/frontend/components/ui/label.tsx +22 -0
  104. data/app/frontend/components/ui/popover.tsx +46 -0
  105. data/app/frontend/components/ui/select.tsx +183 -0
  106. data/app/frontend/components/ui/separator.tsx +26 -0
  107. data/app/frontend/components/ui/sheet.tsx +139 -0
  108. data/app/frontend/components/ui/sidebar.tsx +726 -0
  109. data/app/frontend/components/ui/skeleton.tsx +13 -0
  110. data/app/frontend/components/ui/sonner.tsx +33 -0
  111. data/app/frontend/components/ui/tooltip.tsx +59 -0
  112. data/app/frontend/data/countries-110m.json +1 -0
  113. data/app/frontend/data/globe-data.json +1 -0
  114. data/app/frontend/entrypoints/analytics-tracker.ts +680 -0
  115. data/app/frontend/entrypoints/analytics-ui.tsx +26 -0
  116. data/app/frontend/entrypoints/analytics.css +77 -0
  117. data/app/frontend/layouts/analytics-layout.tsx +28 -0
  118. data/app/frontend/lib/cable.ts +13 -0
  119. data/app/frontend/lib/geocode.ts +65 -0
  120. data/app/frontend/lib/utils.ts +6 -0
  121. data/app/frontend/pages/admin/analytics/api.ts +221 -0
  122. data/app/frontend/pages/admin/analytics/hooks/use-debounce.ts +36 -0
  123. data/app/frontend/pages/admin/analytics/last-load-context.tsx +29 -0
  124. data/app/frontend/pages/admin/analytics/lib/base-path.ts +28 -0
  125. data/app/frontend/pages/admin/analytics/lib/dialog-path.ts +242 -0
  126. data/app/frontend/pages/admin/analytics/lib/number-formatter.ts +100 -0
  127. data/app/frontend/pages/admin/analytics/live.tsx +608 -0
  128. data/app/frontend/pages/admin/analytics/query-context.tsx +61 -0
  129. data/app/frontend/pages/admin/analytics/show.tsx +40 -0
  130. data/app/frontend/pages/admin/analytics/site-context.tsx +22 -0
  131. data/app/frontend/pages/admin/analytics/top-stats-context.tsx +37 -0
  132. data/app/frontend/pages/admin/analytics/types.ts +161 -0
  133. data/app/frontend/pages/admin/analytics/ui/analytics-dashboard.tsx +60 -0
  134. data/app/frontend/pages/admin/analytics/ui/behaviors-panel.tsx +456 -0
  135. data/app/frontend/pages/admin/analytics/ui/date-range-dialog.tsx +173 -0
  136. data/app/frontend/pages/admin/analytics/ui/details-button.tsx +33 -0
  137. data/app/frontend/pages/admin/analytics/ui/devices-panel.tsx +474 -0
  138. data/app/frontend/pages/admin/analytics/ui/filter-dialog.tsx +558 -0
  139. data/app/frontend/pages/admin/analytics/ui/list-table.tsx +346 -0
  140. data/app/frontend/pages/admin/analytics/ui/locations-panel.tsx +566 -0
  141. data/app/frontend/pages/admin/analytics/ui/pages-panel.tsx +207 -0
  142. data/app/frontend/pages/admin/analytics/ui/panel-tabs.tsx +65 -0
  143. data/app/frontend/pages/admin/analytics/ui/remote-details-dialog.tsx +356 -0
  144. data/app/frontend/pages/admin/analytics/ui/simple-tabs.tsx +54 -0
  145. data/app/frontend/pages/admin/analytics/ui/sources-panel.tsx +771 -0
  146. data/app/frontend/pages/admin/analytics/ui/top-bar.tsx +793 -0
  147. data/app/frontend/pages/admin/analytics/ui/visitor-graph.tsx +891 -0
  148. data/app/frontend/pages/admin/analytics/user-context.tsx +22 -0
  149. data/app/frontend/styles/shared.css +156 -0
  150. data/app/helpers/ahoy_analytics/application_helper.rb +96 -0
  151. data/app/jobs/ahoy_analytics/application_job.rb +4 -0
  152. data/app/jobs/ahoy_analytics/update_job.rb +12 -0
  153. data/app/mailers/ahoy_analytics/application_mailer.rb +6 -0
  154. data/app/models/ahoy/event/filters.rb +7 -0
  155. data/app/models/ahoy/event.rb +9 -0
  156. data/app/models/ahoy/visit/cache_key.rb +15 -0
  157. data/app/models/ahoy/visit/constants.rb +11 -0
  158. data/app/models/ahoy/visit/devices.rb +144 -0
  159. data/app/models/ahoy/visit/export.rb +24 -0
  160. data/app/models/ahoy/visit/filters.rb +286 -0
  161. data/app/models/ahoy/visit/imports.rb +36 -0
  162. data/app/models/ahoy/visit/locations.rb +276 -0
  163. data/app/models/ahoy/visit/metrics.rb +473 -0
  164. data/app/models/ahoy/visit/ordering.rb +110 -0
  165. data/app/models/ahoy/visit/pages.rb +533 -0
  166. data/app/models/ahoy/visit/pagination.rb +17 -0
  167. data/app/models/ahoy/visit/ranges.rb +227 -0
  168. data/app/models/ahoy/visit/series.rb +177 -0
  169. data/app/models/ahoy/visit/sources.rb +418 -0
  170. data/app/models/ahoy/visit/url_labels.rb +32 -0
  171. data/app/models/ahoy/visit.rb +143 -0
  172. data/app/models/ahoy_analytics/application_record.rb +5 -0
  173. data/app/models/ahoy_analytics/current.rb +8 -0
  174. data/app/models/ahoy_analytics/funnel.rb +16 -0
  175. data/app/models/ahoy_analytics/imported_entry_page.rb +5 -0
  176. data/app/models/ahoy_analytics/imported_exit_page.rb +5 -0
  177. data/app/models/ahoy_analytics/imported_page.rb +5 -0
  178. data/app/models/ahoy_analytics/live_stats.rb +152 -0
  179. data/app/models/ahoy_analytics/setting.rb +19 -0
  180. data/app/models/analytics/source_catalog.rb +48 -0
  181. data/app/views/layouts/ahoy_analytics/application.html.erb +15 -0
  182. data/config/routes.rb +21 -0
  183. data/config/vite.json +22 -0
  184. data/db/migrate/20251006104056_create_ahoy_visits_and_events.rb +62 -0
  185. data/db/migrate/20251006105012_add_analytics_fields_to_ahoy_visits.rb +11 -0
  186. data/db/migrate/20251012090000_create_analytics_funnels_and_imports.rb +52 -0
  187. data/db/migrate/20251013021500_add_analytics_indexes.rb +14 -0
  188. data/lib/ahoy_analytics/ahoy_store.rb +429 -0
  189. data/lib/ahoy_analytics/asset_manifest.rb +56 -0
  190. data/lib/ahoy_analytics/device_bucket.rb +39 -0
  191. data/lib/ahoy_analytics/engine.rb +55 -0
  192. data/lib/ahoy_analytics/maxmind_geo.rb +77 -0
  193. data/lib/ahoy_analytics/version.rb +3 -0
  194. data/lib/ahoy_analytics.rb +52 -0
  195. data/lib/generators/ahoy_analytics/install/install_generator.rb +111 -0
  196. data/lib/generators/ahoy_analytics/install/templates/initializer.rb +28 -0
  197. data/lib/tasks/ahoy_analytics_tasks.rake +4 -0
  198. 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
@@ -0,0 +1,3 @@
1
+ module AhoyAnalytics
2
+ VERSION = "0.1.0"
3
+ end