|
| 1 | +# The NetSpeedTray database |
| 2 | + |
| 3 | +Your history lives in one SQLite file. It is yours — nothing is uploaded, and this document tells you |
| 4 | +exactly what is in it so you can query it yourself. |
| 5 | + |
| 6 | +``` |
| 7 | +%APPDATA%\NetSpeedTray\speed_history.db |
| 8 | +``` |
| 9 | + |
| 10 | +Paste that into Explorer's address bar to find it. It opens in any SQLite tool |
| 11 | +([DB Browser for SQLite](https://sqlitebrowser.org/) is the usual choice), or from Python with the |
| 12 | +standard library and no dependencies. |
| 13 | + |
| 14 | +> **Read it while NetSpeedTray is running, don't write to it.** The database is in WAL mode, so |
| 15 | +> reading alongside the running app is safe. Writing to it underneath the app is not. |
| 16 | +
|
| 17 | +This file is the source of truth for the schema. If you change the schema, change this file in the |
| 18 | +same commit. |
| 19 | + |
| 20 | +--- |
| 21 | + |
| 22 | +## The shape of it: three tiers that age |
| 23 | + |
| 24 | +Every second, NetSpeedTray records how many bytes crossed each network adapter. Keeping that forever |
| 25 | +would mean ~31 million rows per adapter per year, so older data is rolled up: |
| 26 | + |
| 27 | +| Tier | Resolution | Holds | Then | |
| 28 | +|---|---|---|---| |
| 29 | +| `speed_history_raw` | per second | the last **24 hours** | averaged into per-minute rows, originals deleted | |
| 30 | +| `speed_history_minute` | per minute | the last **30 days** | averaged into per-hour rows, originals deleted | |
| 31 | +| `speed_history_hour` | per hour | until your retention setting | deleted | |
| 32 | + |
| 33 | +Retention is **Settings → Advanced → Keep data**, default 365 days. "Keep everything" sets it to |
| 34 | +36,500 days, which is a hundred years and means in practice *never delete*. |
| 35 | + |
| 36 | +Hardware statistics use the identical three-tier model in `hardware_stats_raw` / `_minute` / `_hour`. |
| 37 | + |
| 38 | +**The consequence that matters when querying:** a window longer than 24 hours spans more than one |
| 39 | +tier, and each tier stores different columns. A query that reads only one table silently misses data. |
| 40 | + |
| 41 | +--- |
| 42 | + |
| 43 | +## Tables |
| 44 | + |
| 45 | +### `speed_history_raw` — per-second, last 24h |
| 46 | + |
| 47 | +```sql |
| 48 | +CREATE TABLE speed_history_raw ( |
| 49 | + timestamp INTEGER NOT NULL, -- Unix epoch seconds |
| 50 | + interface_name TEXT NOT NULL, -- e.g. 'Wi-Fi 3', 'Ethernet', 'Tailscale' |
| 51 | + upload_bytes_sec REAL NOT NULL, -- BYTES per second, not bits |
| 52 | + download_bytes_sec REAL NOT NULL, |
| 53 | + PRIMARY KEY (timestamp, interface_name) |
| 54 | +); |
| 55 | +``` |
| 56 | + |
| 57 | +### `speed_history_minute` / `speed_history_hour` — the rollups |
| 58 | + |
| 59 | +```sql |
| 60 | +CREATE TABLE speed_history_minute ( -- and _hour, identically |
| 61 | + timestamp INTEGER NOT NULL, -- start of the bucket |
| 62 | + interface_name TEXT NOT NULL, |
| 63 | + upload_avg REAL NOT NULL, -- mean bytes/sec across the bucket |
| 64 | + download_avg REAL NOT NULL, |
| 65 | + upload_max REAL NOT NULL, -- peak bytes/sec seen in the bucket |
| 66 | + download_max REAL NOT NULL, |
| 67 | + sample_count INTEGER NOT NULL DEFAULT 1, -- how many samples went in |
| 68 | + PRIMARY KEY (timestamp, interface_name) |
| 69 | +); |
| 70 | +``` |
| 71 | + |
| 72 | +`sample_count` is how you tell a full bucket from a partial one — 60 samples in a minute bucket means |
| 73 | +NetSpeedTray was running the whole minute; 12 means it was not. |
| 74 | + |
| 75 | +### `hardware_stats_raw` / `_minute` / `_hour` |
| 76 | + |
| 77 | +A generic key/value time series. One row per metric per timestamp. |
| 78 | + |
| 79 | +```sql |
| 80 | +CREATE TABLE hardware_stats_raw ( |
| 81 | + timestamp INTEGER NOT NULL, |
| 82 | + stat_type TEXT NOT NULL, |
| 83 | + value REAL NOT NULL, |
| 84 | + PRIMARY KEY (timestamp, stat_type) |
| 85 | +); |
| 86 | +-- _minute and _hour replace `value` with avg_value, max_value, sample_count |
| 87 | +``` |
| 88 | + |
| 89 | +`stat_type` values currently recorded, and their units: |
| 90 | + |
| 91 | +| `stat_type` | Unit | Notes | |
| 92 | +|---|---|---| |
| 93 | +| `cpu`, `gpu`, `ram` | percent | 0–100 | |
| 94 | +| `cpu_temp` | °C | needs LibreHardwareMonitor on most systems | |
| 95 | +| `cpu_power`, `gpu_power`, `total_power` | watts | | |
| 96 | +| `latency_gw` | milliseconds | ping to your router — your LAN | |
| 97 | +| `latency_anchor` | milliseconds | ping to a public anchor — true internet latency | |
| 98 | +| `latency_gw_timeout` | 0 or 1 | 1 = that probe timed out. **Averaged in the rollups, so in `_minute`/`_hour` this is a packet-loss *rate* between 0 and 1.** | |
| 99 | + |
| 100 | +Because this table is generic, **adding a new metric needs no schema change** — a new `stat_type` |
| 101 | +flows through aggregation and retention automatically. |
| 102 | + |
| 103 | +### `usage_counter` — the data-cap odometer |
| 104 | + |
| 105 | +One row, always `id = 1`. Separate from the history tables because a data cap must survive retention |
| 106 | +pruning. |
| 107 | + |
| 108 | +```sql |
| 109 | +CREATE TABLE usage_counter ( |
| 110 | + id INTEGER PRIMARY KEY CHECK (id = 1), |
| 111 | + cumulative_up REAL NOT NULL DEFAULT 0, -- lifetime bytes, monotonic |
| 112 | + cumulative_down REAL NOT NULL DEFAULT 0, |
| 113 | + anchor_up REAL NOT NULL DEFAULT 0, -- cumulative at the start of this billing period |
| 114 | + anchor_down REAL NOT NULL DEFAULT 0, |
| 115 | + period_key TEXT NOT NULL DEFAULT '', -- the billing period this anchor belongs to |
| 116 | + updated_ts INTEGER NOT NULL DEFAULT 0 |
| 117 | +); |
| 118 | +``` |
| 119 | + |
| 120 | +Usage this period is `cumulative - anchor`. The anchor only ever advances on a genuine **forward** |
| 121 | +period rollover, so a clock change or DST shift cannot wipe your running total. |
| 122 | + |
| 123 | +### `metadata` — bookkeeping |
| 124 | + |
| 125 | +`key`/`value` text pairs: `db_version`, `created_at`, `current_retention_days`, |
| 126 | +`last_maintenance_at`, `last_vacuum_at`, and the pending-retention keys used to schedule a prune. |
| 127 | + |
| 128 | +### `bandwidth_history` — **not used** |
| 129 | + |
| 130 | +```sql |
| 131 | +CREATE TABLE bandwidth_history ( |
| 132 | + interface_name TEXT PRIMARY KEY, |
| 133 | + total_upload_bytes REAL NOT NULL DEFAULT 0, |
| 134 | + total_download_bytes REAL NOT NULL DEFAULT 0 |
| 135 | +); |
| 136 | +``` |
| 137 | + |
| 138 | +Created and indexed by every install, and **never written to**. Do not build on it without wiring it |
| 139 | +up first. It is documented here so nobody mistakes it for a working lifetime odometer — that is |
| 140 | +`usage_counter`. |
| 141 | + |
| 142 | +--- |
| 143 | + |
| 144 | +## Things that will bite you |
| 145 | + |
| 146 | +**Speeds are bytes per second, not bits.** Multiply by 8 for Mbps: `bytes_sec * 8 / 1e6`. |
| 147 | + |
| 148 | +**There is no "All interfaces" row.** Every row is one real adapter. To aggregate, `SUM` across |
| 149 | +interfaces — and note that includes virtual adapters (WSL, Hyper-V, VPN clients like Tailscale). A |
| 150 | +typical machine has one adapter carrying ~98% of traffic and several carrying almost none. |
| 151 | + |
| 152 | +**There is no `min` column.** Only `avg` and `max` survive rollup, so percentiles cannot be computed |
| 153 | +honestly beyond the 24-hour raw tier. NetSpeedTray refuses to guess them rather than showing a |
| 154 | +fabricated p95 — if you see percentiles marked unavailable in the Statistics sheet, this is why. |
| 155 | + |
| 156 | +**`avg` and `max` answer different questions.** The graph plots `max` at minute and hour resolution, |
| 157 | +so a long-range line is a *peak envelope*, not a rate trace. Totals use `avg`. The two cannot be |
| 158 | +reconciled by eye, and that is expected. |
| 159 | + |
| 160 | +**Compute volume from `avg × bucket_seconds`, not `sample_count × poll_interval`.** The poll rate is |
| 161 | +user-configurable; multiplying by it retroactively rescales history if the user ever changed it. Use |
| 162 | +60 for minute rows and 3600 for hour rows. |
| 163 | + |
| 164 | +**Timestamps are Unix epoch seconds**, interpreted in local time by the app |
| 165 | +(`datetime.fromtimestamp`). They are bucket *start* times in the rollup tables. |
| 166 | + |
| 167 | +--- |
| 168 | + |
| 169 | +## Recipes |
| 170 | + |
| 171 | +Read-only, safe to run while the app is open. |
| 172 | + |
| 173 | +**Daily volume per interface, last 30 days** |
| 174 | + |
| 175 | +```sql |
| 176 | +SELECT date(timestamp, 'unixepoch', 'localtime') AS day, |
| 177 | + interface_name, |
| 178 | + ROUND(SUM(download_avg) * 60 / 1e9, 2) AS down_gb, |
| 179 | + ROUND(SUM(upload_avg) * 60 / 1e9, 2) AS up_gb |
| 180 | +FROM speed_history_minute |
| 181 | +GROUP BY day, interface_name |
| 182 | +ORDER BY day DESC, down_gb DESC; |
| 183 | +``` |
| 184 | + |
| 185 | +**Which adapters actually carry traffic** |
| 186 | + |
| 187 | +```sql |
| 188 | +SELECT interface_name, |
| 189 | + ROUND(SUM(download_avg) * 60 / 1e9, 3) AS down_gb, |
| 190 | + ROUND(SUM(upload_avg) * 60 / 1e9, 3) AS up_gb |
| 191 | +FROM speed_history_minute |
| 192 | +GROUP BY interface_name |
| 193 | +ORDER BY down_gb DESC; |
| 194 | +``` |
| 195 | + |
| 196 | +**Your busiest hour** |
| 197 | + |
| 198 | +```sql |
| 199 | +SELECT datetime(timestamp, 'unixepoch', 'localtime') AS hour, |
| 200 | + ROUND(MAX(download_max) * 8 / 1e6, 1) AS peak_down_mbps |
| 201 | +FROM speed_history_hour |
| 202 | +GROUP BY timestamp |
| 203 | +ORDER BY peak_down_mbps DESC |
| 204 | +LIMIT 10; |
| 205 | +``` |
| 206 | + |
| 207 | +**Internet latency and packet loss over the last week** |
| 208 | + |
| 209 | +```sql |
| 210 | +SELECT datetime(timestamp, 'unixepoch', 'localtime') AS hour, |
| 211 | + ROUND(MAX(CASE WHEN stat_type='latency_anchor' THEN avg_value END), 1) AS latency_ms, |
| 212 | + ROUND(MAX(CASE WHEN stat_type='latency_gw_timeout' THEN avg_value END) * 100, 1) AS loss_pct |
| 213 | +FROM hardware_stats_hour |
| 214 | +WHERE timestamp > strftime('%s','now','-7 days') |
| 215 | +GROUP BY timestamp |
| 216 | +HAVING latency_ms IS NOT NULL |
| 217 | +ORDER BY hour; |
| 218 | +``` |
| 219 | + |
| 220 | +Empty result? The hourly tier only fills once data is older than 30 days, so on a young install |
| 221 | +widen the window or query `hardware_stats_minute` instead. |
| 222 | + |
| 223 | +**Percentiles — but only over the last 24 hours**, where per-second data still exists: |
| 224 | + |
| 225 | +```sql |
| 226 | +SELECT ROUND(download_bytes_sec * 8 / 1e6, 1) AS mbps |
| 227 | +FROM speed_history_raw |
| 228 | +WHERE interface_name = 'Wi-Fi 3' |
| 229 | +ORDER BY download_bytes_sec |
| 230 | +LIMIT 1 OFFSET (SELECT COUNT(*) * 95 / 100 FROM speed_history_raw |
| 231 | + WHERE interface_name = 'Wi-Fi 3'); |
| 232 | +``` |
| 233 | + |
| 234 | +Beyond 24 hours this is not possible from the stored columns, by design — see *no `min` column* |
| 235 | +above. |
| 236 | + |
| 237 | +--- |
| 238 | + |
| 239 | +## Schema versioning |
| 240 | + |
| 241 | +`metadata.db_version` tracks the schema; the current version is **7**. On launch, a database at an |
| 242 | +older version is backed up and migrated forward one step at a time (v2→v3→…→v7). Migrations are |
| 243 | +additive — no version has ever dropped a column. |
| 244 | + |
| 245 | +| Version | Added | |
| 246 | +|---|---| |
| 247 | +| 3 | covering indexes, `metadata` table | |
| 248 | +| 4 | `sample_count` on the rollup tables | |
| 249 | +| 5 | hardware statistics tables | |
| 250 | +| 6 | `hardware_stats_hour` | |
| 251 | +| 7 | `usage_counter` (the data-cap odometer) | |
| 252 | + |
| 253 | +**If you add a table or column:** bump `_DB_VERSION` in `core/database.py`, add a `_migrate_vN_to_vN+1` |
| 254 | +method, and update this file. Old databases must keep working — a migration that assumes a column |
| 255 | +exists will break every existing install. |
| 256 | + |
| 257 | +--- |
| 258 | + |
| 259 | +## Maintenance |
| 260 | + |
| 261 | +A background pass runs periodically and does three things: roll each tier up into the next, delete |
| 262 | +data past your retention setting, and truncate the write-ahead log. |
| 263 | + |
| 264 | +It also runs `VACUUM` to reclaim space, at most once a day and only when there is real slack to |
| 265 | +reclaim. Rolling data up deletes rows, and deleted rows leave free pages behind; without a periodic |
| 266 | +VACUUM the file keeps its high-water mark forever. |
| 267 | + |
| 268 | +If you ever want to reclaim space by hand — with NetSpeedTray closed: |
| 269 | + |
| 270 | +```sql |
| 271 | +VACUUM; |
| 272 | +``` |
| 273 | + |
| 274 | +--- |
| 275 | + |
| 276 | +## What is deliberately *not* stored |
| 277 | + |
| 278 | +NetSpeedTray does not record what it cannot measure honestly: |
| 279 | + |
| 280 | +- **Per-application bytes.** Windows does not attribute network bytes per process without a kernel |
| 281 | + driver. The Monitor shows per-app *connections*, which is real, rather than per-app speeds, which |
| 282 | + would be a guess. |
| 283 | +- **Which traffic was LAN and which was internet.** Adapter byte counters carry no addresses, ports |
| 284 | + or routes — the information simply is not there to filter on. |
| 285 | +- **Anything that leaves your machine.** No telemetry, no accounts, no uploads. The latency probe |
| 286 | + pings your own gateway by default; pinging a public host is opt-in and you name the host. |
0 commit comments