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