How I used Claude to add a data pipeline to my Flutter app without touching its features
Snapshots, an append-only Cloudflare Worker, and deriving user behaviour after the fact
Better Meals & Shopping (BM&S) is my personal Android Flutter app for planning a week of meals and the shopping list that goes with it. I use it every week for breakfast, lunch, and mains. It gets its plans by calling a cloud AI model, and I want to replace those calls with a small fine-tuned model that runs on its own deployment.
Before I can train or judge a model like that, I need to know what happens in real use: what the API was asked, what it answered, what I changed, and what I actually bought, cooked and threw away. None of that was being recorded.
So the first job was to add logging, with three constraints:
- The update must not lose any of the app's data already on my phone.
- Logging must never slow down or break a feature.
- The app should gain as little code as possible.
This is how that went, including some bugs that were worth noting.
Claude Code wrote the code and tests and ran the checks. I set the requirements and made the main calls: send logs to a server rather than keep them on the phone, add no in-app features, and keep the app changes small. Claude proposed the detailed design, and I approved it, and reviewed the code.
Part 1: the app
The changes to existing code came to 108 lines added and 25 removed. Two new files add about 590 lines.
One wrapper around the AI client
Every AI request already passed through one line in WeekPlanner, so the
wrapper went there. It's a decorator that records the outcome and passes the
result or the error straight through:
// lib/services/logging_ai_client.dart
Future<AiResponse> generate(AiRequest request) async {
if (!_log.isEnabled) return _inner.generate(request);
final sentAt = DateTime.now();
final timer = Stopwatch()..start();
void record({
AiResponse? response,
AiException? error,
Object? unexpected,
}) {
_callId = _log.aiCall(
task: task,
provider: provider.name,
model: provider.model,
requestGroup: requestGroup,
attempt: attempt,
request: request,
sentAt: sentAt,
latencyMs: timer.elapsedMilliseconds,
response: response,
error: error,
unexpected: unexpected,
secrets: [apiKey],
);
}
try {
final response = await _inner.generate(request);
record(response: response);
return response;
} on AiException catch (error) {
record(error: error);
rethrow;
} on Object catch (error) {
record(unexpected: error.runtimeType);
rethrow;
}
}The planner retries up to three times, so each HTTP
attempt gets its own row, tied together by requestGroup. And for an
unexpected error it only logs the exception's type, because its message could
contain anything.
A successful API call can still be a useless reply. The app parses each one and runs an allergy and diet check, and a reply can fail after it has been paid for. That's valuable data, so the planner reports the verdict as a separate event:
// lib/services/week_planner.dart
try {
check(response.text, cost);
} on Object catch (error) {
ai.checked(
rejection: error is PlanException ? error.message : '$error',
);
rethrow;
}
ai.checked();Snapshots instead of log calls
I wanted a log of what I did with each result: accepted, edited, swapped, bought, used. The obvious approach is a log call in every controller method, which scatters logging through the feature code.
But every one of those actions already ends in the same place: a store saving
the whole state. So Claude hooked that instead. This is the entire change to
WeekStore:
// lib/services/week_store.dart
Future<void> save(WeekPlan plan) async {
final prefs = await SharedPreferences.getInstance();
final json = jsonEncode(plan.toJson());
await prefs.setString(_key, json);
UsageLog.instance.snapshot(_key, json);
}The stock and favourites stores got the same treatment.
A queue that can't hurt anything
Every entry point returns immediately and swallows its own errors. Events are appended to a JSONL file in the app's private storage, with file work chained so it runs one task at a time:
// lib/services/usage_log.dart
Future<void> _serial(Future<void> Function() task) {
final next = _work.then((_) => task()).catchError((Object _) {});
_work = next;
return next;
}Uploading renames queue.jsonl to sending.jsonl first, so new events start a
fresh queue while the old one is in flight. If a request fails, the file stays
where it is and goes first on the next attempt, five minutes later. The whole
file may be sent twice, which is fine, because every event carries a random id
and the server ignores repeats.
Batching can involve megabytes of backlog, so it runs on another isolate:
// lib/services/usage_log.dart
static Future<List<String>> _prepare(String path) =>
Isolate.run(() => batchesFor(File(path).readAsLinesSync()));The method is static, so the closure can't capture the logger instance and try to carry its state across the isolate boundary.
batchesFor also merges snapshots. Ticking through a shopping list would
otherwise upload dozens of nearly identical copies, so a snapshot is dropped if
a later one of the same key arrives within 10 minutes:
// lib/services/usage_log.dart
final merged =
later != null &&
event.time != null &&
later.time != null &&
later.time!.difference(event.time!) < snapshotMergeWindow;
if (!merged) kept.add(event.line);Claude chose that rule over "keep only the latest" because of what happens during a long outage. Keeping only the latest would collapse a week offline into one snapshot and lose the previous week entirely. With a time window, a new plan days later never merges into the old one.
Keeping API keys out
The wrapper sits above the HTTP layer, so it never touches request headers. But
that isn't enough, because a provider's error message could quote part of a
rejected key, possibly masked (which is why the patterns allow *). So every
event is scrubbed after encoding, just before it's written to disk:
// lib/services/usage_log.dart
static final _keyShaped = [
RegExp(r'sk-[A-Za-z0-9_\-*]{8,}'),
RegExp(r'AIza[0-9A-Za-z_\-]{20,}'),
RegExp(r'[Bb]earer\s+[A-Za-z0-9._\-~+/=*]{8,}'),
];The exact key in use and the logger's own upload token are removed first, then anything key-shaped. Scrubbing the encoded JSON string, rather than individual fields, means a new field can't forget to do it. A test simulates a 401 quoting the key for all three providers, using fake keys, and asserts none of them appear in anything uploaded.
The server address and write token are compile-time values:
flutter run --dart-define-from-file=usage_log.jsonWithout them, isConfigured is false and the logger does nothing, which also
keeps the existing test suite unaffected. The downside is that a plain
flutter run builds an app that silently logs nothing. In exchange, the app
gets no extra settings fields.
Part 2: the server
The Worker has two routes and about 150 lines of TypeScript. It stores events once, exactly as sent:
-- worker/migrations/0001_events.sql
CREATE TABLE IF NOT EXISTS events (
seq INTEGER PRIMARY KEY AUTOINCREMENT, -- order received; the pull tool's cursor
id TEXT NOT NULL UNIQUE, -- set by the app, so a resend is ignored
type TEXT NOT NULL, -- snapshot | ai_call | ai_check
time TEXT NOT NULL, -- on the phone, ISO 8601 UTC
received_at TEXT NOT NULL,
app_version TEXT,
payload TEXT NOT NULL -- JSON
);Separate tokens. A token inside an APK can be extracted, so the app holds a write-only one. The worst someone can do with it is add junk. Reading needs a different token that exists only on my dev machine, and the Worker refuses to serve reads if the two are ever set to the same value.
Idempotent ingest. INSERT OR IGNORE on the app's event id makes retries
safe. An event that can never be stored is counted and dropped rather than
failing the batch, because the app would only send that batch again forever.
Designing for the free tier. Claude designed around Cloudflare's Workers limits and D1 limits:
| Free-plan limit | How the design handles it |
|---|---|
| 10 ms of CPU per request | Phone sends at most 512 KB per request, or one larger event; reads don't re-parse payloads |
| 50 D1 queries per invocation | Phone sends at most 40 events per request |
| 2 MB per row | Payloads over 1.9 MB are rejected, not retried forever |
| 500 MB per database | Snapshots are merged on the phone first |
The read path has the neatest trick. Payloads are already valid JSON text, so parsing and re-serialising them would burn CPU for nothing. They're spliced in:
// worker/src/index.ts
const events = results.map(
(row) =>
`{"seq":${row.seq},"id":${JSON.stringify(row.id)},"type":${JSON.stringify(row.type)},` +
`"time":${JSON.stringify(row.time)},"received_at":${JSON.stringify(row.received_at)},` +
`"app_version":${JSON.stringify(row.app_version)},"payload":${row.payload}}`,
);I haven't yet measured CPU use under real load. If a large batch trips the 10 ms limit, the paid plan's limit is far higher, so that failure has an easy fix.
The tests avoid Cloudflare's test tooling entirely. Node 24 has built-in SQLite
and TypeScript type stripping, so a small fake of D1 runs the real Worker against
the real migration file, with node --test and no extra test dependencies.
Part 3: working out what happened
Back on the dev machine, a Python tool using only the standard library pulls new
events and rebuilds four tables: ai_call_log, user_action_log, outcome_log
and meals. It replays events in order and compares each snapshot with the one
before it.
Cooking a meal is a good example. The app already stores what each cook took from stock, so the diff is short:
# pull/derive.py
for ident in old_meals.keys() & new_meals.keys():
was, now = old_meals[ident].get("cooked") or [], new_meals[ident].get("cooked") or []
for taken in now[len(was):]:
self._action(time, "accepted", ai_call_id=self._provenance.get(ident),
before=new_meals[ident], after=new_meals[ident], source="user",
detail="cooked", event_id=eid)
for item in taken:
self._outcome(time, "used", item.get("name", ""), amount_of(item), event_id=eid)Ticks become bought. Undoing a cook becomes a negative used. A growing
replacedMeals list is a swap, linked to the AI call that suggested the meal I
turned down.
Because raw events are never changed, these rules can improve and be re-run over the whole history. That's the main payoff of this design. A log call that was never written is lost forever, whereas a missed interpretation only needs a new rule.
Inferring waste, honestly
The app has no "I threw this away" button, and I wasn't going to add one. But stock levels are in the snapshots, and bought and used events explain most changes to them. Whatever's left is either waste or a purchase off the list.
The catch is timing. A stock change and the week change that explains it are saved separately, so they don't land on the same timestamp, and merging can move them further apart. Comparing them one at a time would report waste followed by a matching purchase, neither of which happened. So changes to each item are grouped until 15 minutes pass with nothing new, and only the net result counts:
# pull/derive.py
def _settle(self, key: str, group: list) -> None:
actual = [c for c in group if c[2]]
if not actual:
return
residual = sum(c[1] for c in group if c[2]) - sum(c[1] for c in group if not c[2])
drops = -sum(c[1] for c in actual if c[1] < 0)
rises = sum(c[1] for c in actual if c[1] > 0)
unit = key.rsplit("|", 1)[1]
time, name = actual[-1][0], actual[-1][3]
if residual < -EPSILON and drops > EPSILON:
self._outcome(time, "wasted", name, (min(-residual, drops), unit), inferred=True,
detail="stock_fell_unexplained", explains_stock=False)Every such row is marked inferred: true. A stock correction and a binned
lettuce look identical from here, so these rows get checked by hand during
cleaning. We would rather have a labelled guess than a confident lie in my dataset.
Proving nothing was lost
Since the app has no database, there was no migration to test. The real question was simpler: does starting the new version change anything saved?
Before changing any code, Claude copied the data file off the phone and compared
checksums. Then Claude wrote a test that fills storage, boots the app through the same
loadApp() function main uses, with logging on, and compares every value:
// test/existing_data_test.dart
final (app, sent) = await start(saved);
await _expectUnchanged(saved);
expect(sent, {for (final key in _allKeys) key: saved[key]});The last line also checks that what was uploaded matches byte for byte. The
test can read my real data file through a --dart-define path, so it ran against
real data without any of it entering the repo.
Installing on the phone was the final check:
- Before installing, Claude checked that the installed app's signing certificate matched my local debug keystore. A mismatch would have blocked the install, and uninstalling to get past that would have wiped everything.
- During the install, the data file's checksum was the same before it, after it, and after first launch.
About ten seconds after opening the new version, all six saved values had reached the server, and they matched the backup exactly.
The bug only end-to-end testing found
All the unit tests passed. Then Claude ran the real logger against the Worker under
wrangler dev, and pointed the pull tool at that. The AI call came through
perfectly, but it wasn't linked to the week it produced:
[('accepted', 'unknown', False)]The derivation treated the first week it ever saw as data from before logging began, without checking whether an AI call had just created it. No unit test covered that sequence. The fix took two lines, and the case now has its own test.
One small trap on the way: Flutter's test binding answers every real HTTP
request with a 400, so an end-to-end test has to set HttpOverrides.global = null
first.
What caveats come with this design
It has real costs:
- Duplicated logic. The pull tool reimplements the app's food-name matching ("Lemons" = "lemon", ignoring allergen notes) and quantity parsing ("2 x 400g"), so stock can be reconciled. The two copies can drift apart, and nothing checks them against each other yet.
- Heuristics. A snapshot counts as a new week if its start date changes or fewer than half its meals overlap with the last one. Unusual sequences will be misread, though the raw data survives to re-derive.
- Bandwidth and storage. Whole-state snapshots are wasteful compared with small events. My current week is about 20 KB, so if each week produced around 50 merged copies, week snapshots alone would add about 1 MB a week, which a 500 MB database can absorb for years. The 50 is a guess until Claude has* real numbers, and AI calls and stock snapshots add to the total.
- No record before day one. Nothing recovers the weeks the app already threw away.
What the logs are, and aren't, for
Collecting API outputs doesn't automatically make them training data. AI providers restrict using their output to train other models, so my plan separates evaluation from training. Logged replies are the baseline my model has to beat. They won't be training targets for generating meals. Training data will come from what I write and correct myself, plus sources licensed for it, such as output from a permissively licensed open-weights model that I've filtered.
Before any logged output is used beyond evaluation, I still need to check each provider's current terms, and ask for written permission where the use is a grey area. This is Claudes reading of the rules for my own project, not legal advice.
That's another reason every row records its provider and model, and why it's useful that my corrections live in separate tables from the AI's replies.
What's next
The logging is live, and every week I use the app now adds to the eval set. Next I need to write success criteria for each AI task, check those terms, gather the licensed data sources, and let real weeks build up before any training starts.
This project uses AI, Claude Code, Claude.ai.