- Python 79.7%
- Go 19.8%
- PowerShell 0.4%
- Shell 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| cmd/collector | ||
| config | ||
| dist | ||
| internal/collector | ||
| scripts | ||
| sink | ||
| .dockerignore | ||
| .gitignore | ||
| compose.yaml | ||
| Dockerfile.sink | ||
| go.mod | ||
| go.sum | ||
| README.md | ||
ActivityWatch Simple Sync
Tiny ActivityWatch collector and SQLite sink.
This is intentionally smaller than aw-sync-suite: no Prometheus, no Grafana,
no auth layer, and no plugin system. It assumes collectors can reach the sink
over a private VPN.
Shape
cmd/collectorbuilds a standalone Go collector for Windows.- The collector can install, start, stop, and remove itself as a native Windows service. No Python and no NSSM are needed on collector PCs.
sink/aw_sink.pyruns on one VPN-reachable machine and stores events in SQLite.- The sink includes a D3.js data visualisation website with analysis, timeline, and classification pages.
- The collector keeps checkpoints in memory while running. On restart, it asks the sink for the latest stored timestamp for each bucket. If the sink has no checkpoint yet, the collector uses the first-sync lookback window.
- The collector also sends a status heartbeat so the sink can show whether the computer is online, whether ActivityWatch appears healthy, and basic Windows power/session state.
Sink
Run this on the machine that will hold the SQLite database:
mkdir -p data
docker compose up -d --build
Then open the analysis page:
http://localhost:8720/analysis
The timeline page is available at:
http://localhost:8720/timeline
Classification rules are available at:
http://localhost:8720/classify
Backup and restore controls are available at:
http://localhost:8720/admin
If this server is reachable over a VPN, collectors can post to:
http://<vpn-ip-or-dns-name>:8720
Do not expose the sink directly to the public internet. It has no authentication by design.
The SQLite database is stored on the host at:
data/activitywatch.sqlite3
The admin page includes backup and restore controls. Download creates a consistent SQLite snapshot of the live database. Restore accepts a previously downloaded SQLite backup file and replaces the current sink database after validating the expected tables.
Useful sink commands:
docker compose logs -f sink
docker compose restart sink
docker compose down
If Docker reports sqlite3.OperationalError: unable to open database file,
the host data/ folder is not writable by the container. Recreate the folder
and restart the sink:
docker compose down
mkdir -p data
docker compose up -d --build
If you do not want Docker, the sink can still run directly with Python:
python3 -m sink.aw_sink --host 0.0.0.0 --port 8720 --db ./data/activitywatch.sqlite3
Build Collector
From Linux, build a Windows collector executable:
./scripts/build-collector.sh
This creates:
dist/aw-simple-sync-collector.exe
Equivalent manual command:
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o dist/aw-simple-sync-collector.exe ./cmd/collector
Install Collector
Copy aw-simple-sync-collector.exe to the Windows PC, then run PowerShell as
Administrator:
.\aw-simple-sync-collector.exe install --sink-url "http://<vpn-ip-or-dns-name>:8720"
The installer copies the executable to:
C:\ProgramData\AwSimpleSync\aw-simple-sync-collector.exe
It writes config and logs under:
C:\ProgramData\AwSimpleSync
The active service log is:
C:\ProgramData\AwSimpleSync\logs\collector.log
The collector rotates this file daily into dated archives like
collector-2026-05-19.log and removes archives older than 30 days.
To watch the log:
Get-Content "$env:ProgramData\AwSimpleSync\logs\collector.log" -Tail 100 -Wait
Useful commands:
.\aw-simple-sync-collector.exe status
.\aw-simple-sync-collector.exe stop
.\aw-simple-sync-collector.exe start
.\aw-simple-sync-collector.exe uninstall
Optional wrapper:
.\scripts\install-collector-service.ps1 -SinkUrl "http://<vpn-ip-or-dns-name>:8720" -CollectorExe ".\aw-simple-sync-collector.exe"
Collector Config
The collector config is an INI file:
[collector]
device_id =
activitywatch_url = http://localhost:5600
sink_url = http://127.0.0.1:8720
interval_seconds = 300
lookback_days_first_sync = 90
batch_size = 500
include_buckets =
exclude_buckets =
activitywatch_process_names = aw-server.exe,aw-server-rust.exe,aw-qt.exe,aw-watcher-window.exe,aw-watcher-afk.exe
status_cache_path =
max_status_cache_records = 10000
Leave device_id blank to use the Windows computer name. include_buckets and
exclude_buckets are comma-separated ActivityWatch bucket IDs.
activitywatch_process_names is the comma-separated process list used for the
ActivityWatch process health check. Leave status_cache_path blank to use the
default status-cache.jsonl file under C:\ProgramData\AwSimpleSync.
max_status_cache_records caps the local offline status cache; at the default
five-minute interval, 10,000 records is roughly 34 days of heartbeats.
There is no local checkpoint file. The sink database is the persistent checkpoint source, and the collector only keeps a runtime cache to avoid repeated checkpoint lookups while the service is running.
Device Status
Every sync interval, the collector posts a status heartbeat to:
POST /api/status
The sink stores both the latest status and a status history. The dashboard shows the latest status for each device and the recent status history, including:
- whether the collector has recently checked in, which means the computer is powered on and able to reach the sink
- whether the ActivityWatch API is reachable
- whether common ActivityWatch processes are running
- Windows lock/unlock state when the collector is running as a service and receives session-change events
- AC/battery power source, battery percentage, and uptime when available
- lid state as
unknown
If the sink is unavailable, the collector writes that status heartbeat to the local status cache and retries it later. Cached heartbeats are replayed in order before the next live heartbeat after the VPN or sink comes back.
The same data is available as JSON:
GET /api/analysis
GET /api/analysis/events
GET /api/timeline
GET /api/classifications
POST /api/classifications
GET /api/time-filters
POST /api/time-filters
GET /api/chart-highlights
POST /api/chart-highlights
GET /api/events
GET /api/backup
POST /api/restore
GET /api/status
GET /api/status/history?limit=50
GET /api/status/history?device=<device-id>&limit=200
The web visualisations use D3.js in the browser. The pages load D3 from the jsDelivr CDN, so the browser that opens the dashboard needs access to that CDN unless D3 is vendored locally later.
Event timestamps are stored exactly as received from ActivityWatch, normally in
UTC. Analysis, timeline, and classification pages group and display events in
the configured display timezone. Docker Compose sets this to Australia/Perth
with AW_SIMPLE_SYNC_TIMEZONE; change that environment variable if the display
timezone needs to move.
The analysis charts follow the ActivityWatch summary model: they aggregate
aw-watcher-window activity clipped to aw-watcher-afk not-afk periods.
Raw AFK bucket durations are not included in the configurable usage summaries
or the timeline barchart.
The analysis page includes date-range presets such as last day, last 7 days, last 30 days, this week, previous week, this month, and previous month. This week and previous week cover full Monday-Sunday weeks. Analysis, timeline, and classification filters are opened from the right-side header summary button and can filter by named hours filters, custom start/end times, days of the week, application, exact window title or URL, category, device, bucket, and broad search text. Hours filters clip event durations to the selected local-time window and weekdays before chart totals are calculated. The admin page lets you edit the names, start and end times, display order, and days of the week for named hours filters such as school hours.
Filter state is carried between analysis, timeline, and classification pages in
server-rendered query strings. When a date range is active, the timeline page
shows the days loaded for that range and redraws from an in-page cache when a
day has already been fetched; it calls /api/timeline only for days missing
from that cache.
The /highlights page manages chart-only highlight ranges. Each highlight has
a name, start and end time, colour, display order, and days of the week. Hourly
charts, including the analysis active-hours grid, analysis hourly histogram,
timeline chart, and timeline hourly activity chart, tint matching time spans
without changing the underlying totals.
The analysis header has a global aggregation selector for total, category,
application, window title, or browser domain. Aggregation-aware analysis charts
use that single selection together and redraw in place when it changes. The
analysis page loads semi-raw filtered events from /api/analysis/events and
uses D3 rollups in the browser to build the chart series. Date ranges are
loaded as day-sized requests, with a top progress bar showing loaded days
against the total days that need to be requested. Duration axes use shared tick
intervals with a minimum
15-minute step, scaling to 30 minutes, 1 hour, 2 hours, and larger steps as
totals grow. Category groups use school, games, internet, music,
media, and other. The /classify page lets you add
case-insensitive app or window-title matching rules that override the built-in
fallback patterns. The timeline page shows AFK periods as pale red bands.
Windows does not expose a simple, reliable one-shot lid-open/lid-closed query
through the service APIs used here. On a Lenovo Yoga the physical posture can
also be ambiguous, so the collector deliberately reports lid state as unknown
until a reliable device-specific signal is added.
Run Without Installing
For testing on any platform with Go:
go run ./cmd/collector init-config --config ./collector.ini --sink-url http://127.0.0.1:8720
go run ./cmd/collector once --config ./collector.ini
go run ./cmd/collector run --config ./collector.ini
Offline Behavior
If the collector cannot reach the sink over the VPN, the sync attempt fails, nothing is marked as sent, and the service tries again at the next interval. It does not need public internet access; it only needs local ActivityWatch and the VPN route to the sink.
When connectivity returns, the collector asks the sink for the latest timestamp
already stored for each bucket and then fetches newer events from the local
ActivityWatch database. If the sink has never received that bucket before, the
collector falls back to lookback_days_first_sync. The default is 90 days; set
it higher if a machine might be offline before its first successful sync for
longer than that.
There is no separate local upload queue. ActivityWatch itself is the local source of truth while offline, and SQLite deduplication on the sink makes repeated uploads safe.
Status heartbeats are different: ActivityWatch does not store collector health,
Windows lock state, battery state, or sync errors. Those snapshots are cached in
status-cache.jsonl while the sink cannot be reached, then replayed to
status_history when connectivity returns. The cache is intentionally bounded
by max_status_cache_records; if a machine is offline beyond that window, the
newest status samples are kept.
Notes From aw-sync-suite
The reference agent uses the ActivityWatch REST API, tracks checkpoints, chunks uploads, and can install as a service. This project keeps those simple, useful pieces and drops the heavier parts:
- Prometheus remote-write is replaced with
POST /api/events. - Grafana is replaced with the built-in SQLite web viewer.
- Plugins and bearer-token proxying are omitted because VPN access is assumed.
- The collector is a native Windows service executable.
Development Checks
go test ./...
python3 -m py_compile sink/aw_sink.py