Switch from hackatime heartbeats to native heartbeats

This commit is contained in:
Max Wofford
2025-03-05 23:24:30 -05:00
parent 9ff787bb84
commit 86eb4298c5
13 changed files with 142 additions and 137 deletions

View File

@@ -28,11 +28,9 @@ class StaticPagesController < ApplicationController
def activity_graph
return unless current_user
@daily_durations = Hackatime::Heartbeat
.where(user_id: current_user.slack_uid, time: 365.days.ago..)
.group(Arel.sql("DATE_TRUNC('day', time)"))
.duration_seconds
.transform_keys(&:to_date)
@daily_durations = Rails.cache.fetch("user_#{current_user.id}_daily_durations", expires_in: 1.minute) do
current_user.heartbeats.daily_durations.to_h
end
# Consider 8 hours as a "full" day of coding
@length_of_busiest_day = 8.hours.to_i # 28800 seconds

View File

@@ -21,7 +21,7 @@ class LeaderboardUpdateJob < ApplicationJob
ActiveRecord::Base.transaction do
valid_user_ids.each_slice(BATCH_SIZE) do |batch_user_ids|
entries_data = Hackatime::Heartbeat.where(user_id: batch_user_ids)
entries_data = Hackatime.where(user_id: batch_user_ids)
.where(time: parsed_date.all_day)
.group(:user_id)
.duration_seconds

View File

@@ -4,7 +4,7 @@ class SailorsLogPollForChangesJob < ApplicationJob
def perform
puts "performing SailorsLogPollForChangesJob"
# get all users who've coded in the last minute
users_who_coded = Hackatime::Heartbeat.where("created_at > ?", 1.minutes.ago)
users_who_coded = Hackatime.where("created_at > ?", 1.minutes.ago)
.where(time: 1.minutes.ago..)
.distinct.pluck(:user_id)
@@ -21,7 +21,7 @@ class SailorsLogPollForChangesJob < ApplicationJob
logs.each do |log|
# get all projects for the user with duration
new_project_times = Hackatime::Heartbeat.where(user_id: log.slack_uid)
new_project_times = Hackatime.where(user_id: log.slack_uid)
.group(:project)
.duration_seconds

View File

