diff --git a/.env.example b/.env.example
index f543d4bf..a2bf7bce 100644
--- a/.env.example
+++ b/.env.example
@@ -58,4 +58,8 @@ MAIL_HACKCLUB_TOKEN=replace_me
# Hack Club Account
HCA_CLIENT_ID=your_hackclub_account_client_id_here
-HCA_CLIENT_SECRET=your_hackclub_account_secret_id_here
\ No newline at end of file
+HCA_CLIENT_SECRET=your_hackclub_account_secret_id_here
+
+# PostHog Analytics
+POSTHOG_API_KEY=your_posthog_api_key_here
+POSTHOG_HOST=https://us.i.posthog.com
\ No newline at end of file
diff --git a/Gemfile b/Gemfile
index fd10c40d..998082f8 100644
--- a/Gemfile
+++ b/Gemfile
@@ -86,6 +86,9 @@ gem "flamegraph"
gem "skylight"
+# Analytics
+gem "posthog-ruby"
+
gem "geocoder"
# Airtable syncing
diff --git a/Gemfile.lock b/Gemfile.lock
index 59c2756c..feb22eb3 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -337,6 +337,8 @@ GEM
pg (1.6.3-arm64-darwin)
pg (1.6.3-x86_64-linux)
pg (1.6.3-x86_64-linux-musl)
+ posthog-ruby (3.4.0)
+ concurrent-ruby (~> 1)
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
@@ -636,6 +638,7 @@ DEPENDENCIES
oj
paper_trail
pg
+ posthog-ruby
propshaft
public_activity
puma (>= 5.0)
diff --git a/app/controllers/api/hackatime/v1/hackatime_controller.rb b/app/controllers/api/hackatime/v1/hackatime_controller.rb
index 17872da8..1dfb1614 100644
--- a/app/controllers/api/hackatime/v1/hackatime_controller.rb
+++ b/app/controllers/api/hackatime/v1/hackatime_controller.rb
@@ -256,6 +256,8 @@ def handle_heartbeat(heartbeat_array)
Rails.logger.error("Error creating heartbeat: #{e.class.name} #{e.message}")
results << [ { error: e.message, type: e.class.name }, 422 ]
end
+
+ PosthogService.capture_once_per_day(@user, "heartbeat_sent", { heartbeat_count: heartbeat_array.size })
results
end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 27e5792e..489174d4 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -33,6 +33,9 @@ def hca_create
MigrateUserFromHackatimeJob.perform_later(@user.id)
end
+ PosthogService.identify(@user)
+ PosthogService.capture(@user, "user_signed_in", { method: "hca" })
+
if @user.created_at > 5.seconds.ago
session[:return_data] = { "url" => safe_return_url(params[:continue].presence) }
Rails.logger.info("Sessions return data: #{session[:return_data]}")
@@ -81,6 +84,9 @@ def slack_create
MigrateUserFromHackatimeJob.perform_later(@user.id)
end
+ PosthogService.identify(@user)
+ PosthogService.capture(@user, "user_signed_in", { method: "slack" })
+
state = JSON.parse(params[:state]) rescue {}
if state["close_window"]
redirect_to close_window_path
@@ -132,6 +138,7 @@ def github_create
@user = User.from_github_token(params[:code], redirect_uri, current_user)
if @user&.persisted?
+ PosthogService.capture(@user, "github_linked")
redirect_to my_settings_path, notice: "Successfully linked GitHub account!"
else
Rails.logger.error "Failed to link GitHub account"
@@ -250,6 +257,10 @@ def token
session[:user_id] = valid_token.user_id
session[:return_data] = valid_token.return_data || {}
+ user = User.find(valid_token.user_id)
+ PosthogService.identify(user)
+ PosthogService.capture(user, "user_signed_in", { method: "email" })
+
if valid_token.continue_param.present? && safe_return_url(valid_token.continue_param).present?
redirect_to safe_return_url(valid_token.continue_param), notice: "Successfully signed in!"
else
@@ -293,6 +304,7 @@ def stop_impersonating
end
def destroy
+ PosthogService.capture(session[:user_id], "user_signed_out") if session[:user_id]
session[:user_id] = nil
session[:impersonater_user_id] = nil
redirect_to root_path, notice: "Signed out!"
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index b9a2487f..9d6f7abb 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -18,6 +18,7 @@ def update
if @user.uses_slack_status?
@user.update_slack_status
end
+ PosthogService.capture(@user, "settings_updated", { fields: user_params.keys })
redirect_to is_own_settings? ? my_settings_path : settings_user_path(@user),
notice: "Settings updated successfully"
else
@@ -44,6 +45,7 @@ def rotate_api_key
new_api_key = @user.api_keys.create!(name: "Hackatime key")
+ PosthogService.capture(@user, "api_key_rotated")
render json: { token: new_api_key.token }, status: :ok
end
rescue => e
@@ -54,6 +56,12 @@ def rotate_api_key
def wakatime_setup
api_key = current_user&.api_keys&.last
api_key ||= current_user.api_keys.create!(name: "Wakatime API Key")
+ @current_user_api_key = api_key&.token
+ PosthogService.capture(current_user, "setup_started", { step: 1 })
+ end
+
+ def wakatime_setup_step_2
+ PosthogService.capture(current_user, "setup_step_viewed", { step: 2 })
setup_os = detect_setup_os(request.user_agent)
render inertia: "WakatimeSetup/Index", props: {
@@ -71,6 +79,20 @@ def wakatime_setup_step_2
def wakatime_setup_step_3
api_key = current_user&.api_keys&.last
api_key ||= current_user.api_keys.create!(name: "Wakatime API Key")
+
+ @current_user_api_key = api_key&.token
+ PosthogService.capture(current_user, "setup_step_viewed", { step: 3 })
+ end
+
+ def wakatime_setup_step_4
+ @no_instruction_wording = [
+ "There is no step 4, lol.",
+ "There is no step 4, psych!",
+ "Tricked ya! There is no step 4.",
+ "There is no step 4, gotcha!"
+ ].sample
+ PosthogService.capture(current_user, "setup_completed", { step: 4 })
+
editor = params[:editor]
render inertia: "WakatimeSetup/Step3", props: {
diff --git a/app/models/user.rb b/app/models/user.rb
index a0044cad..17d6da5c 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -307,6 +307,8 @@ def invalidate_activity_graph_cache
def create_signup_activity
create_activity :first_signup, owner: self
+ PosthogService.identify(self)
+ PosthogService.capture(self, "account_created", { source: "signup" })
end
def normalize_username
diff --git a/app/services/posthog_service.rb b/app/services/posthog_service.rb
new file mode 100644
index 00000000..78d8db13
--- /dev/null
+++ b/app/services/posthog_service.rb
@@ -0,0 +1,47 @@
+class PosthogService
+ class << self
+ def capture(user_or_id, event, properties = {})
+ return unless $posthog
+
+ distinct_id = user_or_id.is_a?(User) ? user_or_id.id.to_s : user_or_id.to_s
+
+ $posthog.capture(
+ distinct_id: distinct_id,
+ event: event,
+ properties: properties
+ )
+ rescue => e
+ Rails.logger.error "PostHog capture error: #{e.message}"
+ end
+
+ def identify(user, properties = {})
+ return unless $posthog
+
+ $posthog.identify(
+ distinct_id: user.id.to_s,
+ properties: {
+ slack_uid: user.slack_uid,
+ username: user.username,
+ timezone: user.timezone,
+ country_code: user.country_code,
+ created_at: user.created_at&.iso8601,
+ admin_level: user.admin_level
+ }.merge(properties)
+ )
+ rescue => e
+ Rails.logger.error "PostHog identify error: #{e.message}"
+ end
+
+ def capture_once_per_day(user, event, properties = {})
+ return unless $posthog
+
+ cache_key = "posthog_daily:#{user.id}:#{event}:#{Date.current}"
+ return if Rails.cache.exist?(cache_key)
+
+ capture(user, event, properties)
+ Rails.cache.write(cache_key, true, expires_at: Date.current.end_of_day + 1.hour)
+ rescue => e
+ Rails.logger.error "PostHog capture_once_per_day error: #{e.message}"
+ end
+ end
+end
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 35d59f6f..c69d4ba7 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -161,6 +161,26 @@
+ <% if ENV['POSTHOG_API_KEY'].present? %>
+
+ <% end %>
+
<%# Includes all stylesheet files in app/assets/stylesheets %>
<%= stylesheet_link_tag :app %>
<%= stylesheet_link_tag 'tailwind', 'data-turbo-track': 'reload' %>
diff --git a/config/initializers/posthog.rb b/config/initializers/posthog.rb
new file mode 100644
index 00000000..174ee733
--- /dev/null
+++ b/config/initializers/posthog.rb
@@ -0,0 +1,11 @@
+require "posthog"
+
+if ENV["POSTHOG_API_KEY"].present?
+ $posthog = PostHog::Client.new({
+ api_key: ENV["POSTHOG_API_KEY"],
+ host: ENV.fetch("POSTHOG_HOST", "https://us.i.posthog.com"),
+ on_error: proc { |status, msg| Rails.logger.error "PostHog error: #{status} - #{msg}" }
+ })
+else
+ $posthog = nil
+end