mirror of
https://github.com/hanzoai/insights-js.git
synced 2026-11-03 00:53:53 +00:00
* feat(logs): flush RN logs queue to /i/v1/logs with batching Outbound logs pipeline for the RN SDK, building on the storage work in feat/logs-storage. Intentionally does not expose any public API — that lands in a follow-up so the full pipeline can be validated end-to-end before users can call it. Core (packages/core/src/logs): - PostHogLogs.flush() drains LogsQueue in maxBatchRecordsPerPost slices, builds OTLP payloads, POSTs each via the new _sendLogsBatch helper. Threshold + timer + AppState triggers mirror the events pipeline. - _sendLogsBatch on PostHogCoreStateless encapsulates URL + gzip + fetchWithRetry, returns a SendLogsBatchOutcome tagged union so PostHogLogs doesn't need to know about the core's HTTP error classes. - Concurrent flushes serialize via _flushPromise; 413 halves batch size and retries; network errors keep records queued; fatal errors drop the batch and re-throw. - beforeSend hook accepts single fn or array, mirrors the events-side _runBeforeSend semantics: try/catch per fn so a buggy filter can never crash the caller's captureLog. - Rate limiter (fixed tumbling window) ports browser's maxLogsPerInterval enforcement into core. Handles backward wall-clock jumps by treating a negative elapsed as window-expired, so an NTP correction or manual device-clock change can't leave the cap stuck. - shutdown(timeoutMs?) clears the flush timer and races the final flush against the budget so a stalled transport can't hang shutdown. RN wiring (packages/react-native/src): - AppState listener now drains logs alongside events with logFlushError for console visibility. - _shutdown override runs _logs.shutdown and super._shutdown in parallel under a shared timeout budget. - _waitForStoragePersist closure wires the logs-storage waitForPersist into core so the queue-advance hits disk before the next batch sends — prevents replay duplicates on crash between batches. - resolveLogsConfig now fills flushIntervalMs, maxBatchRecordsPerPost, rateCapWindowMs, and maxLogsPerInterval from the existing DEFAULT_* constants. Tests: +102 core logs tests, +4 RN integration tests. 504 core + 286 RN pass. Lint clean, TS clean. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * feat(logs): expose captureLog + logger + options.logs on RN PostHog Public surface for the logs feature. Users can now call `posthog.captureLog(...)` / `posthog.logger.info(...)` and configure the pipeline via `new PostHog(key, { logs: { ... } })`. The flush pipeline, rate limiter, beforeSend hook, and shutdown integration already landed in the previous commit; this PR is the user-facing seam on top. RN (packages/react-native/src/posthog-rn.ts): - Adds `logs?: PostHogLogsConfig` to `PostHogOptions`. - Wires `options.logs` into `PostHogLogs` via `resolveLogsConfig(options?.logs)` (replaces the hardcoded `resolveLogsConfig(undefined)` TODO). - Adds `captureLog(options: CaptureLogOptions): void` — low-level primitive. - Adds lazy `logger` getter returning a `CaptureLogger` with trace/debug/info/warn/error/fatal convenience methods. Memoized on first access. Shape matches the browser SDK's public logger at `packages/browser/src/posthog-logs.ts:135-147`. Types (packages/core/src/index.ts, packages/react-native/src/index.ts): - Exports `BeforeSendLogFn`, `CaptureLogger`, `CaptureLogOptions`, `LogAttributes`, `LogAttributeValue`, `LogSeverityLevel`, and `PostHogLogsConfig` from both package barrels so consumers can type their own wrappers (hooks, HOCs, redactors) without importing from `@posthog/core` internals. Tests (+6 in packages/react-native/test/posthog.spec.ts): - `posthog.captureLog` → LogsQueue with correct record shape. - `posthog.logger.*` maps each method to the correct OTLP severity. - `posthog.logger` is lazy + memoized (same instance on repeated access). - `options.logs.beforeSend` filters/transforms through the public path. - `options.logs.maxLogsPerInterval` caps captures end-to-end. - `options.logs.enabled=false` keeps records out of the queue entirely. 504 core + 292 RN pass. Lint clean, TS clean. Deferred: console auto-capture (#5) — separate PR. Browser migration to core's `PostHogLogs` — separate PR, blocked on sendBeacon transport override and endpoint routing. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * update example app * document flush and flushLog * feat(logs): tag records with platform + screen + app state + flags Per-batch resource attrs: telemetry.sdk.{name,version} (core, from getLibraryId/Version) and os.{name,version} (RN, from Platform). Per-record context: screen.name (from sessionProps.$screen_name), app.state (from AppState, mapped to foreground|background), and feature_flags (Object.keys of getFeatureFlags, length-gated). User-supplied resourceAttributes still override the auto-populated keys. No effect on the events pipeline. * feat(logs): align RN logs gate with JS SDK and address review feedback Switches the logs feature to opt-in (captureConsoleLogs: true, default off) to match the JS SDK at packages/browser/src/posthog-logs.ts:31-49. Either local config or remote response.logs.captureConsoleLogs can opt in; remote false is an explicit kill-switch. Other review fixes: - Self-heal the 413 batch shrink: each successful send ramps _maxBatchRecordsPerPost back up by 1 toward the configured cap so a one-off oversized payload does not permanently degrade throughput. - Wire backgroundFlushBudgetMs / terminationFlushBudgetMs into the AppState background flush and _shutdown so the declared budgets actually bound flush time. - Protect SDK-identity resource attrs (telemetry.sdk.*, service.name) from accidental override by user resourceAttributes. - Warn on the size-1 413 drop so an oversized record is visible in logs instead of silently disappearing. - Document the captureLog naming collision with sessionReplayConfig, the AppState listener leak window, and the pre-init rate-cap counter. - Parameterise the simple beforeSend and rate-limit specs into test.each tables. * test(logs): emit AppState transition logs in example app for dogfooding Adds an AppState listener in the example's Logs screen that emits a `logger.info` on every transition. Lets us exercise the SDK's `app.state` tagging and `flushWithTimeout(backgroundFlushBudgetMs)` path without manual button taps, since the SDK's own AppState listener runs first and updates `_currentAppState` synchronously before the example's hook fires. Bumps the example's pnpm-lock.yaml to match the workspace tarball versions of @posthog/core (1.27.7) and posthog-react-native (4.43.10). * refactor(logs): switch remote-config gate to AND semantics Aligns RN logs gating with the session-replay `consoleLogRecordingEnabled` precedent (and other mobile-config patterns) instead of the JS SDK's monotonic-ON model. New rule: enabled = local AND remote != false. - Server can disable a locally-on feature. - Server cannot turn capture on for a user who has not opted in locally. - Remote silent (undefined) leaves the existing decision unchanged. Reasoning: mobile users rarely expect the server to silently start collecting data they did not ask for locally; flipping a remote toggle to start capture without a fresh app build is more surprising on RN than in browser. The JS SDK divergence is documented inline. Updates the corresponding tests and the JSDoc on setRemoteEnabled + _isCaptureEnabled to make the AND semantics explicit. * address comments with file names * trim comments * remove autocapture bool for now * add changeset * update comments * remove kill switch this is for auto capture only * clean up comments * refactor(logs): nest rateCap config + privatize lifecycle budgets Public PostHogLogsConfig now uses rateCap: { maxLogs, windowMs } instead of the flat maxLogsPerInterval + rateCapWindowMs pair, so the unit on maxLogs is self-documenting. ResolvedPostHogLogsConfig keeps flat names internally for hot-path terseness; the host SDK unwraps in resolveLogsConfig. Also drops backgroundFlushBudgetMs and terminationFlushBudgetMs from the public config - they are SDK-managed mobile-lifecycle internals (iOS beginBackgroundTask budget, etc.), not customer-tuneable. Both stay on ResolvedPostHogLogsConfig and get the same defaults as before. * test(logs): expand size-1 413 coverage Adds three cases on top of the existing single-record drop test: - explicit warn line is logged on size-1 413 (visibility for the lost record so it doesn't disappear silently) - pipeline continues draining after a size-1 drop (one bad record doesn't stall subsequent records) - the drop fires immediately when there's only one record in the queue (no spurious halving rounds before the drop) * refactor(logs): move LogSdkContext to @posthog/core The type is only consumed through `PostHogLogs._getContext` (an SDK-internal constructor argument). It was leaking into customer-facing @posthog/types autocomplete despite never being a type customers should write against. Moves the definition to packages/core/src/logs/types.ts and re-exports from @posthog/core. Browser SDK's types barrel re-imports from there. * docs(logs): add JSDoc to public API surface for IDE tooltips PostHogLogsConfig fields previously relied on inline `//` comments which don't surface in IDE tooltips. Each field now has a JSDoc with: what it does, the default, when to tune it, and any non-obvious interaction with other fields (e.g. 413 retry shape on maxBatchRecordsPerPost, eviction on maxBufferSize). Logger interface methods (in @posthog/types) now describe each severity level so customers see severity guidance when they autocomplete posthog.logger.{trace,debug,info,...}. BeforeSendLogFn gets two examples: redaction and severity-based filter. RN's PostHogOptions.logs gets a top-level summary plus minimal + high-volume usage examples. * refactor(example): toggle beforeSend via closure instead of SDK reach-in The /logs dev-tools panel previously mutated `(posthog as any)._logs?._config.beforeSend` at runtime to flip filter behavior. That works but reaches into private SDK internals - the wrong pattern to demonstrate in a customer-facing example. The example app now exports a `beforeSendMode` ref from posthog.tsx; the actual `beforeSend` closure reads it on every capture. Flipping the mode mutates the shared ref, no internals required. Comment in posthog.tsx calls this out as the pattern customers should copy for any runtime-tunable filter. --------- Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
340 lines
13 KiB
TypeScript
340 lines
13 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { AppState, Button, ScrollView, StyleSheet, View } from 'react-native'
|
|
import { PostHogPersistedProperty } from 'posthog-react-native'
|
|
|
|
import ParallaxScrollView from '@/components/ParallaxScrollView'
|
|
import { ThemedText } from '@/components/ThemedText'
|
|
import { ThemedView } from '@/components/ThemedView'
|
|
import { IconSymbol } from '@/components/ui/IconSymbol'
|
|
import { beforeSendMode, posthog } from '../posthog'
|
|
|
|
type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'
|
|
type BeforeSendMode = 'pass' | 'drop' | 'throw'
|
|
|
|
interface DevStatus {
|
|
distinctId: string | null
|
|
sessionId: string | null
|
|
appState: 'foreground' | 'background' | null
|
|
}
|
|
|
|
export default function LogsScreen() {
|
|
const [status, setStatus] = useState('Idle')
|
|
const [counter, setCounter] = useState(0)
|
|
const [devStatus, setDevStatus] = useState<DevStatus>({
|
|
distinctId: null,
|
|
sessionId: null,
|
|
appState: null,
|
|
})
|
|
// Mirror of `beforeSendMode.current` for UI display. The actual filter
|
|
// reads from the module-level ref in posthog.tsx.
|
|
const [beforeSendModeUI, setBeforeSendModeUI] = useState<BeforeSendMode>(beforeSendMode.current)
|
|
const [queueDump, setQueueDump] = useState<string>('')
|
|
|
|
const refresh = (): void => {
|
|
setDevStatus({
|
|
distinctId: posthog.getDistinctId() || null,
|
|
sessionId: posthog.getSessionId() || null,
|
|
appState: ((posthog as any)._currentAppState ?? null) as DevStatus['appState'],
|
|
})
|
|
}
|
|
|
|
// Emit a log on every AppState transition so the SDK's `app.state`
|
|
// tagging path can be exercised without manual taps. The PostHog
|
|
// constructor registers its own AppState listener first, which updates
|
|
// `_currentAppState` synchronously *before* this hook's body runs, so
|
|
// the captured record reads the new state.
|
|
useEffect(() => {
|
|
let prev: string = AppState.currentState
|
|
const sub = AppState.addEventListener('change', (next) => {
|
|
posthog.logger.info(`AppState ${prev} → ${next}`, { from: prev, to: next })
|
|
prev = next
|
|
refresh()
|
|
})
|
|
refresh()
|
|
return () => sub.remove()
|
|
}, [])
|
|
|
|
const bump = (message: string): void => {
|
|
const next = counter + 1
|
|
setCounter(next)
|
|
setStatus(`${message} (#${next} @ ${new Date().toISOString().slice(11, 19)})`)
|
|
}
|
|
|
|
// ===== Existing capture buttons =====
|
|
|
|
const sendOne = (level: LogLevel): void => {
|
|
posthog.logger[level](`${level} log from example-expo-53`, {
|
|
source: 'logs-tab',
|
|
level,
|
|
tsClient: Date.now(),
|
|
})
|
|
bump(`Sent ${level}`)
|
|
}
|
|
|
|
const sendAllLevels = (): void => {
|
|
const levels: LogLevel[] = ['trace', 'debug', 'info', 'warn', 'error', 'fatal']
|
|
levels.forEach((level, i) => {
|
|
posthog.logger[level](`Level-sweep message (${level}) #${i}`, { sweep: true, index: i })
|
|
})
|
|
bump(`Sent all 6 levels`)
|
|
}
|
|
|
|
const sendStructured = (): void => {
|
|
posthog.captureLog({
|
|
body: 'Structured attributes payload',
|
|
level: 'info',
|
|
attributes: {
|
|
userId: 'user-42',
|
|
feature: 'checkout',
|
|
durationMs: 123,
|
|
tags: ['beta', 'ios'],
|
|
nested: { foo: 'bar', count: 3 },
|
|
},
|
|
})
|
|
bump('Sent structured attrs')
|
|
}
|
|
|
|
const sendBurst = (): void => {
|
|
for (let i = 0; i < 20; i++) {
|
|
posthog.logger[i % 2 === 0 ? 'info' : 'debug'](`Burst log #${i}`, { i })
|
|
}
|
|
bump('Sent 20-burst')
|
|
}
|
|
|
|
const sendFlood = (): void => {
|
|
// Hit the rate-cap window (default 500/10s on RN). Should emit
|
|
// 500-ish and then warn + drop.
|
|
for (let i = 0; i < 600; i++) {
|
|
posthog.logger.info(`Flood #${i}`, { i, flood: true })
|
|
}
|
|
bump('Sent 600-flood (expect rate cap)')
|
|
}
|
|
|
|
const sendError = (): void => {
|
|
posthog.logger.error('Simulated error with stack', {
|
|
errorName: 'SimulatedError',
|
|
errorMessage: 'Something bad happened',
|
|
stack: new Error('Simulated').stack ?? '',
|
|
})
|
|
bump('Sent error+stack')
|
|
}
|
|
|
|
const flushNow = async (): Promise<void> => {
|
|
try {
|
|
// Drain BOTH pipelines — events on `flush()`, logs on `flushLogs()`.
|
|
// Run in parallel so a slow events flush doesn't delay logs and
|
|
// vice-versa.
|
|
await Promise.all([posthog.flush(), posthog.flushLogs()])
|
|
bump('Manual flush ok')
|
|
} catch (e) {
|
|
setStatus(`Flush error: ${e}`)
|
|
}
|
|
}
|
|
|
|
// ===== Dev tools =====
|
|
//
|
|
// Status reads `_currentAppState` via a reach-in because there's no
|
|
// public getter for the SDK's cached AppState. Reach-ins like this are
|
|
// ONLY appropriate in dogfood-style examples; production wrappers
|
|
// should stick to the public surface (`captureLog`, `logger.*`,
|
|
// `flushLogs`).
|
|
|
|
const setBefore = (mode: BeforeSendMode): void => {
|
|
// Update the shared ref in posthog.tsx — the `beforeSend` closure
|
|
// reads it on every capture, so behavior switches at runtime
|
|
// without touching SDK internals. This is the public-API-only
|
|
// pattern customers should follow for runtime-tunable filters.
|
|
beforeSendMode.current = mode
|
|
setBeforeSendModeUI(mode)
|
|
bump(`beforeSend = ${mode}`)
|
|
}
|
|
|
|
const captureNoLevel = (): void => {
|
|
// Verifies default-level INFO behaviour without a `level` arg.
|
|
posthog.captureLog({ body: 'no-level test (defaults to INFO)' })
|
|
bump('Sent no-level (default INFO)')
|
|
}
|
|
|
|
const captureEmptyBody = (): void => {
|
|
posthog.captureLog({ body: '' })
|
|
bump('Sent empty body (should be silently dropped)')
|
|
}
|
|
|
|
const captureNoBody = (): void => {
|
|
posthog.captureLog({} as any)
|
|
bump('Sent no body (should be silently dropped)')
|
|
}
|
|
|
|
const dumpQueue = (): void => {
|
|
const queue = posthog.getPersistedProperty(PostHogPersistedProperty.LogsQueue) as unknown[] | undefined
|
|
const text = queue
|
|
? `length=${queue.length}\n${JSON.stringify(queue, null, 2).slice(0, 4000)}`
|
|
: '(empty/undefined)'
|
|
setQueueDump(text)
|
|
}
|
|
|
|
const screenAndCapture = async (name: string): Promise<void> => {
|
|
await posthog.screen(name)
|
|
posthog.logger.info(`captured on ${name}`, { screenTagTest: true })
|
|
bump(`screen('${name}') + capture`)
|
|
}
|
|
|
|
const callIdentify = (id: string): void => {
|
|
posthog.identify(id, { source: 'logs-tab' })
|
|
refresh()
|
|
bump(`identify('${id}')`)
|
|
}
|
|
|
|
const callReset = (): void => {
|
|
posthog.reset()
|
|
refresh()
|
|
bump('reset()')
|
|
}
|
|
|
|
const callOptOut = async (): Promise<void> => {
|
|
await posthog.optOut()
|
|
refresh()
|
|
bump('optOut()')
|
|
}
|
|
|
|
const callOptIn = async (): Promise<void> => {
|
|
await posthog.optIn()
|
|
refresh()
|
|
bump('optIn()')
|
|
}
|
|
|
|
return (
|
|
<ParallaxScrollView
|
|
headerBackgroundColor={{ light: '#FFE6B3', dark: '#3D2E1D' }}
|
|
headerImage={<IconSymbol size={310} color="#808080" name="text.bubble.fill" style={styles.headerImage} />}
|
|
>
|
|
<ThemedView style={styles.titleContainer}>
|
|
<ThemedText type="title">Logs</ThemedText>
|
|
</ThemedView>
|
|
<ThemedText>Status: {status}</ThemedText>
|
|
<ScrollView style={styles.scroll}>
|
|
<View style={styles.section}>
|
|
<ThemedText type="subtitle">Single level</ThemedText>
|
|
<View style={styles.row}>
|
|
<Button title="trace" onPress={() => sendOne('trace')} />
|
|
<Button title="debug" onPress={() => sendOne('debug')} />
|
|
<Button title="info" onPress={() => sendOne('info')} />
|
|
</View>
|
|
<View style={styles.row}>
|
|
<Button title="warn" onPress={() => sendOne('warn')} />
|
|
<Button title="error" onPress={() => sendOne('error')} />
|
|
<Button title="fatal" onPress={() => sendOne('fatal')} />
|
|
</View>
|
|
</View>
|
|
<View style={styles.section}>
|
|
<ThemedText type="subtitle">Mixed</ThemedText>
|
|
<Button title="All 6 levels" onPress={sendAllLevels} />
|
|
<Button title="Structured attributes" onPress={sendStructured} />
|
|
<Button title="Error + stack" onPress={sendError} />
|
|
</View>
|
|
<View style={styles.section}>
|
|
<ThemedText type="subtitle">Volume</ThemedText>
|
|
<Button title="Burst 20" onPress={sendBurst} />
|
|
<Button title="Flood 600 (rate cap)" onPress={sendFlood} />
|
|
</View>
|
|
<View style={styles.section}>
|
|
<ThemedText type="subtitle">Control</ThemedText>
|
|
<Button title="Flush now" onPress={flushNow} />
|
|
</View>
|
|
|
|
<View style={styles.divider} />
|
|
<ThemedText type="subtitle">Dev tools</ThemedText>
|
|
|
|
<View style={styles.section}>
|
|
<ThemedText type="defaultSemiBold">Status</ThemedText>
|
|
<ThemedText>distinctId: {devStatus.distinctId ?? '<none>'}</ThemedText>
|
|
<ThemedText>sessionId: {devStatus.sessionId ?? '<none>'}</ThemedText>
|
|
<ThemedText>app.state: {devStatus.appState ?? '<unknown>'}</ThemedText>
|
|
<ThemedText>beforeSend mode: {beforeSendModeUI}</ThemedText>
|
|
<Button title="Refresh status" onPress={refresh} />
|
|
</View>
|
|
|
|
<View style={styles.section}>
|
|
<ThemedText type="defaultSemiBold">Identity</ThemedText>
|
|
<View style={styles.row}>
|
|
<Button title="identify(alice)" onPress={() => callIdentify('alice')} />
|
|
<Button title="identify(bob)" onPress={() => callIdentify('bob')} />
|
|
</View>
|
|
<View style={styles.row}>
|
|
<Button title="reset()" onPress={callReset} />
|
|
<Button title="optOut()" onPress={callOptOut} />
|
|
<Button title="optIn()" onPress={callOptIn} />
|
|
</View>
|
|
</View>
|
|
|
|
<View style={styles.section}>
|
|
<ThemedText type="defaultSemiBold">Screen tagging</ThemedText>
|
|
<Button title="screen('Checkout') + capture" onPress={() => screenAndCapture('Checkout')} />
|
|
<Button title="screen('Profile') + capture" onPress={() => screenAndCapture('Profile')} />
|
|
</View>
|
|
|
|
<View style={styles.section}>
|
|
<ThemedText type="defaultSemiBold">Edge captures</ThemedText>
|
|
<Button title="captureLog (no level → INFO)" onPress={captureNoLevel} />
|
|
<Button title="captureLog (empty body)" onPress={captureEmptyBody} />
|
|
<Button title="captureLog (no body)" onPress={captureNoBody} />
|
|
</View>
|
|
|
|
<View style={styles.section}>
|
|
<ThemedText type="defaultSemiBold">beforeSend hook</ThemedText>
|
|
<View style={styles.row}>
|
|
<Button title="pass-through" onPress={() => setBefore('pass')} />
|
|
<Button title="drop (return null)" onPress={() => setBefore('drop')} />
|
|
<Button title="throw" onPress={() => setBefore('throw')} />
|
|
</View>
|
|
</View>
|
|
|
|
<View style={styles.section}>
|
|
<ThemedText type="defaultSemiBold">Storage</ThemedText>
|
|
<Button title="Dump LogsQueue" onPress={dumpQueue} />
|
|
<ScrollView style={styles.dump}>
|
|
<ThemedText>{queueDump || '(tap Dump to inspect)'}</ThemedText>
|
|
</ScrollView>
|
|
</View>
|
|
</ScrollView>
|
|
</ParallaxScrollView>
|
|
)
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
headerImage: {
|
|
color: '#808080',
|
|
bottom: -90,
|
|
left: -35,
|
|
position: 'absolute',
|
|
},
|
|
titleContainer: {
|
|
flexDirection: 'row',
|
|
gap: 8,
|
|
},
|
|
scroll: {
|
|
marginTop: 12,
|
|
},
|
|
section: {
|
|
marginBottom: 16,
|
|
gap: 6,
|
|
},
|
|
row: {
|
|
flexDirection: 'row',
|
|
gap: 8,
|
|
marginBottom: 6,
|
|
flexWrap: 'wrap',
|
|
},
|
|
divider: {
|
|
height: 1,
|
|
backgroundColor: 'rgba(128,128,128,0.4)',
|
|
marginVertical: 8,
|
|
},
|
|
dump: {
|
|
marginTop: 6,
|
|
maxHeight: 220,
|
|
backgroundColor: 'rgba(0,0,0,0.05)',
|
|
padding: 8,
|
|
},
|
|
})
|