@@ -0,0 +1,86 @@
module Heartbeatable
extend ActiveSupport::Concern
class_methods do
def heartbeat_timeout_duration(duration = nil)
if duration
@heartbeat_timeout_duration = duration
else
@heartbeat_timeout_duration || 2.minutes
end
end
def duration_formatted(scope = all)
seconds = duration_seconds(scope)
hours = seconds / 3600
minutes = (seconds % 3600) / 60
remaining_seconds = seconds % 60
format("%02d:%02d:%02d", hours, minutes, remaining_seconds)
end
def duration_simple(scope = all)
# 3 hours 10 min => "3 hrs"
# 1 hour 10 min => "1 hr"
# 10 min => "10 min"
seconds = duration_seconds(scope)
hours = seconds / 3600
minutes = (seconds % 3600) / 60
if hours > 1
"#{hours} hrs"
elsif hours == 1
"1 hr"
elsif minutes > 0
"#{minutes} min"
else
"0 min"
end
end
def daily_durations(start_date: 365.days.ago, end_date: Time.current)
select(Arel.sql("DATE_TRUNC('day', to_timestamp(time)) as day_group"))
.where(time: start_date..end_date)
.group(Arel.sql("DATE_TRUNC('day', to_timestamp(time))"))
.duration_seconds
.map { |date, duration| [ date.to_date, duration ] }
end
def duration_seconds(scope = all)
if scope.group_values.any?
group_column = scope.group_values.first
# Don't quote if it's a SQL function (contains parentheses)
group_expr = group_column.to_s.include?("(") ? group_column : connection.quote_column_name(group_column)
capped_diffs = scope
.select("#{group_expr} as grouped_time, CASE
WHEN LAG(time) OVER (PARTITION BY #{group_expr} ORDER BY time) IS NULL THEN 0
ELSE LEAST(EXTRACT(EPOCH FROM (to_timestamp(time) - to_timestamp(LAG(time) OVER (PARTITION BY #{group_expr} ORDER BY time)))), #{heartbeat_timeout_duration.to_i})
END as diff")
.where.not(time: nil)
.order(time: :asc)
.unscope(:group)
connection.select_all(
"SELECT grouped_time, COALESCE(SUM(diff), 0)::integer as duration
FROM (#{capped_diffs.to_sql}) AS diffs
GROUP BY grouped_time"
).each_with_object({}) do |row, hash|
hash[row["grouped_time"]] = row["duration"].to_i
end
else
# when not grouped, return a single value
capped_diffs = scope
.select("CASE
WHEN LAG(time) OVER (ORDER BY time) IS NULL THEN 0
ELSE LEAST(EXTRACT(EPOCH FROM (to_timestamp(time) - to_timestamp(LAG(time) OVER (ORDER BY time)))), #{heartbeat_timeout_duration.to_i})
END as diff")
.where.not(time: nil)
.order(time: :asc)
connection.select_value("SELECT COALESCE(SUM(diff), 0)::integer FROM (#{capped_diffs.to_sql}) AS diffs").to_i
end
end
end
end

View File

@@ -1,5 +1,7 @@
class Hackatime::Heartbeat < HackatimeRecord
TIMEOUT_DURATION = 2.minutes
include Heartbeatable
heartbeat_timeout_duration 2.minutes
def self.cached_recent_count
Rails.cache.fetch("heartbeats_recent_count", expires_in: 5.minutes) do
@@ -14,77 +16,4 @@ class Hackatime::Heartbeat < HackatimeRecord
self.inheritance_column = nil
# Prevent collision with Ruby's hash method
self.ignored_columns += [ "hash" ]
def self.duration_seconds(scope = all)
if scope.group_values.any?
group_column = scope.group_values.first
# Don't quote if it's a SQL function (contains parentheses)
group_expr = group_column.to_s.include?("(") ? group_column : connection.quote_column_name(group_column)
capped_diffs = scope
.select("#{group_expr} as grouped_time, CASE
WHEN LAG(time) OVER (PARTITION BY #{group_expr} ORDER BY time) IS NULL THEN 0
ELSE LEAST(EXTRACT(EPOCH FROM (time - LAG(time) OVER (PARTITION BY #{group_expr} ORDER BY time))), #{TIMEOUT_DURATION.to_i})
END as diff")
.where.not(time: nil)
.order(time: :asc)
.unscope(:group)
connection.select_all(
"SELECT grouped_time, COALESCE(SUM(diff), 0)::integer as duration
FROM (#{capped_diffs.to_sql}) AS diffs
GROUP BY grouped_time"
).each_with_object({}) do |row, hash|
hash[row["grouped_time"]] = row["duration"].to_i
end
else
# when not grouped, return a single value
capped_diffs = scope
.select("CASE
WHEN LAG(time) OVER (ORDER BY time) IS NULL THEN 0
ELSE LEAST(EXTRACT(EPOCH FROM (time - LAG(time) OVER (ORDER BY time))), #{TIMEOUT_DURATION.to_i})
END as diff")
.where.not(time: nil)
.order(time: :asc)
connection.select_value("SELECT COALESCE(SUM(diff), 0)::integer FROM (#{capped_diffs.to_sql}) AS diffs").to_i
end
end
def self.duration_formatted(scope = all)
seconds = duration_seconds(scope)
hours = seconds / 3600
minutes = (seconds % 3600) / 60
remaining_seconds = seconds % 60
format("%02d:%02d:%02d", hours, minutes, remaining_seconds)
end
def self.duration_simple(scope = all)
# 3 hours 10 min => "3 hrs"
# 1 hour 10 min => "1 hr"
# 10 min => "10 min"
seconds = duration_seconds(scope)
hours = seconds / 3600
minutes = (seconds % 3600) / 60
if hours > 1
"#{hours} hrs"
elsif hours == 1
"1 hr"
elsif minutes > 0
"#{minutes} min"
else
"0 min"
end
end
def self.daily_durations(start_date: 365.days.ago, end_date: Time.current)
select(Arel.sql("DATE_TRUNC('day', time) as day_group"))
.where(time: start_date..end_date)
.group("day_group")
.duration_seconds
.map { |date, duration| [ date.to_date, duration ] }
end
end

View File

@@ -1,6 +1,12 @@
class Heartbeat < ApplicationRecord
before_save :set_fields_hash!
include Heartbeatable
heartbeat_timeout_duration 2.minutes
scope :today, -> { where(time: Time.current.beginning_of_day..Time.current.end_of_day) }
enum :source_type, {
direct_entry: 0,
wakapi_import: 1

View File

@@ -37,7 +37,7 @@ class SailorsLogLeaderboard < ApplicationRecord
.pluck(:slack_uid)
# Get all durations for users in channel
user_durations = Hackatime::Heartbeat.where(user_id: users_in_channel)
user_durations = Hackatime.where(user_id: users_in_channel)
.today
.group(:user_id)
.duration_seconds
@@ -47,7 +47,7 @@ class SailorsLogLeaderboard < ApplicationRecord
# Now get detailed project info only for top 10 users
top_user_ids.map do |user_id|
user_heartbeats = Hackatime::Heartbeat.where(user_id: user_id).today
user_heartbeats = Hackatime.where(user_id: user_id).today
# Get most common language per project using ActiveRecord
most_common_languages = user_heartbeats

View File

@@ -6,7 +6,9 @@ class User < ApplicationRecord
validates :slack_uid, presence: true, uniqueness: true
validates :username, presence: true
has_many :heartbeats,
has_many :heartbeats
has_many :hackatime_heartbeats,
foreign_key: :user_id,
primary_key: :slack_uid,
class_name: "Hackatime::Heartbeat"
@@ -67,8 +69,8 @@ class User < ApplicationRecord
current_project = heartbeats.order(time: :desc).first&.project
current_project_heartbeats = heartbeats.today.where(project: current_project)
current_project_duration = Hackatime::Heartbeat.duration_seconds(current_project_heartbeats)
current_project_duration_formatted = Hackatime::Heartbeat.duration_simple(current_project_heartbeats)
current_project_duration = Heartbeat.duration_seconds(current_project_heartbeats)
current_project_duration_formatted = Heartbeat.duration_simple(current_project_heartbeats)
# for 0 duration, don't set a status this will let status expire when the user has not been cooking today
return if current_project_duration.zero?

View File

@@ -34,7 +34,9 @@
<p>
Build <%= link_to Rails.application.config.git_version, Rails.application.config.commit_link %>
from <%= time_ago_in_words(Rails.application.config.server_start_time) %> ago.
<%= pluralize(Hackatime::Heartbeat.cached_recent_count, 'heartbeat') %> in the last 24 hours.
<%= pluralize(Heartbeat.where("created_at > ?", 24.hours.ago).count, 'heartbeat') %>
(<%= Heartbeat.where("created_at > ?", 24.hours.ago).where.not(source_type: :direct_entry).count %> imported)
in the last 24 hours.
(DB: <%= pluralize(QueryCount::Counter.counter, "query") %>, <%= QueryCount::Counter.counter_cache %> cached)
(CACHE: <%= cache_stats[:hits] %> hits, <%= cache_stats[:misses] %> misses)
</p>

View File

@@ -1,5 +1,5 @@
<%= turbo_frame_tag "activity_graph" do %>
<%= cache ["project_durations", current_user.id], expires_in: 5.hours do %>
<%= cache ["activity_graph", current_user.id], expires_in: 5.hours do %>
<div class="activity-graph">
<% (365.days.ago.to_date..Time.current.to_date).to_a.each do |date| %>
<% duration = daily_durations[date] || 0 %>

View File

@@ -22,6 +22,29 @@
601
],
"note": ""
},
{
"warning_type": "SQL Injection",
"warning_code": 0,
"fingerprint": "db41fbf90d0feb7b7c1c11545d498447162cf5e54e091d2ddcfcaba28b2513f6",
"check_name": "SQL",
"message": "Possible SQL injection",
"file": "app/jobs/one_time/generate_unique_heartbeat_hashes_job.rb",
"line": 32,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "Heartbeat.where(:id => chunk.map do\n index = 1\nputs(\"Processing heartbeat #{heartbeat.id} (#{1} of #{batch.size})\")\nfield_hash = Heartbeat.generate_fields_hash(heartbeat.attributes)\nputs(\"Field hash: #{Heartbeat.generate_fields_hash(heartbeat.attributes)}\")\n[heartbeat.id, Heartbeat.generate_fields_hash(heartbeat.attributes)]\n end.map(&:first)).update_all(\"fields_hash = CASE #{chunk.map do\n index = 1\nputs(\"Processing heartbeat #{heartbeat.id} (#{1} of #{batch.size})\")\nfield_hash = Heartbeat.generate_fields_hash(heartbeat.attributes)\nputs(\"Field hash: #{Heartbeat.generate_fields_hash(heartbeat.attributes)}\")\n[heartbeat.id, Heartbeat.generate_fields_hash(heartbeat.attributes)]\n end.map do\n \"WHEN id = #{id} THEN '#{hash}'\"\n end.join(\" \")} END\")",
"render_path": null,
"location": {
"type": "method",
"class": "OneTime::GenerateUniqueHeartbeatHashesJob",
"method": "perform"
},
"user_input": "Heartbeat.generate_fields_hash(heartbeat.attributes)",
"confidence": "High",
"cwe_id": [
89
],
"note": ""
}
],
"brakeman_version": "7.0.0"

View File

@@ -0,0 +1,5 @@
class ChangeHeartbeatsTimeToFloat8 < ActiveRecord::Migration[8.0]
def change
change_column :heartbeats, :time, :float8, null: false
end
end

50
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_03_05_195904) do
ActiveRecord::Schema[8.0].define(version: 2025_03_06_033109) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -139,7 +139,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_05_195904) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.text "fields_hash"
t.integer "source_type", default: 0, null: false
t.integer "source_type", null: false
t.index ["fields_hash"], name: "index_heartbeats_on_fields_hash", unique: true
t.index ["user_id"], name: "index_heartbeats_on_user_id"
end
@@ -163,19 +163,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_05_195904) do
t.datetime "deleted_at"
end
create_table "project_checks", force: :cascade do |t|
t.integer "check_type"
t.integer "status"
t.datetime "started_at"
t.datetime "ended_at"
t.text "output_message"
t.jsonb "metadata"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["check_type", "created_at"], name: "index_project_checks_on_check_type_and_created_at"
t.index ["status"], name: "index_project_checks_on_status"
end
create_table "sailors_log_leaderboards", force: :cascade do |t|
t.string "slack_channel_id"
t.string "slack_uid"
@@ -211,36 +198,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_05_195904) do
t.datetime "updated_at", null: false
end
create_table "ship_chains", force: :cascade do |t|
t.text "code_url"
t.text "demo_url"
t.text "readme_url"
t.text "description"
t.integer "ysws_type"
t.bigint "user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["code_url"], name: "index_ship_chains_on_code_url"
t.index ["demo_url"], name: "index_ship_chains_on_demo_url"
t.index ["user_id"], name: "index_ship_chains_on_user_id"
end
create_table "ship_update_descriptions", force: :cascade do |t|
t.string "what_changed"
t.bigint "ship_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["ship_id"], name: "index_ship_update_descriptions_on_ship_id"
end
create_table "ships", force: :cascade do |t|
t.bigint "ship_chain_id", null: false
t.integer "duration"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["ship_chain_id"], name: "index_ships_on_ship_chain_id"
end
create_table "users", force: :cascade do |t|
t.string "slack_uid", null: false
t.string "email", null: false
@@ -270,7 +227,4 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_05_195904) do
add_foreign_key "api_keys", "users"
add_foreign_key "heartbeats", "users"
add_foreign_key "leaderboard_entries", "leaderboards"
add_foreign_key "ship_chains", "users"
add_foreign_key "ship_update_descriptions", "ships"
add_foreign_key "ships", "ship_chains"
end