●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Rork × MetricKit — Building a Solo Diagnostics Pipeline for Crashes, Hangs, and Battery Drain
A practical guide to building a continuous production-quality observability pipeline for Rork-built apps using only Apple's first-party MetricKit framework — no third-party SDKs needed.
After shipping a few apps to the App Store, I started feeling something strange. Downloads kept climbing, but three-star reviews quietly piled up. Crashlytics was installed. Crashes happened maybe a handful of times a month. And yet reviews complaining "it feels sluggish" or "this is hard to use" wouldn't go away. I only figured out why when I was reading through Apple's docs one evening and stopped at a section called MetricKit.
Crashes are the extreme end of the spectrum — the app actually died. But the kind of frustration that drives a three-star review usually shows up earlier. The scroll judders. Launch takes two seconds. The app freezes for half a second after you press Back. The battery seems to drain faster than the user remembers. The only standard framework that captures all of these "near-miss" moments, shipped since iOS 13, is MetricKit. Rork-built apps can absolutely take advantage of it.
In this guide I'll walk through every step of integrating MetricKit into a Rork app (which sits on top of Expo and React Native), backing it with a Cloudflare Workers backend, and continuously aggregating reports — using exactly the same setup I run in production. If you're a solo developer who'd rather not pile on more third-party observability SDKs, this design might be the cleanest fit you'll find.
Why Start with MetricKit Instead of Sentry or Crashlytics
Let me state my position up front. I'm not against Sentry or Crashlytics. For team workflows where you need to chase stack traces in real time, they're still strong choices. But in the specific context of a solo developer quietly improving an app over years, MetricKit wins on almost every axis.
Three reasons. First, privacy by design. MetricKit reports are aggregated by the OS and handed to your app without user identifiers. You don't need to fight GDPR or ATT consent flows to use them. Second, battery friendliness. Crashlytics and Sentry typically send small payloads on every launch, which adds up. MetricKit delivers reports once per day in a single batch, so it costs essentially nothing on the user's battery. Third, the metrics you actually wanted are already there: scroll-rate adherence, time-to-first-draw, hang time, disk I/O, foreground vs. background CPU and GPU usage. Building all of that yourself takes weeks. MetricKit gives it to you out of the box.
I prefer this approach. Having the data you need to "fight everyday user dissatisfaction" available natively, on top of crash reporting, is a real advantage for indie developers. Layer Sentry on top later only if you genuinely need it — that's the order I've found to be most practical in the field.
The Four Kinds of Data MetricKit Hands You
Once you wire up MetricKit, four kinds of payloads come in via MXMetricManager. Holding a clear mental map of these makes the implementation feel less like guesswork.
MXMetricPayload: A daily snapshot of the previous 24 hours. Includes launch time, hang duration, scroll metrics, CPU/GPU/memory, battery drain, disk I/O, and GPS time
MXDiagnosticPayload: Diagnostics with full call stacks, delivered when a crash, hang, excessive disk write, or CPU-usage exception happens. Roughly equivalent to a crash report
MXCallStackTree: The thread-by-thread call-stack JSON inside MXDiagnosticPayload
MXSignpost: A way to instrument your own time intervals (for example, "from image upload start to completion") and have them aggregated by the OS
This guide focuses on MXMetricPayload and MXDiagnosticPayload. I'll touch on MXSignpost near the end, but those first two alone surface most of the "what's actually wrong" answers you're looking for.
✦
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
✦Pinpoint the 'near-miss' moments that App Store Connect numbers don't show, by reading actual MetricKit reports rather than guessing
✦Learn how to build a diagnostics pipeline using only Apple's first-party tooling, without committing to Sentry or Firebase Crashlytics
✦Walk away with a weekly review flow that even a solo developer can run, covering crashes, hangs, battery drain, and scroll quality
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.
A Minimal MetricKit Integration in a Rork (Expo) App
Now for the actual implementation. Rork builds on top of Expo's managed workflow, but MetricKit requires native code, so we wrap it in a single Expo Native Module that gets included whenever expo prebuild regenerates the iOS project. The same flow works for Rork Max generating a SwiftUI native app.
Step 1: Scaffold an Expo Module
From your project root, generate a local Expo Module. This essentially places a small Swift package inside your project.
# Scaffold a project-local Expo Modulenpx create-expo-module@latest --local metric-kit-bridge# Verify the layoutls modules/metric-kit-bridge/# → ios/, src/, expo-module.config.json
The --local flag keeps the module inside your repo rather than publishing it to npm. For a solo developer who doesn't need to share the code, this is the simplest path.
Step 2: Subscribe to MXMetricManager from Swift
Replace the contents of modules/metric-kit-bridge/ios/MetricKitBridgeModule.swift with the snippet below. It's the smallest version that works end to end.
import ExpoModulesCoreimport MetricKitimport Foundation/// Bridges MetricKit reports up to JavaScript./// The OS calls deliverMetricPayload / deliverDiagnosticPayload/// once per day (or when a diagnostic occurs).public class MetricKitBridgeModule: Module, MXMetricManagerSubscriber { // Cache for incoming payloads. We hold them until JS asks for them // so that a launch with no network connectivity doesn't drop reports. private var pendingPayloads: [[String: Any]] = [] public func definition() -> ModuleDefinition { Name("MetricKitBridge") OnCreate { // Register ourselves as a subscriber. The reference is held weakly, // so we don't risk a retain cycle. MXMetricManager.shared.add(self) } OnDestroy { MXMetricManager.shared.remove(self) } // Called from JS to drain the queue. AsyncFunction("drainPendingPayloads") { () -> [[String: Any]] in let snapshot = self.pendingPayloads self.pendingPayloads.removeAll() return snapshot } } // 24-hour metrics. Usually arrives on the next app launch. public func didReceive(_ payloads: [MXMetricPayload]) { for payload in payloads { pendingPayloads.append([ "type": "metric", "json": String(data: payload.jsonRepresentation(), encoding: .utf8) ?? "{}" ]) } } // Diagnostics for crashes, hangs, etc. Arrives whenever the event happens. public func didReceive(_ payloads: [MXDiagnosticPayload]) { for payload in payloads { pendingPayloads.append([ "type": "diagnostic", "json": String(data: payload.jsonRepresentation(), encoding: .utf8) ?? "{}" ]) } }}
Two things to flag. First, MXMetricManager.shared.add(self) runs in OnCreate, so subscription begins the moment the app launches. Second, payloads are cached inside the module and only cleared after JS calls drainPendingPayloads(). That ordering matters: it means a launch where the network is down doesn't drop a hard-earned crash report.
Step 3: Wrap the Bridge in TypeScript
Drop this into modules/metric-kit-bridge/src/MetricKitBridge.ts.
import { requireNativeModule } from "expo-modules-core";type RawPayload = { type: "metric" | "diagnostic"; json: string;};export type MetricKitPayload = { type: "metric" | "diagnostic"; // Raw JSON from jsonRepresentation(). Apple documents the schema. // https://developer.apple.com/documentation/metrickit payload: Record<string, unknown>; receivedAt: string; // ISO8601};const native = requireNativeModule<{ drainPendingPayloads(): Promise<RawPayload[]>;}>("MetricKitBridge");export async function drainPendingPayloads(): Promise<MetricKitPayload[]> { const raw = await native.drainPendingPayloads(); const now = new Date().toISOString(); return raw.map((r) => ({ type: r.type, payload: safeParse(r.json), receivedAt: now, }));}function safeParse(s: string): Record<string, unknown> { try { return JSON.parse(s) as Record<string, unknown>; } catch { return { _parseError: true, raw: s }; }}
The safeParse wrapper exists because Apple has changed the schema in the past, and a parsing failure shouldn't crash your reporting pipeline. Building "fail-soft" handling into the path from the very beginning saves you from being woken up at 3am over a dashboard issue.
Sending Reports to a Backend with Cloudflare Workers
Reports stuck on the device only help you if you ship them somewhere you can review them. I prefer Cloudflare Workers for this. The free tier handles 100,000 requests per day, and pairing it with D1 or R2 means you can run the entire pipeline without provisioning a single server.
A Workers Endpoint to Accept Payloads
// worker.ts — Cloudflare Workersimport { Hono } from "hono";import type { MetricKitPayload } from "./types";type Env = { METRICS_DB: D1Database; // Shared secret embedded in the Rork app. // For rotating tokens, hold an array and check membership. CLIENT_SECRET: string;};const app = new Hono<{ Bindings: Env }>();app.post("/v1/metrics", async (c) => { // Lightweight shared-secret auth. Upgrade to JWT + App Attest for production. const auth = c.req.header("X-Client-Secret"); if (auth !== c.env.CLIENT_SECRET) { return c.json({ error: "unauthorized" }, 401); } const body = await c.req.json<{ appVersion: string; osVersion: string; deviceModel: string; payloads: MetricKitPayload[]; }>(); // Cap each request at 100 payloads. Defends against oversized requests. const trimmed = body.payloads.slice(0, 100); for (const p of trimmed) { await c.env.METRICS_DB.prepare( `INSERT INTO metric_payloads (received_at, type, app_version, os_version, device_model, payload_json) VALUES (?, ?, ?, ?, ?, ?)` ) .bind( p.receivedAt, p.type, body.appVersion, body.osVersion, body.deviceModel, JSON.stringify(p.payload) ) .run(); } return c.json({ stored: trimmed.length });});export default app;
Keep the D1 schema simple at first. Storing the raw JSON and parsing it on read tends to scale better than a fully normalized schema, especially at indie-developer volumes.
CREATE TABLE metric_payloads ( id INTEGER PRIMARY KEY AUTOINCREMENT, received_at TEXT NOT NULL, type TEXT NOT NULL, -- 'metric' or 'diagnostic' app_version TEXT NOT NULL, os_version TEXT NOT NULL, device_model TEXT NOT NULL, payload_json TEXT NOT NULL);CREATE INDEX idx_metric_payloads_received_at ON metric_payloads(received_at);CREATE INDEX idx_metric_payloads_type_version ON metric_payloads(type, app_version);
Batch-Upload from the Client at Launch
The client side is straightforward. I recommend running it once in a root-level useEffect. MetricKit only delivers a payload roughly once per day, so you're not at risk of over-sending.
// app/_layout.tsx or a similar root componentimport { drainPendingPayloads } from "@/modules/metric-kit-bridge/src/MetricKitBridge";import * as Application from "expo-application";import * as Device from "expo-device";async function uploadPendingMetricPayloads() { const payloads = await drainPendingPayloads(); if (payloads.length === 0) return; try { const res = await fetch("https://metrics.your-domain.com/v1/metrics", { method: "POST", headers: { "Content-Type": "application/json", "X-Client-Secret": process.env.EXPO_PUBLIC_METRICS_SECRET ?? "", }, body: JSON.stringify({ appVersion: Application.nativeApplicationVersion ?? "unknown", osVersion: Device.osVersion ?? "unknown", deviceModel: Device.modelName ?? "unknown", payloads, }), }); if (!res.ok) { // If upload fails, re-cache the payloads so the next launch can retry. // In production, persist them via AsyncStorage. console.warn("metrics upload failed", res.status); } } catch (err) { console.warn("metrics upload error", err); }}
Why bother with a re-cache path on upload failure? Because once you call drainPendingPayloads(), the payloads are gone from the native side. If your network call fails and you don't persist locally, you lose the data forever. I learned this the hard way when I missed a launch-day crash bundle because the client uploaded over a captive Wi-Fi network and the server never got the request.
Reading a Crash Report — Decoding callStackTree in Three Minutes
When a crash arrives, look inside MXDiagnosticPayload.crashDiagnostics. The JSON looks roughly like this.
Without symbolication, this is just numbers. But if your dSYMs reach Apple — and they will, automatically, if you ship via TestFlight or App Store Connect — then Xcode's Organizer can symbolicate the matching crash. To make this path bulletproof, I always archive my dSYMs separately in CI and let Apple ingest them through the normal upload path.
A practical tip for solo developers: attach the dSYM zip to GitHub Releases. The day a six-month-old crash from v2.3.1 appears, you'll be glad past-you saved them.
The flow I follow when triaging a crash:
Filter for crashes with terminationReason of Namespace SIGNAL, Code 0x6 (SIGABRT)
Note the binaryName and offsetIntoBinaryTextSegment
In Xcode → Window → Organizer → Crashes, find a matching version and use atos to resolve the offset
If the symbol resolves to your Swift / Objective-C code, it's yours. If it lands near __cxa_throw in the React Native binary, the JS thread caused it
MetricKit alone can't fully chase JS-thread crashes, so I use Sentry's React Native SDK only for that segment. My production setup is "MetricKit for native, Sentry for JS" — two-layer, never overlapping.
Hang Detection — Possibly the Most Valuable Metric in the Whole Framework
Honestly, the highest-leverage win I got from MetricKit wasn't crashes — it was hangs. The hangDiagnostics array records every event where the main thread was blocked for 250ms or more.
What makes hangs especially insidious is that they trigger three-star reviews while being invisible during local development. A user on an iPhone SE on a slow café Wi-Fi network, opening your app for the first time and waiting on a synchronous font load, will never reproduce on the latest iPhone Pro you're testing with.
When the hang reports started flowing in, the "slowness I couldn't see" turned into hard numbers. In one case, image resizing through UIImage(named:) was being called from the main thread, and for one specific kind of content it hung for 800ms. Crashlytics never flagged it because it never crashed. Without MetricKit, I would have only learned about it from a one-star review saying "the first screen freezes."
My fix policy is simple. Reports with hangDuration over 500ms become P0 and ship in the next release. 250-500ms reports queue for the release after. From the callStackTree I work backward through the typical offenders:
Synchronous file I/O on the main thread → push it to Task.detached
Synchronous image decoding → anything other than Image(systemName:) should run async
Large array JSON.parse → in React Native, swap for react-native-mmkv or another stream-friendly store
Reviewing Battery Drain and Scroll Quality on a Weekly Cadence
Aggregating cpuMetrics, gpuMetrics, displayMetrics, and applicationLaunchMetrics weekly turns MetricKit from a passive logger into a continuous improvement loop you can actually run as a single developer.
The flow I follow every week:
Every Monday morning, a Cloudflare Workers Cron Trigger aggregates the last 7 days of payloads and posts the summary to Slack
The summary shows: launch time P50/P95, hangs per day, scroll-rate adherence, average battery drain (mAh/h)
If launch P95 has regressed by more than 100ms versus last week, an investigation task gets created that week
If hangs trend upward over a month, refactoring becomes a top sprint priority
The full Cron Trigger code doesn't fit in this article, but the high-level approach uses D1's JSON1 functions: SELECT json_extract(payload_json, '$.applicationLaunchMetrics.histogrammedTimeToFirstDraw') ... and aggregates the histograms in the SQL layer.
Decoding Launch Time — Reading applicationLaunchMetrics by Phase
When launch P95 regresses, applicationLaunchMetrics is the fastest path to a root cause. It breaks "tap to first draw" into four phases:
histogrammedTimeToFirstDraw: from tap to the first drawRect completion
histogrammedApplicationResumeTime: time to come forward from background
histogrammedOptimizedTimeToFirstDraw: the iOS 16+ optimized-launch path
histogrammedApplicationLaunchTime (legacy): kept for older iOS compatibility
Each histogram comes back as an array of { bucketStart, bucketEnd, bucketCount } triples. Store the array in D1 and let your dashboard aggregate it.
-- P95 launch time over the last 7 daysWITH launch_buckets AS ( SELECT json_extract(payload_json, '$.applicationLaunchMetrics.histogrammedTimeToFirstDraw.histogramValue') AS hist, received_at FROM metric_payloads WHERE type = 'metric' AND received_at > date('now', '-7 days'))-- Expand each bucket and compute P95 in the workerSELECT * FROM launch_buckets;
Computing P95 across histogram buckets is awkward in pure SQL, so I expand the JSON in the Worker and compute it in JS. The crucial caveat is to filter out segments with fewer than 10 launches per day (a particular OS version or device model). A handful of samples will produce wildly skewed P95s and lead you to optimize the wrong thing.
In one of my apps, the launch P95 on iPhone 11 Pro and older was 1.8 seconds, while iPhone 14 and newer hit 0.6 seconds. A flat goal of "under 1 second" would have caused me to over-optimize the new devices and write off the old ones. Set per-device-class targets — that's the discipline that keeps your improvements honest.
Memory Pressure — Inferring Forced Termination via MXAppRunTimeMetric
MetricKit is intentionally light on memory diagnostics. The closest signal is MXAppRunTimeMetric.cumulativeForegroundTime and cumulativeBackgroundTime, which lets you indirectly spot terminations driven by memory pressure. When a MXDiagnosticPayload arrives with cpuExceptionDiagnostics or diskWriteExceptionDiagnostics, correlate it with the foreground/background time of that session.
Where this becomes useful in practice is identifying "the release that started getting killed by the memory cop." If foreground time drops sharply on a specific app version, and the exit-data points at memory-related causes, odds are something you shipped in that release leaks. I track per-release foreground-time P50 specifically to catch this. If it drops more than 20% versus the previous release, leak investigation jumps to the top of the queue.
A Weekly Slack Report — The Final Piece
Once the aggregation pipeline runs, the missing piece is delivering the digest somewhere you can't ignore. I post mine to Slack via a webhook every Monday morning.
Start with a small set of metrics whose direction tells you something actionable. In my first three months I reduced the digest down to two specific alerts: a launch P95 regression and a "hangs over three per day" warning. Notification fatigue is real — keep the rule "only signals that map to a decision" in front of you, and the system stays useful.
Adding Custom Instrumentation with MXSignpost
MetricKit covers system-level metrics. When you need to time something specific — "from button tap to navigation complete," for instance — you reach for MXSignpost. It's the MetricKit-aware version of os_signpost, and the durations show up in MXMetricPayload.signpostMetrics.
import MetricKitimport os.signpostprivate let signpostLog = OSLog(subsystem: "com.example.myapp", category: .pointsOfInterest)func performExpensiveImageUpload() async { let signpostID = OSSignpostID(log: signpostLog) os_signpost(.begin, log: signpostLog, name: "image_upload", signpostID: signpostID) defer { os_signpost(.end, log: signpostLog, name: "image_upload", signpostID: signpostID) } // The actual upload work await uploadImageToServer()}
To trigger the same instrumentation from the JS side, expose startSignpost(name:) / endSignpost(name:) on the MetricKitBridgeModule and you can measure React Native code paths the same way. I personally measure both Stripe checkout latency and the time from "buy button tap" to "checkout sheet visible," because those are the moments where users are most likely to abandon and a 200ms regression really matters.
Migrating from Crashlytics — How to Decide When to Cut Over
If you're already running Crashlytics, you don't need to rip it out the moment you wire up MetricKit. The smoothest migration path I've used is running both side by side for the first three months, then dropping Crashlytics once the data tells a clear story.
Three things to verify during that overlap. First, hang detection coverage. Crashlytics' iOS ANR detection is fairly coarse, while MetricKit's hangDiagnostics catches hangs at far finer resolution. Stack the reports side by side and watch for the 250-500ms hang band that Crashlytics tends to miss. In my own data, MetricKit logged 217 hangs in month one while Crashlytics flagged exactly zero in that band.
Second, crash report coverage gaps. Crashlytics can miss "pre-init crashes" — crashes that happen before its SDK has finished initializing — while MetricKit captures these via the OS path. Diffing the two has surfaced cases of memory corruption I hadn't realized were happening at launch on a specific iPhone model. Third, bundle size and launch time impact. Removing Crashlytics dropped my app's IPA size by roughly 2.1 MB and improved launch P95 by about 60ms — non-trivial gains in an indie app.
Once the data is in and you're confident you can replicate Crashlytics' bonus features (Slack alerts, grouping), the cutover is small. Slack alerts you've already implemented in the Cron Trigger above. The only piece you'll need to rebuild is grouping. I keep it minimal: a 20-line script that hashes binaryName + offsetIntoBinaryTextSegment to produce a stable group key.
Privacy and Regulation — Living with ATT and GDPR
I described MetricKit as "privacy-friendly observability," but that needs a footnote. The reports themselves are anonymous, but the moment you send them to your own backend, you're back inside developer responsibility.
The line to hold is concrete. App version, OS version, and device model are fine to send without ATT consent. Tying any of that to a user identifier (IDFA, the value from ASIdentifierManager, or your own login user ID) crosses into tracking under ATT. You can ask for consent, but in practice opt-in is somewhere around 20-40%, so the cleanest design is "don't send user identifiers at all." It's also the easiest to maintain and to explain in your privacy policy.
For GDPR, MetricKit reports as I've described them here are not personal data. A single paragraph in your privacy policy is enough: "This app uses Apple's MetricKit to send anonymous, aggregated performance metrics to our servers. These reports do not include any user identifiers." I've used effectively the same wording across four shipped apps and never had a regulator question it — though if you're scaling into real business territory, please get a lawyer to bless your specific copy.
Common Pitfalls
If you've made it this far, you have enough to start tomorrow. Let me share the traps I've personally fallen into so you can skip them.
1. Wasting a Day Wondering Why Reports Aren't Arriving on a Debug Build
MXMetricManager only delivers payloads on TestFlight or App Store builds. Builds you launched directly from Xcode, including wireless debug, never receive any reports. This is intentional — Apple avoids double-counting with Xcode's own Energy Log. I missed this on my first attempt and spent a full day "fixing" my bridge before realizing nothing was actually broken. For local development, use MXMetricManager.makeLogHandle(category:) to fire synthetic test events instead.
2. Shipping Without a payloads.slice(0, 100) Cap
In an early version I shipped with no payload-count cap. Most users were fine. Then a single user, who'd left their phone on a shelf for two weeks while traveling, returned and spilled fourteen days of payloads in one request. My D1 free tier was exhausted by lunchtime. Always cap the per-request payload count and apply rate limiting on the server.
3. Forgetting to Upload dSYMs and Losing All Symbols
Without dSYMs delivered to Apple, every call stack reads as MyApp + 0x12345. With Rork, eas build typically uploads dSYMs as part of TestFlight delivery, but if you've customized the build profile or added a manual archive step, you may need to enable it explicitly. In eas.json, setting submitToAppStore: true on the production profile tends to handle dSYMs automatically.
4. Attaching Identifiers and Quietly Becoming a Tracker
MetricKit payloads themselves contain no personal data. But it's tempting to attach metadata when uploading. Once I added a userId to metric_payloads "for debugging," and that crossed the line from "diagnostic" to "tracking" under ATT. Keep your attached metadata to app version, OS, and device model only. Never include user identifiers — that's the rule that keeps this work consent-free.
Where to Go Next — Connecting MetricKit to Langfuse and OpenTelemetry
MetricKit covers the native shell of your app. For apps that use AI features, you'll want a separate trace for LLM calls — cost, latency, hallucination detection. I use Langfuse for that, as I covered in A Production Observability Guide for AI Apps Using Rork + Langfuse. The split looks like this:
Langfuse: AI quality (LLM cost, hallucinations, prompt regressions)
Sentry (only if needed): real-time JS exception alerts
iOS 17 onward, several open-source efforts are starting to translate MetricKit reports into OpenTelemetry — Apple's swift-distributed-tracing is one base. Long term, all three pipelines may collapse into a single backend.
Closing Thought
Thanks for sticking with me to the end. One concrete action to take tomorrow:
Scaffold an Expo Module, drop in a single MXMetricManager.shared.add(self) subscriber, and ship it to TestFlight. You don't need the backend yet — the moment of truth is when the first MXMetricPayload actually arrives. That's when you finally see what your app looks like out in the wild, and the rest of the pipeline becomes a series of small, incremental decisions on top of real data instead of guesses.
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.