Hackatime architecture guide
Hackatime architecture guide
This is a map of current ownership and invariants, not a proposed design. Follow the linked source when behavior and this summary disagree.
Sources of truth and derived state
| Domain | Source of truth | Derived / disposable state |
|---|---|---|
| Identity | users, email_addresses, provider IDs and encrypted provider tokens |
Session cookie |
| Coding activity | Non-deleted heartbeats |
Durations, dashboard/profile payloads, rollups, leaderboards, streaks, Rails caches |
| Heartbeat project identity | heartbeats.project |
Project lists and project statistics |
| Per-user project settings | project_repo_mappings keyed by project name |
Discovery retry/coalescing cache keys |
| Repository identity | Shared repositories row (url, host, owner, name) |
Stars, languages, homepage, commit count, sync timestamps, imported commits |
| Async work | Pending/scheduled good_jobs rows and their serialized arguments |
Finished execution history, process records, cron history and enqueue cache keys, subject to retention policy |
Do not write a rollup, duration, profile statistic, or cache as though it were primary data. Change its input or the derivation, invalidate it, and let it be rebuilt.
1. Rails request and Inertia/Svelte UI flow
- Routes dispatch to Rails controllers. Browser controllers inherit
ApplicationController, which supplies session identity, lockout enforcement, no-store headers, error reporting, PaperTrail attribution, and the user’s time zone. - Inertia controllers inherit
InertiaController. It shares layout props (navigation, flash, theme, CSRF token, footer and impersonation state) on every Inertia response. Controllers select a page withrender inertia: "Directory/Page", props: ...; server props are the request’s serialized boundary, not a second model layer. inertia.tsresolves that name toapp/javascript/pages/Directory/Page.svelteand wraps pages inAppLayout.svelte. Inertia<Link>,<Form>androutercalls make later visits while Rails still owns routing, authorization, validation and writes.- Svelte pages own presentation and truly local/editable UI state. Keep domain
calculations and authorization on the server. Shared props belong in
InertiaController; page-specific props belong in the rendering controller.
Client route strings come from js_from_routes, not controller props or
hand-built URLs. Add a named route to EXPORTED_ROUTES in
js_from_routes.rb, regenerate, and
import the controller module from app/javascript/api. Call .path() with
path/query parameters. The generated directory is gitignored. The allowlist
exports named routes and nameless siblings for an already-exported controller;
it intentionally avoids exposing every Rails route.
Keep server-built URLs only when the client lacks required information (for
example, request host), or for external links. API-only controllers may inherit
ActionController::API; they are not part of the Inertia boundary.
2. Identity and authorization boundaries
Browser identity
ApplicationController#current_user
is exactly User.find_by(id: session[:user_id]). HCA, Slack, and single-use
email-link sign-in converge on a User; successful login resets the session
before assigning that ID. Slack and GitHub callback state is consumed and
compared with secure_compare; HCA currently does not use an OAuth state
nonce. Continuation URLs must be local paths (not //...). See
SessionsController.
EmailAddress owns normalized, globally
unique login addresses and their provenance. Provider/preserved addresses
cannot be unlinked, and a user cannot remove their last address. Provider IDs
and encrypted HCA/Slack/GitHub access tokens live on
User; provider concerns own token exchange and remote
profile synchronization.
API identities
ApiKeyis a user credential (UUIDv4 for WakaTime compatibility). The Hackatime-compatible controller accepts Bearer, Basic, or legacyapi_keyquery input, resolves the key’s user, then calls the ingestion service. It skips CSRF because it is token-authenticated; pending deletion blocks writes. SeeHackatimeController.- Doorkeeper is a separate delegated-user boundary. Its configured scopes are
profile(default),read, andadmin; validate token acceptability and required scopes, then load the resource owner. Ordinary OAuth/API access is denied for convicted or pending-deletion users viaapi_access_restricted?. Controllers that support ordinary user credentials declare their accepted API-key sources and OAuth scopes throughUserApiAuthentication. Seedoorkeeper.rb. - Admin API credentials are either active
AdminApiKeys or acceptable Doorkeeperadmintokens. OAuth admin access additionally requires a confidential, verified, admin-scoped application. The API boundary isApi::Admin::ApplicationController.
Admin authorization
Use AuthHelpers and explicit
controller/model predicates; hiding a nav link is not authorization. viewer
can enter read-only admin surfaces and use admin API authentication, but general
browser admin writes require admin, superadmin, or ultraadmin.
The enum’s stored numeric order is historical and not privilege order.
Use User::ADMIN_LEVEL_RANK and helpers. Effective order is
default < viewer < admin < superadmin < ultraadmin. Role/trust changes prohibit
self-action and require the actor to strictly outrank the target; only
superadmin+ changes admin levels, only ultraadmin grants ultraadmin, and a red
trust conviction requires superadmin+.
3. Heartbeat ingestion and duration semantics
Non-deleted Heartbeat rows are authoritative
activity. All direct and imported writes should flow through
HeartbeatIngest, which owns:
- accepted input normalization, sane epoch validation/repair, null/control cleanup, default categories, language and user-agent inference, source type, request metadata, and WakaTime placeholder handling;
- model validation before bulk insertion (bulk insertion deliberately bypasses callbacks), plus explicit callback-equivalent fields;
- deduplication and race-safe persistence; and
- scheduling rollup refresh and best-effort project mapping only after inserts.
fields_hash is the persisted identity of a normalized heartbeat and includes
the user, time and activity metadata listed by
Heartbeat.indexed_attributes (plus present AI attributes). Direct batches
collapse equal hashes; insert_all ... unique_by lets the database settle
cross-request races, then ingestion fetches the winning row. Import batches
keep the latest row per hash and also check legacy hashes so normalization
changes do not duplicate old imports. During the Timescale cutover, uniqueness
may be (fields_hash) or (fields_hash, time_epoch); ingestion detects the
schema, explicitly sets the partition epoch, refreshes stale schema metadata,
and retries only outside an open transaction.
Soft deletion is implemented by deleted_at; the model’s default scope hides
those rows. Use soft_delete / restore, which also invalidate rollups.
Duration is not stored. Heartbeatable
derives it from ordered heartbeat timestamps. The default timeout is 2 minutes:
- the first heartbeat contributes zero;
- each later heartbeat contributes
min(current_time - previous_time, 120s); - grouped duration partitions by the requested group, while
attributed_durations_bycomputes globally ordered gaps and attributes each gap to the current heartbeat’s bucket; to_spansplits when a gap exceeds the timeout and caps the prior span’s tail at the timeout; and- boundary-aware calculations include the preceding heartbeat so a requested window does not incorrectly lose its opening interval.
Preserve deterministic ordering by time, id, timestamp validity filters, and
the timeout cap when adding reports. Eligibility scopes additionally distinguish
coding, browser activity and the <<LAST_PROJECT>> sentinel.
4. Dashboard/profile rollups and caches
DashboardStats is the read facade. An
unfiltered all-time dashboard can use dashboard_rollups; filtered/custom time
ranges query heartbeats. A missing aggregate total falls back to live
calculation and schedules a refresh. A dirty or stale aggregate total is served
while refresh is scheduled. Invalid activity-graph/today fragments and
malformed filter options fall back to live calculation and schedule refresh.
Short Rails caches (currently 1/5/15 minutes depending on fragment) are also
disposable.
DashboardRollupRefreshService
rebuilds totals, dimensions, weekly projects, project details, filter options,
activity graph and today’s stats from the user’s non-archived heartbeats. It
atomically replaces all of one user’s rows in a transaction. The refresh job
marks the user dirty before enqueue, coalesces scheduling with a cache key, and
uses a per-user GoodJob concurrency limit. Heartbeat commits, soft-delete/
restore, timezone changes, and project archive changes schedule refreshes.
ProfileStatsService is a thin
projection of DashboardStats, including OG-image totals. It has no independent
authoritative statistic. Change shared duration/snapshot logic below both
dashboard and profile rather than patching profile output independently.
5. Projects, repositories and repo hosts
ProjectRepoMapping owns a user’s
repository association, archive state and sharing state keyed by a heartbeat
project name. The heartbeat remains authoritative for the project identity and
a mapping may not exist. Archiving affects dashboard scope and invalidates
rollups. Ingestion asynchronously attempts mapping for new non-sentinel project
names; discovery currently searches the linked GitHub user and organizations.
Repository is shared by URL and owns parsed
host/owner/name plus synchronized host metadata. Mapping callbacks create/reuse
it and trigger metadata/commit work. A mapping is user-specific; a repository
is not. Do not put user preferences on Repository or shared host metadata on
the mapping.
External repository calls belong behind
RepoHost::ServiceFactory and
BaseService. Only GitHub is
supported today; GithubService
owns GitHub headers, existence checks, metadata requests and rate-limit/error
translation. Extending hosts requires factory/host validation and a service
implementation, plus updating jobs that currently contain GitHub-specific
discovery/event logic. Several periodic repository scan/sync cron entries are
currently disabled; do not assume they run.
6. GoodJob, mail and Slack
All jobs inherit ApplicationJob, which
provides shared error-reporting helpers and discards deserialization and
concurrency-limit failures.
good_job.rb is the queue/cron source of
truth: development runs async threads, non-development expects external
workers, and cron is production-only. Choose a queue by latency/ownership; do
not perform slow remote work in request controllers merely because development
can execute jobs in-process.
Action Mailer owns message composition/delivery. Production SMTP configuration
and the latency_10s deliver_later queue live in
production.rb. Some jobs intentionally
call deliver_now inside an already-queued job; preserve that boundary unless
changing retry/queue semantics deliberately.
Slack has three boundaries: OAuth/provider identity in the user concerns,
signed command ingress in SlackController,
and queued command/profile/status work. Outside development, commands require a
valid Slack HMAC signature and timestamp within five minutes. Remote API calls,
token choice and typed rate-limit behavior live in
SlackIntegration; controllers
should authenticate, validate and enqueue.
7. Time zones, transactions and concurrency
Heartbeat time is Unix epoch time. Calendar concepts (today, week, streak,
activity dates) use the validated User#timezone. Browser requests run inside
Time.use_zone(current_user.timezone); services/jobs without that wrapper must
use Time.use_zone explicitly. SQL day grouping converts epochs with the user
timezone and falls back to UTC only where the reporting code explicitly guards
invalid legacy data. A timezone change invalidates both old/new activity cache
keys and rollups.
Use database constraints/upserts for cross-process correctness, transactions
for multi-row replacement, after_commit for derived work, and GoodJob
concurrency plus cache coalescing for expensive idempotent refreshes. Rails
cache alone is an optimization, not a lock or source of truth. In particular,
keep heartbeat dedup race-safe and rollup replacement atomic.
Where should this change go?
| Change | Put it here |
|---|---|
| Parse/normalize/accept heartbeat input | HeartbeatIngest; controller only permits/authenticates/responds |
| Change activity or gap math | Heartbeatable / shared snapshot query code, then verify every consumer |
| Add dashboard/profile statistic | DashboardData::Snapshots + DashboardStats; add rollup dimension only if appropriate |
| Change page data or validation | Rails controller/service/model; serialize minimal Inertia props |
| Change page interaction/presentation | Svelte page/component; use Inertia primitives |
| Add a frontend Rails URL | Rails route + js_from_routes allowlist + generated helper import |
| Add browser/admin/API authorization | Existing auth concern/controller boundary and model capability predicate |
| Change sign-in/provider identity | SessionsController plus the relevant user OAuth/provider concern |
| Change project archive/share/user mapping | ProjectRepoMapping and its controller/job |
| Change shared repository metadata/API calls | Repository + RepoHost service + sync job |
| Add slow, scheduled or retryable work | ApplicationJob subclass and GoodJob queue/cron config as needed |
| Compose/send mail | Mailer; enqueue from the owning lifecycle/job |
| Handle Slack command/API behavior | Verified ingress controller, Slack concern/client boundary, then job |
| Change a calendar-day report | Explicit user-zone service/query; invalidate timezone-sensitive derived data |