Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 30 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@
"date-fns-tz": "^3.2.0",
"dom-serializer": "^0.2.2",
"domhandler": "^5.0.3",
"expensify-common": "2.0.169",
"expensify-common": "2.0.171",
"expo": "54.0.10",
"expo-asset": "12.0.8",
"expo-audio": "1.1.1",
Expand Down
24 changes: 24 additions & 0 deletions src/libs/CurrentUserStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Thin store for current user email that has no dependencies on Log.
* This avoids circular dependency: Log -> NetworkStore -> Log
* Other modules can import getCurrentUserEmail from NetworkStore for convenience,
* but Log specifically imports from here to break the cycle.
*/
import Onyx from 'react-native-onyx';
import ONYXKEYS from '@src/ONYXKEYS';

let currentUserEmail: string | null = null;

Onyx.connectWithoutView({
key: ONYXKEYS.SESSION,
callback: (val) => {
currentUserEmail = val?.email ?? null;
},
});

function getCurrentUserEmail(): string | null {
return currentUserEmail;
}

// eslint-disable-next-line import/prefer-default-export
export {getCurrentUserEmail};
76 changes: 67 additions & 9 deletions src/libs/Log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import ONYXKEYS from '@src/ONYXKEYS';
import pkg from '../../package.json';
import {addLog, flushAllLogsOnAppLaunch} from './actions/Console';
import {shouldAttachLog} from './Console';
import {getCurrentUserEmail} from './CurrentUserStore';
import getPlatform from './getPlatform';
import {post} from './Network';
import requireParameters from './requireParameters';
Expand Down Expand Up @@ -46,24 +47,80 @@ function LogCommand(parameters: LogCommandParameters): Promise<{requestID: strin

// eslint-disable-next-line
type ServerLoggingCallbackOptions = {api_setCookie: boolean; logPacket: string};
type RequestParams = Merge<ServerLoggingCallbackOptions, {shouldProcessImmediately: boolean; shouldRetry: boolean; expensifyCashAppVersion: string; parameters: string}>;
type RequestParams = Merge<
ServerLoggingCallbackOptions,
{shouldProcessImmediately: boolean; shouldRetry: boolean; expensifyCashAppVersion: string; parameters?: string; email?: string | null}
>;

type LogLine = {email?: string | null; [key: string]: unknown};

/**
* Network interface for logger.
* Splits log packets by email to ensure logs are attributed to the correct user,
* even when multiple users' logs are queued before flushing.
*/
function serverLoggingCallback(logger: Logger, params: ServerLoggingCallbackOptions): Promise<{requestID: string}> {
const requestParams = params as RequestParams;
requestParams.shouldProcessImmediately = false;
requestParams.shouldRetry = false;
requestParams.expensifyCashAppVersion = `expensifyCash[${getPlatform()}]${pkg.version}`;
if (requestParams.parameters) {
requestParams.parameters = JSON.stringify(requestParams.parameters);
const baseParams = {
shouldProcessImmediately: false,
shouldRetry: false,
expensifyCashAppVersion: `expensifyCash[${getPlatform()}]${pkg.version}`,
};

// Parse log lines and group by email to handle multi-user scenarios
// (e.g., user signs out and another signs in before logs flush)
const logLines = JSON.parse(params.logPacket) as LogLine[];
const logsByEmail = new Map<string | null, LogLine[]>();
for (const line of logLines) {
const email = line.email ?? null;
const existing = logsByEmail.get(email) ?? [];
existing.push(line);
logsByEmail.set(email, existing);
}

// Create a request for each email group
const requests: Array<Promise<{requestID: string}>> = [];
for (const [email, lines] of logsByEmail) {
const requestParams: RequestParams = {
...params,
...baseParams,
logPacket: JSON.stringify(lines),
email,
};

if (requestParams.parameters) {
requestParams.parameters = JSON.stringify(requestParams.parameters);
}

requests.push(LogCommand(requestParams));
}

// Mirror backend log payload into Telemetry logger for better context
forwardLogsToSentry(requestParams.logPacket);
forwardLogsToSentry(params.logPacket);
clearTimeout(timeout);
timeout = setTimeout(() => logger.info('Flushing logs older than 10 minutes', true, {}, true), 10 * 60 * 1000);
return LogCommand(requestParams);

// Use allSettled to handle partial failures gracefully.
// If we used Promise.all, a single failed group would reject and cause the Logger
// to retry the entire original packet, duplicating already-uploaded groups.
// With allSettled: if ANY succeed we resolve (preventing duplicates), only rejecting
// if ALL fail (allowing the Logger to retry). This trades potential log loss on
// partial failure for guaranteed no duplicates.
return Promise.allSettled(requests).then((results) => {
const fulfilled = results.filter((r): r is PromiseFulfilledResult<{requestID: string}> => r.status === 'fulfilled');
const rejected = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected');

if (fulfilled.length > 0) {
// At least one group succeeded - resolve to prevent retry/duplicates
if (rejected.length > 0) {
// Log warning about lost logs (rare: partial failure + multiple email groups)
console.error(`[Log] ${rejected.length} of ${results.length} log groups failed to upload and will not be retried`);
}
return fulfilled.at(0)?.value ?? {requestID: ''};
}

// All requests failed - reject so Logger can retry the whole batch
throw rejected.at(0)?.reason ?? new Error('All log requests failed');
});
}

// Note: We are importing Logger from expensify-common because it is used by other platforms. The server and client logging
Expand All @@ -84,6 +141,7 @@ const Log = new Logger({
},
maxLogLinesBeforeFlush: 150,
isDebug: true,
getContextEmail: getCurrentUserEmail,
});
timeout = setTimeout(() => Log.info('Flushing logs older than 10 minutes', true, {}, true), 10 * 60 * 1000);

Expand Down
7 changes: 1 addition & 6 deletions src/libs/Network/NetworkStore.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Onyx from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import CONST from '@src/CONST';
import {getCurrentUserEmail} from '@src/libs/CurrentUserStore';
import Log from '@src/libs/Log';
import ONYXKEYS from '@src/ONYXKEYS';
import type Credentials from '@src/types/onyx/Credentials';
Expand All @@ -9,7 +10,6 @@ let credentials: Credentials | null | undefined;
let lastShortAuthToken: string | null | undefined;
let authToken: string | null | undefined;
let authTokenType: ValueOf<typeof CONST.AUTH_TOKEN_TYPES> | null;
let currentUserEmail: string | null = null;
let offline = false;
let authenticating = false;

Expand Down Expand Up @@ -56,7 +56,6 @@ Onyx.connectWithoutView({
callback: (val) => {
authToken = val?.authToken ?? null;
authTokenType = val?.authTokenType ?? null;
currentUserEmail = val?.email ?? null;
checkRequiredData();
},
});
Expand Down Expand Up @@ -118,10 +117,6 @@ function setAuthToken(newAuthToken: string | null) {
authToken = newAuthToken;
}

function getCurrentUserEmail(): string | null {
return currentUserEmail;
}

function hasReadRequiredDataFromStorage(): Promise<unknown> {
return isReadyPromise;
}
Expand Down
Loading
Loading