Add on-device debug logging: file capture + share (for real-session testing)
- fileLogger: append redacted API requests/responses + app events to an on-device log file (persists across restarts); the client's logSink now writes to it - Diagnostics screen (Account > Diagnostics): toggle recording, Share log file (expo-sharing), clear. StartSession logs the session attempt/result explicitly. - Lets a real parking session be captured while away from a computer, then shared. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
80812d25cf
commit
7ec7ae9200
8 changed files with 216 additions and 19 deletions
|
|
@ -13,6 +13,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"parksmarter-client": "*",
|
"parksmarter-client": "*",
|
||||||
"expo": "~53.0.0",
|
"expo": "~53.0.0",
|
||||||
|
"expo-file-system": "~18.1.11",
|
||||||
|
"expo-sharing": "~13.1.5",
|
||||||
"expo-secure-store": "~14.0.0",
|
"expo-secure-store": "~14.0.0",
|
||||||
"expo-location": "~18.0.0",
|
"expo-location": "~18.0.0",
|
||||||
"expo-notifications": "~0.29.0",
|
"expo-notifications": "~0.29.0",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import * as Localization from 'expo-localization';
|
||||||
import { ParkSmarterClient, type EnvironmentName } from 'parksmarter-client';
|
import { ParkSmarterClient, type EnvironmentName } from 'parksmarter-client';
|
||||||
import { secureTokenStore } from './secureTokenStore';
|
import { secureTokenStore } from './secureTokenStore';
|
||||||
import { authBus } from '@/auth/authBus';
|
import { authBus } from '@/auth/authBus';
|
||||||
|
import { logLine } from '@/features/diagnostics/fileLogger';
|
||||||
|
|
||||||
const env =
|
const env =
|
||||||
(Constants.expoConfig?.extra?.psEnvironment as EnvironmentName) ?? 'prodv2';
|
(Constants.expoConfig?.extra?.psEnvironment as EnvironmentName) ?? 'prodv2';
|
||||||
|
|
@ -17,7 +18,11 @@ export const ps = new ParkSmarterClient({
|
||||||
tokens: secureTokenStore,
|
tokens: secureTokenStore,
|
||||||
localeCode: (Localization.getLocales()[0]?.languageCode ?? 'en').toLowerCase(),
|
localeCode: (Localization.getLocales()[0]?.languageCode ?? 'en').toLowerCase(),
|
||||||
onUnauthorized: () => authBus.onUnauthorized?.(),
|
onUnauthorized: () => authBus.onUnauthorized?.(),
|
||||||
// Redacted request/response logging -> logcat (grep "PS →" / "PS ←").
|
// Redacted request/response logging -> logcat AND the on-device log file (when
|
||||||
// Toggle at runtime with ps.setLogRequests(false), or flip extra.debugHttp.
|
// enabled in Diagnostics). Toggle at runtime with ps.setLogRequests().
|
||||||
logRequests: Boolean(Constants.expoConfig?.extra?.debugHttp),
|
logRequests: Boolean(Constants.expoConfig?.extra?.debugHttp),
|
||||||
|
logSink: (line: string) => {
|
||||||
|
console.log(line);
|
||||||
|
logLine(line);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
83
app/src/features/diagnostics/fileLogger.ts
Normal file
83
app/src/features/diagnostics/fileLogger.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import * as FileSystem from 'expo-file-system';
|
||||||
|
import * as Sharing from 'expo-sharing';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On-device debug log. When enabled, API requests/responses (already redacted by
|
||||||
|
* the client) and app events are appended to a file the user can share afterward
|
||||||
|
* — useful for capturing a real parking-session attempt while away from a computer.
|
||||||
|
*/
|
||||||
|
const LOG_URI = FileSystem.documentDirectory + 'bbp-debug.log';
|
||||||
|
const ENABLED_KEY = 'ps_file_logging';
|
||||||
|
const MAX_LINES = 4000;
|
||||||
|
|
||||||
|
let enabled = false;
|
||||||
|
let buffer: string[] = [];
|
||||||
|
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
export async function initFileLogging(): Promise<boolean> {
|
||||||
|
enabled = (await AsyncStorage.getItem(ENABLED_KEY)) === 'true';
|
||||||
|
if (enabled) {
|
||||||
|
// Carry over the previous session's log so a crash mid-test isn't lost.
|
||||||
|
try {
|
||||||
|
const info = await FileSystem.getInfoAsync(LOG_URI);
|
||||||
|
if (info.exists) {
|
||||||
|
const prev = await FileSystem.readAsStringAsync(LOG_URI);
|
||||||
|
buffer = prev.split('\n').slice(-MAX_LINES);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFileLoggingEnabled(): boolean {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setFileLogging(on: boolean): Promise<void> {
|
||||||
|
enabled = on;
|
||||||
|
await AsyncStorage.setItem(ENABLED_KEY, String(on));
|
||||||
|
if (on) logLine('--- logging enabled ---');
|
||||||
|
else await flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logLine(line: string): void {
|
||||||
|
if (!enabled) return;
|
||||||
|
buffer.push(`${new Date().toISOString()} ${line}`);
|
||||||
|
if (buffer.length > MAX_LINES) buffer = buffer.slice(-MAX_LINES);
|
||||||
|
if (flushTimer) clearTimeout(flushTimer);
|
||||||
|
flushTimer = setTimeout(() => void flush(), 1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function flush(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await FileSystem.writeAsStringAsync(LOG_URI, buffer.join('\n'));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shareLog(): Promise<void> {
|
||||||
|
await flush();
|
||||||
|
if (await Sharing.isAvailableAsync()) {
|
||||||
|
await Sharing.shareAsync(LOG_URI, {
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
dialogTitle: 'BigBrainParking debug log',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearLog(): Promise<void> {
|
||||||
|
buffer = [];
|
||||||
|
try {
|
||||||
|
await FileSystem.deleteAsync(LOG_URI, { idempotent: true });
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logStats(): { lines: number } {
|
||||||
|
return { lines: buffer.length };
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ import { VehiclesScreen } from '@/screens/VehiclesScreen';
|
||||||
import { PaymentMethodsScreen } from '@/screens/PaymentMethodsScreen';
|
import { PaymentMethodsScreen } from '@/screens/PaymentMethodsScreen';
|
||||||
import { NotificationsScreen } from '@/screens/NotificationsScreen';
|
import { NotificationsScreen } from '@/screens/NotificationsScreen';
|
||||||
import { StartSessionScreen } from '@/screens/StartSessionScreen';
|
import { StartSessionScreen } from '@/screens/StartSessionScreen';
|
||||||
|
import { DiagnosticsScreen } from '@/screens/DiagnosticsScreen';
|
||||||
import { useTheme } from '@/theme/ThemeContext';
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
import type { Zone } from 'parksmarter-client';
|
import type { Zone } from 'parksmarter-client';
|
||||||
|
|
||||||
|
|
@ -33,6 +34,7 @@ export type RootStackParamList = {
|
||||||
Vehicles: undefined;
|
Vehicles: undefined;
|
||||||
PaymentMethods: undefined;
|
PaymentMethods: undefined;
|
||||||
Notifications: undefined;
|
Notifications: undefined;
|
||||||
|
Diagnostics: undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TabParamList = {
|
export type TabParamList = {
|
||||||
|
|
@ -131,6 +133,11 @@ export function RootNavigator() {
|
||||||
component={NotificationsScreen}
|
component={NotificationsScreen}
|
||||||
options={{ title: 'Notifications' }}
|
options={{ title: 'Notifications' }}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Diagnostics"
|
||||||
|
component={DiagnosticsScreen}
|
||||||
|
options={{ title: 'Diagnostics' }}
|
||||||
|
/>
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
) : (
|
) : (
|
||||||
<LoginScreen />
|
<LoginScreen />
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import { ScrollView, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
|
import { ScrollView, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
|
||||||
import Constants from 'expo-constants';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { ps } from '@/api/client';
|
|
||||||
import { useAuth } from '@/auth/AuthContext';
|
import { useAuth } from '@/auth/AuthContext';
|
||||||
import { useTheme } from '@/theme/ThemeContext';
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||||
|
|
@ -15,9 +13,6 @@ export function AccountScreen() {
|
||||||
const { colors, mode, toggle } = useTheme();
|
const { colors, mode, toggle } = useTheme();
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
const [logging, setLogging] = useState<boolean>(
|
|
||||||
Boolean(Constants.expoConfig?.extra?.debugHttp),
|
|
||||||
);
|
|
||||||
|
|
||||||
const Item = ({
|
const Item = ({
|
||||||
icon,
|
icon,
|
||||||
|
|
@ -63,17 +58,7 @@ export function AccountScreen() {
|
||||||
<Text style={[styles.itemText, { color: colors.text }]}>Dark mode</Text>
|
<Text style={[styles.itemText, { color: colors.text }]}>Dark mode</Text>
|
||||||
<Switch value={mode === 'dark'} onValueChange={toggle} />
|
<Switch value={mode === 'dark'} onValueChange={toggle} />
|
||||||
</View>
|
</View>
|
||||||
<View style={[styles.item, { borderBottomColor: colors.border }]}>
|
<Item icon="bug" label="Diagnostics" onPress={() => navigation.navigate('Diagnostics')} />
|
||||||
<Ionicons name="bug" size={20} color={colors.primary} />
|
|
||||||
<Text style={[styles.itemText, { color: colors.text }]}>Log requests (debug)</Text>
|
|
||||||
<Switch
|
|
||||||
value={logging}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
setLogging(v);
|
|
||||||
ps.setLogRequests(v);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|
|
||||||
96
app/src/screens/DiagnosticsScreen.tsx
Normal file
96
app/src/screens/DiagnosticsScreen.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import { Alert, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import { ps } from '@/api/client';
|
||||||
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
|
import {
|
||||||
|
clearLog,
|
||||||
|
isFileLoggingEnabled,
|
||||||
|
logStats,
|
||||||
|
setFileLogging,
|
||||||
|
shareLog,
|
||||||
|
} from '@/features/diagnostics/fileLogger';
|
||||||
|
|
||||||
|
export function DiagnosticsScreen() {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const [on, setOn] = useState(false);
|
||||||
|
const [lines, setLines] = useState(0);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
setOn(isFileLoggingEnabled());
|
||||||
|
setLines(logStats().lines);
|
||||||
|
}, []),
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggle = async (v: boolean) => {
|
||||||
|
setOn(v);
|
||||||
|
await setFileLogging(v);
|
||||||
|
ps.setLogRequests(v);
|
||||||
|
setLines(logStats().lines);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { backgroundColor: colors.bg }]}>
|
||||||
|
<View style={[styles.card, { backgroundColor: colors.card }]}>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={[styles.title, { color: colors.text }]}>Record debug log</Text>
|
||||||
|
<Text style={[styles.sub, { color: colors.subtext }]}>
|
||||||
|
Saves API requests/responses and app events to a file on this device.
|
||||||
|
Sensitive values (tokens, card numbers) are redacted.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch value={on} onValueChange={toggle} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={[styles.count, { color: colors.subtext }]}>
|
||||||
|
{lines} line{lines === 1 ? '' : 's'} captured
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.btn, { backgroundColor: colors.primary }]}
|
||||||
|
onPress={() => shareLog().catch((e) => Alert.alert('Share failed', String(e?.message ?? e)))}
|
||||||
|
>
|
||||||
|
<Text style={styles.btnText}>Share log file</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.btn, { backgroundColor: colors.card, borderWidth: 1, borderColor: colors.border }]}
|
||||||
|
onPress={() =>
|
||||||
|
Alert.alert('Clear log?', 'Deletes the captured log.', [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Clear',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
await clearLog();
|
||||||
|
setLines(0);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text style={[styles.btnText, { color: colors.text }]}>Clear log</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<Text style={[styles.note, { color: colors.subtext }]}>
|
||||||
|
Turn this on before heading out, do your parking session, then come back and
|
||||||
|
“Share log file” to send it to yourself. It persists across app restarts.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, padding: 16 },
|
||||||
|
card: { borderRadius: 12, padding: 16 },
|
||||||
|
row: { flexDirection: 'row', alignItems: 'center' },
|
||||||
|
title: { fontSize: 16, fontWeight: '600' },
|
||||||
|
sub: { fontSize: 13, marginTop: 4, lineHeight: 18 },
|
||||||
|
count: { fontSize: 13, marginTop: 16, marginLeft: 4 },
|
||||||
|
btn: { borderRadius: 12, padding: 16, alignItems: 'center', marginTop: 12 },
|
||||||
|
btnText: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
||||||
|
note: { fontSize: 12, marginTop: 20, lineHeight: 18 },
|
||||||
|
});
|
||||||
|
|
@ -15,6 +15,7 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { ps } from '@/api/client';
|
import { ps } from '@/api/client';
|
||||||
import { useTheme } from '@/theme/ThemeContext';
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
import { scheduleExpiryReminder } from '@/notifications/localReminders';
|
import { scheduleExpiryReminder } from '@/notifications/localReminders';
|
||||||
|
import { logLine } from '@/features/diagnostics/fileLogger';
|
||||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||||
import type {
|
import type {
|
||||||
CreditCardDetail,
|
CreditCardDetail,
|
||||||
|
|
@ -142,6 +143,11 @@ export function StartSessionScreen() {
|
||||||
const doStart = async () => {
|
const doStart = async () => {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
setPaying(true);
|
setPaying(true);
|
||||||
|
logLine(
|
||||||
|
`[SESSION] start attempt zone=${zone.ZoneName} space=${space?.SpaceId} vehicle=${vehicleId} ` +
|
||||||
|
`card=${cardId} min=${selected.Minutes} cost=${selected.ParkingCost} fee=${selected.TransactionFee} ` +
|
||||||
|
`start="${selected.StartTime}" end="${selected.EndTime}"`,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
const res = await ps.startParkingSession({
|
const res = await ps.startParkingSession({
|
||||||
creditCardId: cardId!,
|
creditCardId: cardId!,
|
||||||
|
|
@ -166,10 +172,12 @@ export function StartSessionScreen() {
|
||||||
endTime: end,
|
endTime: end,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
logLine(`[SESSION] start OK: ${JSON.stringify(res)}`);
|
||||||
Alert.alert('Parked!', `Session started at ${zone.ZoneName}.`, [
|
Alert.alert('Parked!', `Session started at ${zone.ZoneName}.`, [
|
||||||
{ text: 'OK', onPress: () => navigation.navigate('Tabs') },
|
{ text: 'OK', onPress: () => navigation.navigate('Tabs') },
|
||||||
]);
|
]);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
logLine(`[SESSION] start FAILED: ${e?.status ?? ''} ${e?.serverMessage ?? e?.message ?? e}`);
|
||||||
Alert.alert('Could not start session', e?.serverMessage ?? e?.message ?? 'error');
|
Alert.alert('Could not start session', e?.serverMessage ?? e?.message ?? 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setPaying(false);
|
setPaying(false);
|
||||||
|
|
|
||||||
11
package-lock.json
generated
11
package-lock.json
generated
|
|
@ -21,11 +21,13 @@
|
||||||
"@react-navigation/native-stack": "^7.0.0",
|
"@react-navigation/native-stack": "^7.0.0",
|
||||||
"expo": "~53.0.0",
|
"expo": "~53.0.0",
|
||||||
"expo-constants": "~17.0.0",
|
"expo-constants": "~17.0.0",
|
||||||
|
"expo-file-system": "~18.1.11",
|
||||||
"expo-linking": "~7.0.0",
|
"expo-linking": "~7.0.0",
|
||||||
"expo-localization": "~16.0.0",
|
"expo-localization": "~16.0.0",
|
||||||
"expo-location": "~18.0.0",
|
"expo-location": "~18.0.0",
|
||||||
"expo-notifications": "~0.29.0",
|
"expo-notifications": "~0.29.0",
|
||||||
"expo-secure-store": "~14.0.0",
|
"expo-secure-store": "~14.0.0",
|
||||||
|
"expo-sharing": "~13.1.5",
|
||||||
"expo-status-bar": "~2.0.0",
|
"expo-status-bar": "~2.0.0",
|
||||||
"parksmarter-client": "*",
|
"parksmarter-client": "*",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
|
|
@ -4939,6 +4941,15 @@
|
||||||
"expo": "*"
|
"expo": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-sharing": {
|
||||||
|
"version": "13.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-13.1.5.tgz",
|
||||||
|
"integrity": "sha512-X/5sAEiWXL2kdoGE3NO5KmbfcmaCWuWVZXHu8OQef7Yig4ZgHFkGD11HKJ5KqDrDg+SRZe4ISd6MxE7vGUgm4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-status-bar": {
|
"node_modules/expo-status-bar": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-2.0.1.tgz",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue