From ab1aaea4374975c5702f483f054dec6513282a00 Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Thu, 5 Feb 2026 11:08:49 +0000 Subject: [PATCH 1/4] Add PostHog --- .env.example | 6 ++- Gemfile | 3 ++ Gemfile.lock | 3 ++ .../api/hackatime/v1/hackatime_controller.rb | 2 + app/controllers/sessions_controller.rb | 12 +++++ app/controllers/users_controller.rb | 6 +++ app/models/user.rb | 2 + app/services/posthog_service.rb | 47 +++++++++++++++++++ app/views/layouts/application.html.erb | 20 ++++++++ config/initializers/posthog.rb | 11 +++++ 10 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 app/services/posthog_service.rb create mode 100644 config/initializers/posthog.rb diff --git a/.env.example b/.env.example index f543d4bf2..a2bf7bce6 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 494533911..03b4ceac4 100644 --- a/Gemfile +++ b/Gemfile @@ -86,6 +86,9 @@ gem "flamegraph" gem "skylight" +# Analytics +gem "posthog-ruby" + gem "geocoder" gem "pagy", "~> 43.2" diff --git a/Gemfile.lock b/Gemfile.lock index fad180a2b..f8eceda89 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -334,6 +334,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) @@ -622,6 +624,7 @@ DEPENDENCIES pagy (~> 43.2) 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 3aab523cd..bd37ee4ae 100644 --- a/app/controllers/api/hackatime/v1/hackatime_controller.rb +++ b/app/controllers/api/hackatime/v1/hackatime_controller.rb @@ -249,6 +249,8 @@ def body_to_json end def handle_heartbeat(heartbeat_array) + PosthogService.capture_once_per_day(@user, "heartbeat_sent", { heartbeat_count: heartbeat_array.size }) + results = [] heartbeat_array.each do |heartbeat| source_type = :direct_entry diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 39a8637d2..7efb2bb3d 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: params[:continue].presence || request.referer } Rails.logger.info("Sessions return data: #{session[:return_data]}") @@ -78,6 +81,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 @@ -126,6 +132,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" @@ -243,6 +250,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? redirect_to valid_token.continue_param, notice: "Successfully signed in!" else @@ -286,6 +297,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 271d419ec..31c1ce99a 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -16,6 +16,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 @@ -42,6 +43,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 @@ -53,15 +55,18 @@ 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 }) end 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 @@ -71,6 +76,7 @@ def wakatime_setup_step_4 "Tricked ya! There is no step 4.", "There is no step 4, gotcha!" ].sample + PosthogService.capture(current_user, "setup_completed", { step: 4 }) end def update_trust_level diff --git a/app/models/user.rb b/app/models/user.rb index 73d655142..6f2a555af 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -628,6 +628,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 000000000..5ad067221 --- /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_in: 25.hours) + 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 2a2e27289..22c7a8034 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -156,6 +156,26 @@ + <% if ENV['POSTHOG_API_KEY'].present? %> + + <% end %> + <%# Includes all stylesheet files in app/assets/stylesheets %> <%= stylesheet_link_tag :app %> <%= javascript_importmap_tags %> diff --git a/config/initializers/posthog.rb b/config/initializers/posthog.rb new file mode 100644 index 000000000..174ee733a --- /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 From 725acc6770ae5811b36a1edb2c14784c870802ec Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Thu, 5 Feb 2026 11:31:37 +0000 Subject: [PATCH 2/4] Queue Posthog *after* adding to DB --- app/controllers/api/hackatime/v1/hackatime_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/hackatime/v1/hackatime_controller.rb b/app/controllers/api/hackatime/v1/hackatime_controller.rb index bd37ee4ae..cc5f87994 100644 --- a/app/controllers/api/hackatime/v1/hackatime_controller.rb +++ b/app/controllers/api/hackatime/v1/hackatime_controller.rb @@ -249,8 +249,6 @@ def body_to_json end def handle_heartbeat(heartbeat_array) - PosthogService.capture_once_per_day(@user, "heartbeat_sent", { heartbeat_count: heartbeat_array.size }) - results = [] heartbeat_array.each do |heartbeat| source_type = :direct_entry @@ -289,6 +287,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 From 64ea39b15e49fc117d0aa2501cd524b3cddf3cb7 Mon Sep 17 00:00:00 2001 From: Mahad Kalam <55807755+skyfallwastaken@users.noreply.github.com> Date: Thu, 5 Feb 2026 12:14:13 +0000 Subject: [PATCH 3/4] Update app/services/posthog_service.rb Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/services/posthog_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/posthog_service.rb b/app/services/posthog_service.rb index 5ad067221..78d8db138 100644 --- a/app/services/posthog_service.rb +++ b/app/services/posthog_service.rb @@ -39,7 +39,7 @@ def capture_once_per_day(user, event, properties = {}) return if Rails.cache.exist?(cache_key) capture(user, event, properties) - Rails.cache.write(cache_key, true, expires_in: 25.hours) + 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 From bfd7787b5784829c52a5d1da8ce3d623eac5bda6 Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Thu, 5 Feb 2026 18:19:19 +0000 Subject: [PATCH 4/4] Add /api/v1/banned_users/counts --- app/controllers/api/v1/stats_controller.rb | 29 ++++++++++++++++++++++ config/routes.rb | 2 ++ 2 files changed, 31 insertions(+) diff --git a/app/controllers/api/v1/stats_controller.rb b/app/controllers/api/v1/stats_controller.rb index 4055aca62..6c37c91ec 100644 --- a/app/controllers/api/v1/stats_controller.rb +++ b/app/controllers/api/v1/stats_controller.rb @@ -167,6 +167,35 @@ def trust_factor render json: { trust_level: level, trust_value: User.trust_levels[level] } end + def banned_users_counts + now = Time.current + + day_ago = now - 1.day + week_ago = now - 1.week + month_ago = now - 1.month + + day_count = TrustLevelAuditLog.where(new_trust_level: "red") + .where("created_at >= ?", day_ago) + .distinct + .count(:user_id) + + week_count = TrustLevelAuditLog.where(new_trust_level: "red") + .where("created_at >= ?", week_ago) + .distinct + .count(:user_id) + + month_count = TrustLevelAuditLog.where(new_trust_level: "red") + .where("created_at >= ?", month_ago) + .distinct + .count(:user_id) + + render json: { + day: day_count, + week: week_count, + month: month_count + } + end + def user_projects return render json: { error: "User not found" }, status: :not_found unless @user diff --git a/config/routes.rb b/config/routes.rb index 1b207a01f..d32fc3095 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -182,6 +182,8 @@ def matches?(request) get "users/lookup_email/:email", to: "users#lookup_email", constraints: { email: /[^\/]+/ } get "users/lookup_slack_uid/:slack_uid", to: "users#lookup_slack_uid" + get "banned_users/counts", to: "stats#banned_users_counts" + # External service Slack OAuth integration post "external/slack/oauth", to: "external_slack#create_user"