●MAX — Rork Max generates native Swift apps for iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage●PUBLISH — Rork Max ships 2-click App Store publishing and runs $200/month●RN — The standard Rork builds native iOS/Android apps with React Native (Expo) — the quicker path to a working app●PRICE — Rork is free to start, with paid plans from $25/month●FUND — Rork raised $2.8M from a16z; the platform now sees 743k+ monthly visits with 85% growth●FLOW — Describe your app in plain English and Rork generates deployable code that can use the camera, notifications, and more●MAX — Rork Max generates native Swift apps for iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage●PUBLISH — Rork Max ships 2-click App Store publishing and runs $200/month●RN — The standard Rork builds native iOS/Android apps with React Native (Expo) — the quicker path to a working app●PRICE — Rork is free to start, with paid plans from $25/month●FUND — Rork raised $2.8M from a16z; the platform now sees 743k+ monthly visits with 85% growth●FLOW — Describe your app in plain English and Rork generates deployable code that can use the camera, notifications, and more
Logging Design for Rork Apps: What to Keep and How to Redact PII
Rork-generated apps tend to scatter console.log everywhere, and when a bug appears you cannot read the part that matters. This designs structured logging, log levels, automatic PII masking, and production send control — all with code you can use as-is.
A few days after the app cleared review, a report came in: saving fails on one specific device model. It would not reproduce on my own phone. Logs are what you lean on at moments like this, but when I opened the code Rork had exported, console.log calls were scattered around in raw form, and the part that actually mattered was impossible to read.
As an indie developer running several apps across the App Store and Google Play, "bugs that don't reproduce on my machine" are unavoidable. Each time, it drives home that logs are too late if you add them after the accident.
Logs also carry the opposite danger. The more detail you try to keep, the more you risk writing out personal information — email addresses, auth tokens — right alongside it. Here we build, hands-on, a logging design that keeps enough to investigate while reliably hiding what must be hidden.
Why scattered console.log fails you
A string log like console.log("saved") looks clear in the moment. But when you search later, it gives almost nothing to grab onto.
There are three problems. First, when, on which screen, and in what user state it happened are buried in a string rather than structured. Second, dev output lingers into production, leaking unnecessary information. Third, PII that should be hidden mixes in unprotected.
So what you need is one place that does three things: shape logs into a readable form, switch the volume by environment, and automatically hide dangerous values.
Logs become searchable only once structured
First, treat a log as an object, not a string. Shift to JSON from the start so you can filter mechanically later.
// logger.ts — the single logger for the whole apptype Level = "debug" | "info" | "warn" | "error";type LogRecord = { ts: string; // ISO8601 timestamp level: Level; event: string; // a short identifier like "save.failed" screen?: string; meta?: Record<string, unknown>;};function emit(record: LogRecord) { // dev: print as-is; production: route to the send logic below console.log(JSON.stringify(record));}
The key is to standardize event into a short identifier like "save.failed". With fixed names instead of free text, filtering for "only save failures" later is a one-liner.
Attach context to screen and meta, and "on which screen, with what input" stays as structure. The point is to split into fields rather than write into a string.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A migration path that consolidates scattered console.log into one logger and shifts to JSON structured logs
✦A regex-based masking implementation that automatically hides emails, tokens, and device IDs before they are sent
✦Log-level control that prints everything in dev but sends only warn-and-above in production, with sampling to cap cost
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
This is the most important part of the article. The more context you keep, the more PII slips in. Prepare a masking function that every value passed to meta must go through before sending.
This function guards in two layers. Keys named email or token are replaced whole with [redacted], and any other string is scanned by regex to hide email- or token-like fragments.
Even if you carelessly pass a whole object like meta: { user }, it walks recursively and hides the contents. Nothing feels more pointless to me than scrubbing email addresses out of logs after the fact, so I strongly recommend a design that stops it mechanically at the entrance.
Switch volume by environment with log levels
In development you want to see everything. But sending every log in production inflates both bandwidth and storage cost. So cut off by log level.
// logger.ts (continued)const ORDER: Record<Level, number> = { debug: 0, info: 1, warn: 2, error: 3 };// dev: debug and up; production: warn and up onlyconst MIN_LEVEL: Level = __DEV__ ? "debug" : "warn";export function log(level: Level, event: string, meta?: Record<string, unknown>, screen?: string) { if (ORDER[level] < ORDER[MIN_LEVEL]) return; // cut off emit({ ts: new Date().toISOString(), level, event, screen, meta: meta ? (redact(meta) as Record<string, unknown>) : undefined, });}
__DEV__ is a constant Expo switches automatically. Now dev keeps everything down to debug, while production keeps only warn and error. The fine-grained logs you need for local investigation stay out of production.
You want every error sent, but sending all high-frequency logs like info makes cost spike as your user base grows. So thin out only the low-importance logs to a fixed fraction.
// sampling built into the send logicconst SAMPLE_RATE: Record<Level, number> = { debug: 0, // never sent in production info: 0.1, // send 10% warn: 1, // all error: 1, // all};function shouldSend(level: Level): boolean { return Math.random() < SAMPLE_RATE[level];}
In my own operation, thinning info to 10% noticeably cut the number of sends, and it was still plenty to read trends. Since warn and error go at 100%, no real investigation ever loses data. Adjusting the ratio gradually while watching user count is the realistic approach.
Which events to keep: a starting list
Logging everything makes things harder to read, not easier. Deciding the list of events to keep up front saves you hesitation. These are the four kinds I always include in a new app.
Example event name
Level
Why keep it
purchase.completed / purchase.failed
error / warn
Tied to revenue and hard to reproduce
save.failed
error
Data loss leads straight to churn
auth.expired
warn
Lets you gauge how often logins lapse
screen.view
info
Tracks where in the flow users drop off
Of these, the purchase events and save.failed are set to a level that always arrives, even in production. By contrast, screen.view is info, so sampling thins it and it never strains cost.
An order for rolling the logger into an existing app
You don't need to replace every console.log at once. For an app already in production, this order is safe.
Drop in the two files logger.ts and redact.ts
Replace console.log calls around purchasing with log()
Replace the spots for saving and network errors
Migrate the rest gradually whenever you touch them
With this order, the logs for the places you most want to know about get shaped first. For events that hurt revenue yet are hard to reproduce - like an AdMob initialization failure - I recommend keeping them as error early.
The pitfall is deferring masking. Once PII lands in a log it's hard to recover, so I always build redact into the very first step.
Your next step
Start by dropping in the two files logger.ts and redact.ts, and replace console.log calls one at a time with log(). You do not need to fix them all at once. Begin around purchasing and saving, and the logs for the places you most want to know about will start to remain — with PII already hidden.
Logs are something you plant before the accident, not after. The longer you operate generated code, the more this bit of effort at the entrance ends up helping you.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.