Eleven tables, zero indices, and the observability I added afterwards found three more bugs
This is a submission for DEV's Summer Bug Smash: Clear the Lineup. TL;DR. My app recomputes every coaching verdict from raw training logs on read. With a five-year history that took 119.1 ms per session, because eleven Room entities had zero indices between them. Adding indices took it to 6.8 ms, a 17.4x improvement, and one query got no faster at all. Then I instrumented the thing properly and Sentry found three bugs I did not know about, including one where Sentry had been silently discarding every trace I sent it. Zero accepted. Five invalid. What I Built I built WhyRep, a workout tracker that analyzes your training instead of just recording it. Log a session, get a verdict with a traceable reason: you are progressing, you have stalled, this is what to change. Every coaching decision traces back to a methodology document I signed off on, not to something a model invented in the moment. The architecture choice that matters for this post is that nothing is precomputed. Verdicts are derived from raw set logs on read, every time. That keeps the coaching logic honest, because there is no cached judgement to go stale when the rules change. It also means every read walks the history. Android is native Kotlin and Jetpack Compose. iOS is SwiftUI over a shared Kotlin Multiplatform core, so the engines have one implementation across both platforms. Storage is Room, local-first, and the tracker works offline with no account. The coach runs through a 235-line dependency-free Cloudflare Worker that holds the model key so it never ships in the APK. Roughly 10,000 lines of Kotlin in the app module. Eleven Room entities. An 847-exercise catalog. And, until the work in this post, no observability of any kind. No error reporting. No performance data. The Worker's top-level handler was console.error(e) followed by a generic 500, which in production means nothing is recorded anywhere. I want to be precise about the order of events, because it matters for how you read the rest of this. The performance work came first and I found it by reading code, not by using Sentry. Sentry did not exist in this project yet. What Sentry found is a separate section, further down, and those are different bugs. Bug Fix or Performance Improvement The analyzer read path had four independent defects on it. I found them during a performance pass on 2026-07-25, reading the code that answers the question "how did this session go." B3, the headline: eleven entities, zero indices. Every relation fetch full scanned set_logs , which is the table holding every set the lifter has ever performed. It is the largest table in the schema by a wide margin and it is the one on the hot path. This is invisible on a fresh install and gets worse every month, which means it punishes the most committed users first. That is exactly the wrong population to punish in a training app. B1: reorderExercises wrote one row at a time. WorkoutRepository.kt:143 and :316 issued one UPDATE per row with no enclosing transaction. Finishing a 30-set workout was roughly 30 separate commits. B2: cold start hydrated the entire catalog. seedIfEmpty at WorkoutRepository.kt:99 materialized all 847 catalog entities on every launch, purely to build a set of name and equipment pairs it then threw away. B4: search allocated a string per row per keystroke. Three call sites built a joined lowercase string for every one of 847 exercises, on every keypress. The interesting one is B3, and the interesting part of B3 is not the index. Everybody knows to add an index. The interesting part is the trap I nearly walked into while adding it. The read path, before and after. The analyzer recomputes every verdict from raw logs, so the scan was not a corner case, it was the main case. Code Here is the migration. The repo is private, so this post carries the diffs inline, which the rules explicitly allow. // Db.kt, MIGRATION_11_12 database.execSQL( "CREATE INDEX IF NOT EXISTS index_set_logs_exerciseLogId " + "ON set_logs (exerciseLogId)" ) database.execSQL( "CREATE INDEX IF NOT EXISTS index_exercise_logs_sessionId " + "ON exercise_logs (sessionId)" ) // ...seven tables in total And the corresponding entity annotation: @Entity( tableName = "set_logs", indices = [Index(value = ["exerciseLogId"])], // ... ) Now the trap, which is the part worth stealing. Room builds a fresh install from the @Entity annotations and an upgrade from the migration's raw SQL. Those are two independent sources of truth describing the same schema, and nothing in the framework checks them against each other at compile time. Name that index index_set_logs_exerciseLogId in one place and anything else in the other, and you get the worst possible failure shape. Every new install works perfectly. Every existing install crashes on open with a schema validation error. You will not see it in development, because your development database gets recreated constantly. It is a bug that only fires for users who already trust you. So the test does not go on the annotation, and it does not go on the migration. It goes between them: @Test fun migration produces the index names the annotations expect() { val migrated = helper.runMigrationsAndValidate(TEST_DB, 12, true, MIGRATION_11_12) val names = migrated.query( "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='set_logs'" ).use { c -> generateSequence { if (c.moveToNext()) c.getString(0) else null }.toList() } assertTrue("index_set_logs_exerciseLogId" in names) } @Test fun the planner actually uses the index() { val plan = db.query("EXPLAIN QUERY PLAN SELECT * FROM set_logs WHERE exerciseLogId = ?", arrayOf(1)) .use { c -> c.moveToFirst(); c.getString(c.getColumnIndexOrThrow("detail")) } assertTrue(plan.contains("USING INDEX index_set_logs_exerciseLogId")) } The second test is the one I would not skip. Asserting an index exists proves you created an object. Asserting EXPLAIN QUERY PLAN names it proves the planner reaches for it, which is the thing you actually wanted. Those are not the same claim and I have written code where only the first one was true. The lesson generalises past Room. When a framework generates the same artefact from two sources, the test belongs between the two sources, not on either one. My Improvements Measured on 2026-07-25 with PerformanceBenchmark.kt , run as: ./gradlew :app:testDebugUnitTest --tests 'PerformanceBenchmark' -Dbenchmark=1 | Measurement | Before | After | Change | |---|---|---|---| | Analyze one session, 300-session history | 119.1 ms | 6.8 ms | 17.4x | | 20 keystrokes over 847 exercises | 4.2 ms | 566 us | 7.5x | | Mark 20 sets complete | 2.4 ms | 342 us | 7.1x | | Cold-start catalog identity check | 2.8 ms | 908 us | 3.1x | | Load the full history list | 10.7 ms | 11.4 ms | no measurable change | The methodology, in full, because the numbers are worthless without it. The dataset is synthetic: 300 sessions by 5 exercises by 4 sets, which is 6,000 set rows and roughly a five-year training history. Measured on the JVM under Robolectric, not on a device. These are fair relative comparisons of two implementations against the same seeded database in the same process. They are not phone timings and I am not presenting them as phone timings. Median of 15 runs after 5 warmups, median rather than mean so one GC pause cannot move the figure. Where the old code no longer exists, the benchmark reimplements it inline so both sides run under identical conditions. The index rows are measured by dropping and recreating the real v12 indices on the same data. The last row is the honest one and it stays in. Indices made no measurable difference to loading the full history list. That is correct, not a measurement error. That query returns nearly every row of sessions , so SQLite scans regardless and an index cannot help. Indices pay off on selective lookups, which is where the 17.4x came from. I could have reported only the flattering row. A table with one negative result in it is more trustworthy than a table without one, and I would rather you believed the 17.4x. Here is what I refused to do while fixing this: - Do not quote a device number I measured on the JVM. - Do not drop the row that did not improve. - Do not present a synthetic history as real user data. - Do not assert an index exists and call that a performance test. Best Use of Sentry Everything above was found by reading code. Sentry found different bugs, and this section is only about those. I am keeping the line hard because a submission that blurs it is not worth reading. I wired Sentry into three projects: the Android app, the Cloudflare Worker, and the static landing site. The Worker could not use the official SDK, because its deploy story is "paste one dependency-free file into the Cloudflare dashboard" and I was not giving that up. So the Worker talks to Sentry's envelope endpoint through about 150 lines I wrote by hand. That decision is how the first bug happened. 1. Sentry told me it was throwing away everything I sent it. Through a usage counter. Found by: Settings, then Stats and Usage. Not an issue. Not an alert. Not Seer. The Worker deployed clean. /health returned {"ok":true} . /chat correctly returned {"error":"unauthenticated"} on a bare request. wrangler tail showed outcome: ok with zero exceptions. Sentry's Traces view said "Waiting for this project's first trace." Every one of those is exactly what a healthy Worker with no traffic looks like. That is the problem. I had sent it traffic. The only place the truth appeared was a counter on a settings page: | Project | Total | Accepted | Filtered | Rate Limited | Invalid | |---|---|---|---|---|---| | coach-worker | 5 | 0 | 0 | 0 | 5 | | android | 3 | 3 | 0 | 0 | 0 | 33 requests at a 0.2 sample rate produced 5 sampled transactions, which is the sampler working correctly. All 5 were rejected. The android row on the same screen, using the official SDK, accepted 3 of 3. That comparison is what made it a payload bug rather than a D
Comments
No comments yet. Start the discussion.