mirror of
https://github.com/hanzoai/insights-js.git
synced 2026-11-03 00:53:53 +00:00
* perf(react-native): coalesce storage writes with a debounce window RN persists the whole storage blob on every change, so a burst of captures re-serializes a growing blob N times (O(n^2)) and does N disk writes. Batch writes into a 100ms window while keeping the in-memory copy current; event flush, AppState background, shutdown, and login/logout still drain synchronously so the queue and identity stay durable. Refine captureException durability. Drain both storage pipelines to disk only when the exception is fatal (tagged via $exception_level), rather than unconditionally on every captureException: console autocapture turns every console.error/warn into a captureException, so an unconditional drain would re-introduce write amplification. Handled exceptions persist via the normal debounce. Soften the durability comments to say writes are initiated (best-effort, async) rather than guaranteed to land before a crash. - Drain on optIn() / optOut(). Consent must be durable: a hard-kill within the debounce window before any flush/background/shutdown would lose the OptedOut write, and the optedOut getter falls back to !defaultOptIn so capture silently resumes for a user who explicitly opted out. Same crash-window the PR closes for logout/identify. - New storage test: the debounced write fires on its own after the window. Every other functional test forced the write via waitForPersist, so a regression where the timer never armed would ship green. - New storage test: the timer is not reset by later mutations (arm-once, no starvation). Guards against a textbook clear-and-reschedule debounce. - Refined the fatal-exception assertion to check the parsed events queue for an item whose message.event is \$exception, rather than a substring match that could pass on only the level tag round-tripping. - Trimmed the verbose drain-site comments and centralized the waitForPersist contract on the method doc (addresses the PR feedback). - Updated the changeset to note custom-storage write-timing and the ~100ms loss window.
99 lines
4.0 KiB
TypeScript
99 lines
4.0 KiB
TypeScript
import { useState } from 'react'
|
|
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
|
|
import PostHog from 'posthog-react-native'
|
|
|
|
// Counting storage: every setItem is one disk write. We track the count and the
|
|
// bytes serialized so the screen can show what a burst of captures actually
|
|
// costs at the storage layer.
|
|
let writeCount = 0
|
|
let totalBytes = 0
|
|
const cache: Record<string, string> = {}
|
|
const countingStorage = {
|
|
getItem: (key: string) => cache[key] ?? null,
|
|
setItem: (key: string, value: string) => {
|
|
writeCount += 1
|
|
totalBytes += value.length
|
|
cache[key] = value
|
|
},
|
|
}
|
|
|
|
// Dedicated instance for this demo. flushAt is set high so a burst stays in one
|
|
// batch and we observe storage *write coalescing* rather than a flush-per-capture.
|
|
// (The main app instance in app/posthog.tsx uses flushAt: 1, which drains storage
|
|
// on every capture and would hide the effect.)
|
|
const burstPostHog = new PostHog(process.env.EXPO_PUBLIC_POSTHOG_PROJECT_API_KEY ?? 'phc_storage_burst_demo', {
|
|
host: process.env.EXPO_PUBLIC_POSTHOG_API_HOST,
|
|
flushAt: 1000,
|
|
captureAppLifecycleEvents: false,
|
|
customStorage: countingStorage,
|
|
})
|
|
|
|
const BURST_SIZE = 100
|
|
|
|
export default function StorageBurstScreen() {
|
|
const [result, setResult] = useState<{ writes: number; kb: string } | null>(null)
|
|
const [running, setRunning] = useState(false)
|
|
|
|
const fireBurst = async (): Promise<void> => {
|
|
setRunning(true)
|
|
setResult(null)
|
|
const beforeWrites = writeCount
|
|
const beforeBytes = totalBytes
|
|
|
|
for (let i = 0; i < BURST_SIZE; i++) {
|
|
burstPostHog.capture('storage_burst_event', { index: i })
|
|
}
|
|
|
|
// Writes are debounced, so the coalesced write lands on a later tick.
|
|
// Wait past the window before reading the counters.
|
|
await new Promise((resolve) => setTimeout(resolve, 300))
|
|
|
|
setResult({
|
|
writes: writeCount - beforeWrites,
|
|
kb: ((totalBytes - beforeBytes) / 1024).toFixed(1),
|
|
})
|
|
setRunning(false)
|
|
}
|
|
|
|
return (
|
|
<ScrollView contentContainerStyle={styles.container}>
|
|
<Text style={styles.title}>Storage write coalescing</Text>
|
|
<Text style={styles.body}>
|
|
Fires {BURST_SIZE} captures in a tight loop and counts the disk writes the SDK performs. With
|
|
debounced coalescing, the burst collapses into a single write instead of one per capture.
|
|
</Text>
|
|
|
|
<Pressable
|
|
style={[styles.button, running && styles.buttonDisabled]}
|
|
onPress={fireBurst}
|
|
disabled={running}
|
|
testID="fire-burst"
|
|
>
|
|
<Text style={styles.buttonText}>{running ? 'Running…' : `Fire ${BURST_SIZE} events`}</Text>
|
|
</Pressable>
|
|
|
|
{result && (
|
|
<View style={styles.result} testID="burst-result">
|
|
<Text style={styles.resultLine}>Captures fired: {BURST_SIZE}</Text>
|
|
<Text style={styles.resultBig}>Disk writes: {result.writes}</Text>
|
|
<Text style={styles.resultLine}>Bytes serialized: {result.kb} KB</Text>
|
|
<Text style={styles.resultHint}>Without coalescing this would be {BURST_SIZE} writes.</Text>
|
|
</View>
|
|
)}
|
|
</ScrollView>
|
|
)
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: { padding: 24, paddingTop: 80, gap: 16 },
|
|
title: { fontSize: 22, fontWeight: '700' },
|
|
body: { fontSize: 15, lineHeight: 21, opacity: 0.8 },
|
|
button: { backgroundColor: '#1d4aff', paddingVertical: 14, paddingHorizontal: 20, borderRadius: 10, alignItems: 'center' },
|
|
buttonDisabled: { opacity: 0.5 },
|
|
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
|
result: { gap: 6, padding: 16, borderRadius: 10, backgroundColor: 'rgba(127,127,127,0.12)' },
|
|
resultLine: { fontSize: 15 },
|
|
resultBig: { fontSize: 24, fontWeight: '700', color: '#1d4aff' },
|
|
resultHint: { fontSize: 13, opacity: 0.7, marginTop: 4 },
|
|
})
|