โ† insidertrades.directory / built with rails

Ahoy + Blazer metrics

Jason Kotchoff committed on 2025-09-30 12:57:00 +1000 ยท commit 27b621a36b8a71b36001062d64d1e5dcd7c72fd3

๐Ÿค– Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
@@ -19,6 +19,10 @@ gem "jbuilder"
1919
2020 gem "jwt" # for google signin
2121
22+# Analytics and dashboard
23+gem "ahoy_matey"
24+gem "blazer"
25+
2226 # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
2327 # gem "bcrypt", "~> 3.1.7"
2428
@@ -74,26 +74,39 @@ GEM
7474 uri (>= 0.13.1)
7575 addressable (2.8.7)
7676 public_suffix (>= 2.0.2, < 7.0)
77+ ahoy_matey (5.4.0)
78+ activesupport (>= 7.1)
79+ device_detector (>= 1)
80+ safely_block (>= 0.4)
7781 ast (2.4.3)
7882 base64 (0.3.0)
7983 bcrypt_pbkdf (1.1.1)
8084 benchmark (0.4.1)
8185 bigdecimal (3.2.3)
8286 bindex (0.8.1)
87+ blazer (3.3.0)
88+ activerecord (>= 7.1)
89+ chartkick (>= 5)
90+ csv
91+ railties (>= 7.1)
92+ safely_block (>= 0.4)
8393 bootsnap (1.18.6)
8494 msgpack (~> 1.2)
8595 brakeman (7.1.0)
8696 racc
8797 builder (3.3.0)
98+ chartkick (5.2.0)
8899 childprocess (5.1.0)
89100 logger (~> 1.5)
90101 concurrent-ruby (1.3.5)
91102 connection_pool (2.5.4)
92103 crass (1.0.6)
104+ csv (3.3.5)
93105 date (3.4.1)
94106 debug (1.11.0)
95107 irb (~> 1.10)
96108 reline (>= 0.3.8)
109+ device_detector (1.1.3)
97110 diff-lcs (1.6.2)
98111 dotenv (3.1.8)
99112 drb (2.2.3)
@@ -297,6 +310,7 @@ GEM
297310 rubocop-performance (>= 1.24)
298311 rubocop-rails (>= 2.30)
299312 ruby-progressbar (1.13.0)
313+ safely_block (0.5.0)
300314 securerandom (0.4.1)
301315 solid_cable (3.0.12)
302316 actioncable (>= 7.2)
@@ -370,6 +384,8 @@ PLATFORMS
370384 x86_64-linux-musl
371385
372386 DEPENDENCIES
387+ ahoy_matey
388+ blazer
373389 bootsnap
374390 brakeman
375391 debug
@@ -1,5 +1,19 @@
11 class ApplicationController < ActionController::Base
22 include Authentication
3+ include ApplicationHelper
4+
35 # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
46 allow_browser versions: :modern
7+
8+ private
9+
10+ def track_action
11+ ahoy.track "Action", request.path_parameters
12+ end
13+
14+ def require_admin
15+ unless admin_user?
16+ render plain: "Access denied - Admin privileges required", status: 403
17+ end
18+ end
519 end
@@ -1,2 +1,5 @@
11 module ApplicationHelper
2+ def admin_user?
3+ authenticated? && Rails.application.config.admin_user_ids.include?(Current.user.id)
4+ end
25 end
@@ -0,0 +1,10 @@
1+class Ahoy::Event < ApplicationRecord
2+ include Ahoy::QueryMethods
3+
4+ self.table_name = "ahoy_events"
5+
6+ belongs_to :visit
7+ belongs_to :user, optional: true
8+
9+ serialize :properties, coder: JSON
10+end
@@ -0,0 +1,6 @@
1+class Ahoy::Visit < ApplicationRecord
2+ self.table_name = "ahoy_visits"
3+
4+ has_many :events, class_name: "Ahoy::Event"
5+ belongs_to :user, optional: true
6+end
@@ -24,6 +24,7 @@
2424 <%# Includes all stylesheet files in app/assets/stylesheets %>
2525 <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
2626 <%= javascript_importmap_tags %>
27+ <%= javascript_include_tag "https://unpkg.com/ahoy.js@0.4.2/dist/ahoy.js" %>
2728 </head>
2829
2930 <body>
@@ -38,6 +39,11 @@
3839 <li class="nav-item">
3940 <%= link_to "Built with Rails", built_with_rails_path, class: "nav-link" %>
4041 </li>
42+ <% if admin_user? %>
43+ <li class="nav-item">
44+ <%= link_to "Analytics", "/blazer", class: "nav-link text-warning" %>
45+ </li>
46+ <% end %>
4147 <li class="nav-item">
4248 <% if authenticated? %>
4349 <%= button_to "Sign Out", session_path, method: :delete,
@@ -0,0 +1,79 @@
1+# see https://github.com/ankane/blazer for more info
2+
3+data_sources:
4+ main:
5+ url: <%= Rails.env.production? ? "sqlite3:/rails/storage/production.sqlite3" : "sqlite3:storage/development.sqlite3" %>
6+
7+ # statement timeout, in seconds
8+ # none by default
9+ # timeout: 15
10+
11+ # caching settings
12+ # can greatly improve speed
13+ # off by default
14+ # cache:
15+ # mode: slow # or all
16+ # expires_in: 60 # min
17+ # slow_threshold: 15 # sec, only used in slow mode
18+
19+ # wrap queries in a transaction for safety
20+ # not necessary if you use a read-only user
21+ # true by default
22+ # use_transaction: false
23+
24+ smart_variables:
25+ # zone_id: "SELECT id, name FROM zones ORDER BY name ASC"
26+ # period: ["day", "week", "month"]
27+ # status: {0: "Active", 1: "Archived"}
28+
29+ linked_columns:
30+ # user_id: "/admin/users/{value}"
31+
32+ smart_columns:
33+ # user_id: "SELECT id, name FROM users WHERE id IN {value}"
34+
35+# create audits
36+audit: true
37+
38+# change the time zone
39+# time_zone: "Pacific Time (US & Canada)"
40+
41+# class name of the user model
42+# user_class: User
43+
44+# method name for the current user
45+# user_method: current_user
46+
47+# method name for the display name
48+# user_name: name
49+
50+# custom before_action to use for auth
51+before_action_method: require_admin
52+
53+# email to send checks from
54+# from_email: blazer@example.org
55+
56+# webhook for Slack
57+# slack_webhook_url: <%= ENV["BLAZER_SLACK_WEBHOOK_URL"] %>
58+
59+check_schedules:
60+ - "1 day"
61+ - "1 hour"
62+ - "5 minutes"
63+
64+# enable anomaly detection
65+# note: with trend, time series are sent to https://trendapi.org
66+# anomaly_checks: prophet / trend / anomaly_detection
67+
68+# enable forecasting
69+# note: with trend, time series are sent to https://trendapi.org
70+# forecasting: prophet / trend
71+
72+# enable map
73+# mapbox_access_token: <%= ENV["MAPBOX_ACCESS_TOKEN"] %>
74+
75+# enable uploads
76+# uploads:
77+# url: <%= ENV["BLAZER_UPLOADS_URL"] %>
78+# schema: uploads
79+# data_source: main
@@ -0,0 +1,7 @@
1+# Admin configuration
2+Rails.application.configure do
3+ # List of user IDs that should have access to Blazer analytics
4+ config.admin_user_ids = [
5+ 1 # Add actual admin user IDs here
6+ ]
7+end
@@ -0,0 +1,10 @@
1+class Ahoy::Store < Ahoy::DatabaseStore
2+end
3+
4+# set to true for JavaScript tracking
5+Ahoy.api = false
6+
7+# set to true for geocoding (and add the geocoder gem to your Gemfile)
8+# we recommend configuring local geocoding as well
9+# see https://github.com/ankane/ahoy#geocoding
10+Ahoy.geocode = false
@@ -1,3 +1,5 @@
1+require "blazer"
2+
13 Rails.application.routes.draw do
24 resource :session do
35 member do
@@ -13,6 +15,9 @@ Rails.application.routes.draw do
1315 post "google-signin", to: "sessions#create_for_google", as: :google_signin
1416 # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
1517
18+ # Analytics dashboard - protected route
19+ mount Blazer::Engine, at: "blazer"
20+
1621 # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
1722 # Can be used by load balancers and uptime monitors to verify that the app is live.
1823 get "up" => "rails/health#show", as: :rails_health_check
@@ -0,0 +1,61 @@
1+class CreateAhoyVisitsAndEvents < ActiveRecord::Migration[8.0]
2+ def change
3+ create_table :ahoy_visits do |t|
4+ t.string :visit_token
5+ t.string :visitor_token
6+
7+ # the rest are recommended but optional
8+ # simply remove any you don't want
9+
10+ # user
11+ t.references :user
12+
13+ # standard
14+ t.string :ip
15+ t.text :user_agent
16+ t.text :referrer
17+ t.string :referring_domain
18+ t.text :landing_page
19+
20+ # technology
21+ t.string :browser
22+ t.string :os
23+ t.string :device_type
24+
25+ # location
26+ t.string :country
27+ t.string :region
28+ t.string :city
29+ t.float :latitude
30+ t.float :longitude
31+
32+ # utm parameters
33+ t.string :utm_source
34+ t.string :utm_medium
35+ t.string :utm_term
36+ t.string :utm_content
37+ t.string :utm_campaign
38+
39+ # native apps
40+ t.string :app_version
41+ t.string :os_version
42+ t.string :platform
43+
44+ t.datetime :started_at
45+ end
46+
47+ add_index :ahoy_visits, :visit_token, unique: true
48+ add_index :ahoy_visits, [ :visitor_token, :started_at ]
49+
50+ create_table :ahoy_events do |t|
51+ t.references :visit
52+ t.references :user
53+
54+ t.string :name
55+ t.text :properties
56+ t.datetime :time
57+ end
58+
59+ add_index :ahoy_events, [ :name, :time ]
60+ end
61+end
@@ -0,0 +1,47 @@
1+class InstallBlazer < ActiveRecord::Migration[8.0]
2+ def change
3+ create_table :blazer_queries do |t|
4+ t.references :creator
5+ t.string :name
6+ t.text :description
7+ t.text :statement
8+ t.string :data_source
9+ t.string :status
10+ t.timestamps null: false
11+ end
12+
13+ create_table :blazer_audits do |t|
14+ t.references :user
15+ t.references :query
16+ t.text :statement
17+ t.string :data_source
18+ t.datetime :created_at
19+ end
20+
21+ create_table :blazer_dashboards do |t|
22+ t.references :creator
23+ t.string :name
24+ t.timestamps null: false
25+ end
26+
27+ create_table :blazer_dashboard_queries do |t|
28+ t.references :dashboard
29+ t.references :query
30+ t.integer :position
31+ t.timestamps null: false
32+ end
33+
34+ create_table :blazer_checks do |t|
35+ t.references :creator
36+ t.references :query
37+ t.string :state
38+ t.string :schedule
39+ t.text :emails
40+ t.text :slack_channels
41+ t.string :check_type
42+ t.text :message
43+ t.datetime :last_run_at
44+ t.timestamps null: false
45+ end
46+ end
47+end
@@ -10,7 +10,105 @@
1010 #
1111 # It's strongly recommended that you check this file into your version control system.
1212
13-ActiveRecord::Schema[8.0].define(version: 2025_09_29_043752) do
13+ActiveRecord::Schema[8.0].define(version: 2025_09_30_004923) do
14+ create_table "ahoy_events", force: :cascade do |t|
15+ t.integer "visit_id"
16+ t.integer "user_id"
17+ t.string "name"
18+ t.text "properties"
19+ t.datetime "time"
20+ t.index ["name", "time"], name: "index_ahoy_events_on_name_and_time"
21+ t.index ["user_id"], name: "index_ahoy_events_on_user_id"
22+ t.index ["visit_id"], name: "index_ahoy_events_on_visit_id"
23+ end
24+
25+ create_table "ahoy_visits", force: :cascade do |t|
26+ t.string "visit_token"
27+ t.string "visitor_token"
28+ t.integer "user_id"
29+ t.string "ip"
30+ t.text "user_agent"
31+ t.text "referrer"
32+ t.string "referring_domain"
33+ t.text "landing_page"
34+ t.string "browser"
35+ t.string "os"
36+ t.string "device_type"
37+ t.string "country"
38+ t.string "region"
39+ t.string "city"
40+ t.float "latitude"
41+ t.float "longitude"
42+ t.string "utm_source"
43+ t.string "utm_medium"
44+ t.string "utm_term"
45+ t.string "utm_content"
46+ t.string "utm_campaign"
47+ t.string "app_version"
48+ t.string "os_version"
49+ t.string "platform"
50+ t.datetime "started_at"
51+ t.index ["user_id"], name: "index_ahoy_visits_on_user_id"
52+ t.index ["visit_token"], name: "index_ahoy_visits_on_visit_token", unique: true
53+ t.index ["visitor_token", "started_at"], name: "index_ahoy_visits_on_visitor_token_and_started_at"
54+ end
55+
56+ create_table "blazer_audits", force: :cascade do |t|
57+ t.integer "user_id"
58+ t.integer "query_id"
59+ t.text "statement"
60+ t.string "data_source"
61+ t.datetime "created_at"
62+ t.index ["query_id"], name: "index_blazer_audits_on_query_id"
63+ t.index ["user_id"], name: "index_blazer_audits_on_user_id"
64+ end
65+
66+ create_table "blazer_checks", force: :cascade do |t|
67+ t.integer "creator_id"
68+ t.integer "query_id"
69+ t.string "state"
70+ t.string "schedule"
71+ t.text "emails"
72+ t.text "slack_channels"
73+ t.string "check_type"
74+ t.text "message"
75+ t.datetime "last_run_at"
76+ t.datetime "created_at", null: false
77+ t.datetime "updated_at", null: false
78+ t.index ["creator_id"], name: "index_blazer_checks_on_creator_id"
79+ t.index ["query_id"], name: "index_blazer_checks_on_query_id"
80+ end
81+
82+ create_table "blazer_dashboard_queries", force: :cascade do |t|
83+ t.integer "dashboard_id"
84+ t.integer "query_id"
85+ t.integer "position"
86+ t.datetime "created_at", null: false
87+ t.datetime "updated_at", null: false
88+ t.index ["dashboard_id"], name: "index_blazer_dashboard_queries_on_dashboard_id"
89+ t.index ["query_id"], name: "index_blazer_dashboard_queries_on_query_id"
90+ end
91+
92+ create_table "blazer_dashboards", force: :cascade do |t|
93+ t.integer "creator_id"
94+ t.string "name"
95+ t.datetime "created_at", null: false
96+ t.datetime "updated_at", null: false
97+ t.index ["creator_id"], name: "index_blazer_dashboards_on_creator_id"
98+ end
99+
100+ create_table "blazer_queries", force: :cascade do |t|
101+ t.integer "creator_id"
102+ t.string "name"
103+ t.text "description"
104+ t.text "statement"
105+ t.string "data_source"
106+ t.string "status"
107+ t.datetime "created_at", null: false
108+ t.datetime "updated_at", null: false
109+ t.index ["creator_id"], name: "index_blazer_queries_on_creator_id"
110+ end
111+
14112 create_table "posts", force: :cascade do |t|
15113 t.string "title", null: false
16114 t.string "slug", null: false
@@ -42,12 +42,12 @@ posts_data = [
4242 },
4343 {
4444 title: "Ahoy & Blazer analytics integration",
45- slug: "ahoy-blazer-analytics",
46- summary: "Adding user analytics and business intelligence to Rails apps with Ahoy and Blazer",
47- read_time: "6 min read",
48- youtube_slug: nil,
49- published_date: Date.new(2025, 12, 1),
50- status: "draft"
45+ slug: "ahoy-blazer-analytics-claude-voice-cli",
46+ summary: "Adding user analytics and business intelligence to Rails apps with Ahoy and Blazer - implemented with Claude Code voice control",
47+ read_time: "8 min read",
48+ youtube_slug: "VT0iyi8y8Q4",
49+ published_date: Date.new(2025, 9, 30),
50+ status: "published"
5151 }
5252 ]
5353
@@ -64,4 +64,69 @@ posts_data.each do |post_attrs|
6464 end
6565 end
6666
67+# Blazer queries for analytics
68+puts "Creating Blazer queries..."
69+
70+blazer_queries = [
71+ {
72+ name: "Referrals by Domain",
73+ description: "Track website visits grouped by referring domain",
74+ statement: <<~SQL
75+ SELECT#{' '}
76+ referring_domain,
77+ COUNT(*) as visits,
78+ COUNT(DISTINCT visitor_token) as unique_visitors,
79+ DATE(started_at) as visit_date
80+ FROM ahoy_visits#{' '}
81+ WHERE referring_domain IS NOT NULL#{' '}
82+ AND started_at >= date('now', '-30 days')
83+ GROUP BY referring_domain, DATE(started_at)
84+ ORDER BY visits DESC, visit_date DESC
85+ LIMIT 50
86+ SQL
87+ },
88+ {
89+ name: "Visits by Landing Page",
90+ description: "Track website visits grouped by landing page URL",
91+ statement: <<~SQL
92+ SELECT#{' '}
93+ landing_page,
94+ COUNT(*) as visits,
95+ COUNT(DISTINCT visitor_token) as unique_visitors,
96+ DATE(started_at) as visit_date
97+ FROM ahoy_visits#{' '}
98+ WHERE started_at >= date('now', '-30 days')
99+ GROUP BY landing_page, DATE(started_at)
100+ ORDER BY visits DESC, visit_date DESC
101+ LIMIT 50
102+ SQL
103+ },
104+ {
105+ name: "New Users by Day",
106+ description: "Track new user registrations grouped by day",
107+ statement: <<~SQL
108+ SELECT#{' '}
109+ DATE(created_at) as registration_date,
110+ COUNT(*) as new_users,
111+ COUNT(CASE WHEN google_user_id IS NOT NULL THEN 1 END) as google_signups,
112+ COUNT(CASE WHEN google_user_id IS NULL THEN 1 END) as email_signups
113+ FROM users#{' '}
114+ WHERE created_at >= date('now', '-30 days')
115+ GROUP BY DATE(created_at)
116+ ORDER BY registration_date DESC
117+ SQL
118+ }
119+]
120+
121+blazer_queries.each do |query_attrs|
122+ query = Blazer::Query.find_or_initialize_by(name: query_attrs[:name])
123+ query.assign_attributes(query_attrs.merge(data_source: "main"))
124+
125+ if query.save
126+ puts "โœ“ Created/Updated Blazer query: #{query.name}"
127+ else
128+ puts "โœ— Error creating query #{query_attrs[:name]}: #{query.errors.full_messages.join(', ')}"
129+ end
130+end
131+
67132 puts "Seed data loaded successfully!"
@@ -0,0 +1,88 @@
1+require 'rails_helper'
2+
3+RSpec.describe "Blazer Authentication", type: :request do
4+ let(:admin_user) { User.create!(email_address: "admin@example.com") }
5+ let(:regular_user) { User.create!(email_address: "user@example.com") }
6+ let(:admin_session) { Session.create!(user: admin_user) }
7+ let(:regular_session) { Session.create!(user: regular_user) }
8+
9+ before do
10+ # Set up admin user IDs in config
11+ Rails.application.config.admin_user_ids = [ admin_user.id ]
12+ end
13+
14+
15+ describe "accessing blazer routes" do
16+ context "without authentication" do
17+ it "denies access to blazer dashboard" do
18+ get "/blazer"
19+ expect(response).to have_http_status(403)
20+ expect(response.body).to include("Access denied - Admin privileges required")
21+ end
22+
23+ it "denies access to blazer queries" do
24+ get "/blazer/queries"
25+ expect(response).to have_http_status(403)
26+ expect(response.body).to include("Access denied - Admin privileges required")
27+ end
28+ end
29+
30+ context "with regular user authentication" do
31+ let(:signed_cookie_value) do
32+ Rails.application.message_verifier("signed cookie").generate(regular_session.id)
33+ end
34+
35+ it "denies access to blazer dashboard for non-admin users" do
36+ get "/blazer", headers: { "Cookie" => "session_id=#{signed_cookie_value}" }
37+ expect(response).to have_http_status(403)
38+ expect(response.body).to include("Access denied - Admin privileges required")
39+ end
40+
41+ it "denies access to blazer queries for non-admin users" do
42+ get "/blazer/queries", headers: { "Cookie" => "session_id=#{signed_cookie_value}" }
43+ expect(response).to have_http_status(403)
44+ expect(response.body).to include("Access denied - Admin privileges required")
45+ end
46+ end
47+
48+ context "with admin user authentication" do
49+ let(:signed_cookie_value) do
50+ Rails.application.message_verifier("signed cookie").generate(admin_session.id)
51+ end
52+
53+ it "allows access to blazer dashboard for admin users" do
54+ get "/blazer", headers: { "Cookie" => "session_id=#{signed_cookie_value}" }
55+ expect(response).to have_http_status(200)
56+ end
57+
58+ it "allows access to blazer queries for admin users" do
59+ get "/blazer/queries", headers: { "Cookie" => "session_id=#{signed_cookie_value}" }
60+ # Blazer queries page redirects to queries list, which is expected behavior
61+ expect([ 200, 302 ]).to include(response.status)
62+ end
63+ end
64+
65+ context "with invalid session" do
66+ let(:signed_cookie_value) do
67+ Rails.application.message_verifier("signed cookie").generate("invalid_session_id")
68+ end
69+
70+ it "denies access with invalid session" do
71+ get "/blazer", headers: { "Cookie" => "session_id=#{signed_cookie_value}" }
72+ expect(response).to have_http_status(403)
73+ expect(response.body).to include("Access denied - Admin privileges required")
74+ end
75+ end
76+
77+ context "with deleted session" do
78+ it "denies access when session is deleted" do
79+ session = Session.create!(user: admin_user)
80+ signed_cookie_value = Rails.application.message_verifier("signed cookie").generate(session.id)
81+ session.destroy
82+ get "/blazer", headers: { "Cookie" => "session_id=#{signed_cookie_value}" }
83+ expect(response).to have_http_status(403)
84+ expect(response.body).to include("Access denied - Admin privileges required")
85+ end
86+ end
87+ end
88+end