Skip to content
Merged
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
2 changes: 2 additions & 0 deletions src/libs/DebugUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,7 @@ function validateReportActionDraftProperty(key: keyof ReportAction, value: strin
isTestReceipt: 'boolean',
isTestDriveReceipt: 'boolean',
thumbnail: 'string',
receiptTraceId: 'string',
});
case 'childRecentReceiptTransactionIDs':
return validateObject<ObjectElement<ReportAction, 'childRecentReceiptTransactionIDs'>>(value, {}, 'string');
Expand Down Expand Up @@ -1188,6 +1189,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string)
isTestReceipt: 'boolean',
isTestDriveReceipt: 'boolean',
thumbnail: 'string',
receiptTraceId: 'string',
});
case 'taxRate':
return validateObject<ObjectElement<Transaction, 'taxRate'>>(value, {
Expand Down
21 changes: 21 additions & 0 deletions src/libs/Network/SequentialQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import Log from '@libs/Log';
import {getIsOffline as isOfflineNetwork} from '@libs/NetworkState';
import {processWithMiddleware} from '@libs/Request';
import RequestThrottle from '@libs/RequestThrottle';
import {logReceiptEnqueued, RECEIPT_BEARING_COMMANDS} from '@libs/telemetry/ReceiptObservability';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
Expand Down Expand Up @@ -555,6 +556,26 @@ async function push<TKey extends OnyxKey>(newRequest: OnyxRequest<TKey>): Promis
isSequentialQueueRunning,
});

if (RECEIPT_BEARING_COMMANDS.has(newRequest.command)) {
const data = (newRequest.data ?? {}) as {
transactionID?: string;
receipt?: {receiptTraceId?: string};
};
// Only log when there is a receipt at data.receipt. SplitBill nests it in the splits JSON, and SendMoney and
// friends can run without one. A row without a trace id cannot be joined to the capture log, so it is just noise.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...SendMoney and friends can run without one.

I think this need to be either more precise or dropped? We should rather state that some commands do not have this appended.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean the wording just seem to be too loose

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah lets update this but I wont block on it @adhorodyski @rinej

if (data.receipt) {
logReceiptEnqueued({
receiptTraceId: data.receipt.receiptTraceId,
transactionID: data.transactionID,
command: newRequest.command,
persistedQueueLength: currentRequests.length,
});
}
}

// Save the request to the persisted queue. The in-memory update inside save()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure why we're documenting it here, this promise is not related to the observability, right?

// happens synchronously, so flush() below will see the new request immediately.
// The returned promise resolves when disk persistence completes.
let persistencePromise: Promise<void>;

if (newRequest.checkAndFixConflictingRequest) {
Expand Down
3 changes: 3 additions & 0 deletions src/libs/actions/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {sanitizeUrlForLogging} from '@libs/sanitizeLogParams';
import {isLoggingInAsNewUser as isLoggingInAsNewUserSessionUtils} from '@libs/SessionUtils';
import {clearSoundAssetsCache} from '@libs/Sound';
import {cancelAllSpans, endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans';
import {logReceiptQueueSnapshot} from '@libs/telemetry/ReceiptObservability';

import CONST from '@src/CONST';
import getPathFromState from '@src/libs/Navigation/helpers/getPathFromState';
Expand Down Expand Up @@ -289,12 +290,14 @@ AppState.addEventListener('change', (nextAppState) => {
if (nextAppState.match(/inactive|background/) && appState === 'active') {
Log.info('App going to background', false, {previousState: appState, nextState: nextAppState});
Log.info('Flushing logs as app is going inactive', true, {}, true);
logReceiptQueueSnapshot('background');
saveCurrentPathBeforeBackground();
}

if (nextAppState === 'active' && appState?.match(/inactive|background/)) {
Log.info('App coming to foreground', false, {previousState: appState, nextState: nextAppState});
Log.info('Cancelling telemetry spans as app is coming to foreground', false, {previousState: appState, nextState: nextAppState});
logReceiptQueueSnapshot('foreground');
cancelAllSpans();
}
appState = nextAppState;
Expand Down
10 changes: 10 additions & 0 deletions src/libs/actions/IOU/MoneyRequest.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {LocalizedTranslate} from '@components/LocaleContextProvider';

import {WRITE_COMMANDS} from '@libs/API/types';
import DateUtils from '@libs/DateUtils';
import DistanceRequestUtils from '@libs/DistanceRequestUtils';
import {getGPSRoutes, getGPSWaypoints} from '@libs/GPSDraftDetailsUtils';
Expand All @@ -18,6 +19,7 @@ import {
} from '@libs/ReportUtils';
import type {OptionData} from '@libs/ReportUtils';
import {startSpan} from '@libs/telemetry/activeSpans';
import {logReceiptSubmitted} from '@libs/telemetry/ReceiptObservability';
import {
getCategoryTaxDetails,
getDefaultTaxCode,
Expand Down Expand Up @@ -145,6 +147,14 @@ function createTransaction({
const taxCode = (transaction?.taxCode ? transaction.taxCode : defaultTaxCode) ?? '';
const taxAmount = transaction?.taxAmount ?? 0;
const optimisticTransactionID = optimisticTransactionIDs.at(index);
const submittedCommand = iouType === CONST.IOU.TYPE.TRACK && report ? WRITE_COMMANDS.TRACK_EXPENSE : WRITE_COMMANDS.REQUEST_MONEY;
logReceiptSubmitted({
receiptTraceId: receipt.receiptTraceId,
draftTransactionID: receiptFile.transactionID,
transactionID: optimisticTransactionID ?? receiptFile.transactionID,
command: submittedCommand,
iouType,
});
if (iouType === CONST.IOU.TYPE.TRACK && report) {
trackExpense({
report,
Expand Down
8 changes: 7 additions & 1 deletion src/libs/actions/IOU/Receipt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Navigation from '@libs/Navigation/Navigation';
import {hasDependentTags, isGroupPolicy} from '@libs/PolicyUtils';
import {buildOptimisticDetachReceipt, isInvoiceReport as isInvoiceReportReportUtils} from '@libs/ReportUtils';
import {getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils';
import {logReceiptCaptured, mintAndStampReceiptTraceId} from '@libs/telemetry/ReceiptObservability';
import ViolationsUtils from '@libs/Violations/ViolationsUtils';

import {resolveDetachReceiptConflicts} from '@userActions/RequestConflictUtils';
Expand Down Expand Up @@ -194,6 +195,9 @@ function replaceReceipt({
return;
}

const receiptTraceId = mintAndStampReceiptTraceId(file);
logReceiptCaptured({file, captureSource: 'replace', receiptTraceId});

const allTransactions = getAllTransactions();
const allReports = getAllReports();

Expand All @@ -205,6 +209,7 @@ function replaceReceipt({
localSource: null,
state: state ?? CONST.IOU.RECEIPT_STATE.OPEN,
filename: file.name,
receiptTraceId,
};
const newTransaction = transaction && {...transaction, receipt: receiptOptimistic};
const retryParams: ReplaceReceipt = {transactionID, file: undefined, source, transactionPolicy, transactionPolicyCategories, transactionPolicyTagList, transactionViolations};
Expand Down Expand Up @@ -315,10 +320,11 @@ function setMoneyRequestReceipt(
isTestReceipt = false,
isTestDriveReceipt = false,
thumbnail?: string,
receiptTraceId?: string,
) {
Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {
// isTestReceipt = false and isTestDriveReceipt = false are being converted to null because we don't really need to store it in Onyx in those cases
receipt: {source, filename, type: type ?? '', isTestReceipt: isTestReceipt ? true : null, isTestDriveReceipt: isTestDriveReceipt ? true : null, thumbnail},
receipt: {source, filename, type: type ?? '', isTestReceipt: isTestReceipt ? true : null, isTestDriveReceipt: isTestDriveReceipt ? true : null, thumbnail, receiptTraceId},
});
}

Expand Down
2 changes: 2 additions & 0 deletions src/libs/actions/Session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {getReportIDFromLink} from '@libs/ReportUtils';
import * as SessionUtils from '@libs/SessionUtils';
import {checkIfShouldUseNewPartnerName, resetDidUserLogInDuringSession} from '@libs/SessionUtils';
import {clearSoundAssetsCache} from '@libs/Sound';
import {logReceiptQueueSnapshot} from '@libs/telemetry/ReceiptObservability';
import Timers from '@libs/Timers';

import {hideContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu';
Expand Down Expand Up @@ -258,6 +259,7 @@ function signInWithSupportAuthToken(authToken: string) {
*/
function signOut(params: {autoGeneratedLogin?: string; signedInWithSAML?: boolean; authToken?: string} = {}): Promise<void | Response<never>> {
Log.info('Flushing logs before signing out', true, {}, true);
logReceiptQueueSnapshot('signOut');
const shouldUseNewPartnerName = checkIfShouldUseNewPartnerName(params.autoGeneratedLogin);

const logOutParams: LogOutParams = {
Expand Down
208 changes: 208 additions & 0 deletions src/libs/telemetry/ReceiptObservability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import {getAll as getAllPersistedRequests, getOngoingRequest} from '@libs/actions/PersistedRequests';
import {WRITE_COMMANDS} from '@libs/API/types';
import getPlatform from '@libs/getPlatform';
import Log from '@libs/Log';
import {getIsOffline} from '@libs/NetworkState';
import {rand64} from '@libs/NumberUtils';

import CONST from '@src/CONST';
import type {FileObject} from '@src/types/utils/Attachment';

/** Prefix on every receipt log line so we can filter the logs without parsing free text. */
const RECEIPT_LOG_PREFIX = '[Receipt]';

/** Points in the app lifecycle where we snapshot the receipts that are still pending. */
type ReceiptSnapshotTrigger = 'signOut' | 'background' | 'foreground';

/** How a receipt entered the app. */
type ReceiptCaptureSource = 'camera' | 'gallery' | 'file' | 'replace';

/**
* Maps the picker capture path to a source. On native the picker is the OS gallery. On web the same callback fires
* for both file browsing and drag and drop. Keeping it here means a new platform only has to change one place.
*/
function getPickerCaptureSource(): ReceiptCaptureSource {
return getPlatform() === CONST.PLATFORM.WEB ? 'file' : 'gallery';
}

/** Inputs for the enqueued milestone, taken when the receipt request reaches the write queue. */
type ReceiptEnqueuedParams = {
receiptTraceId: string | undefined;
transactionID: string | undefined;
command: string;
persistedQueueLength: number;
};

/**
* Write commands whose params can carry a captured receipt. A pending request with one of these commands is a receipt
* that has not reached the server yet. Keep this in sync with the durability slice so the two features agree on which
* queued requests own a local receipt file.
*/
const RECEIPT_BEARING_COMMANDS = new Set<string>([
WRITE_COMMANDS.REQUEST_MONEY,
WRITE_COMMANDS.TRACK_EXPENSE,
WRITE_COMMANDS.SPLIT_BILL,
Comment on lines +36 to +44

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was already in the other PR so I assume we need to clean these up now

WRITE_COMMANDS.SPLIT_BILL_AND_OPEN_REPORT,
WRITE_COMMANDS.START_SPLIT_BILL,
WRITE_COMMANDS.COMPLETE_SPLIT_BILL,
Comment on lines +44 to +47

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude receiptless split commands

For offline/manual split bills using SplitBill, SplitBillAndOpenReport, or CompleteSplitBill, the request params written by Split.ts don't include a receipt, but adding these commands to the receipt-bearing set makes push() and queue snapshots emit [Receipt] logs with no trace id for ordinary split requests. That pollutes the pending-receipt trail with non-receipt queue entries; either filter on data.receipt before logging/snapshotting or keep only commands whose params actually carry the local receipt file.

Useful? React with 👍 / 👎.

WRITE_COMMANDS.REPLACE_RECEIPT,
WRITE_COMMANDS.SEND_MONEY_ELSEWHERE,
WRITE_COMMANDS.SEND_MONEY_WITH_WALLET,
WRITE_COMMANDS.CATEGORIZE_TRACKED_EXPENSE,
WRITE_COMMANDS.SHARE_TRACKED_EXPENSE,
]);

/** When each receipt was enqueued, keyed by transaction id, so a snapshot can report how long it has waited. */
const enqueuedAtByTransactionID = new Map<string, number>();

/**
* Upper bound on the enqueue timing map. The snapshot path normally drains it, but a session that never backgrounds
* or signs out, like a long-lived web tab, would keep adding one entry per receipt. Once we pass this cap we drop the
* oldest entry. Losing it only makes a later snapshot miss the wait time for that receipt, so the data stays correct.
*/
const MAX_TRACKED_ENQUEUE_TIMESTAMPS = 100;

/**
* Creates a unique correlation id for a captured receipt and stamps it on the in-memory file object.
*
* We add it as a normal property so it travels with the file through submit into the final request params, and
* survives JSON.stringify and Onyx storage into the persisted request. This still works for a web File, whose binary
* content never serializes.
*/
function mintAndStampReceiptTraceId(file: FileObject): string {
const receiptTraceId = rand64();
// eslint-disable-next-line no-param-reassign
file.receiptTraceId = receiptTraceId;
return receiptTraceId;
}

/**
* Records the capture milestone, the moment a receipt image enters the app. It carries the entry point, file format,
* and size, so we can later check whether lost receipts lean toward a format like HEIC or PDF, or toward large files.
* Sent right away so it survives a hard app kill.
*/
function logReceiptCaptured({file, captureSource, receiptTraceId}: {file: FileObject; captureSource: ReceiptCaptureSource; receiptTraceId: string}) {
Log.info(`${RECEIPT_LOG_PREFIX} captured`, true, {
event: 'captured',
receiptTraceId,
captureSource,
mimeType: file.type,
fileExtension: file.name?.includes('.') ? file.name.split('.').pop()?.toLowerCase() : undefined,
fileSizeBytes: file.size ?? undefined,
platform: getPlatform(),
});
}

/**
* Records the submit milestone and maps the draft transaction id to the final one. This is what joins the capture
* logs to everything downstream, because the id changes from the fixed draft id at submit.
*/
function logReceiptSubmitted({
receiptTraceId,
draftTransactionID,
transactionID,
command,
iouType,
}: {
receiptTraceId: string | undefined;
draftTransactionID: string;
transactionID: string;
command: string;
iouType: string;
}) {
Log.info(`${RECEIPT_LOG_PREFIX} submitted`, true, {
event: 'submitted',
receiptTraceId,
draftTransactionID,
transactionID,
command,
iouType,
});
}

/**
* Records the enqueued milestone, when the receipt upload reaches the write queue. The gap between this and the
* existing network "sent" log is the window where the queue is blocked, which is what we want to see. It records the
* offline state and queue depth so we can tell a normal offline wait apart from a stuck queue.
*/
function logReceiptEnqueued({receiptTraceId, transactionID, command, persistedQueueLength}: ReceiptEnqueuedParams) {
if (transactionID) {
// Re-insert so this key becomes the newest, then drop the oldest entries past the cap. This keeps the map
// bounded even when no snapshot ever runs to drain it.
enqueuedAtByTransactionID.delete(transactionID);
enqueuedAtByTransactionID.set(transactionID, Date.now());
while (enqueuedAtByTransactionID.size > MAX_TRACKED_ENQUEUE_TIMESTAMPS) {
const oldestTransactionID = enqueuedAtByTransactionID.keys().next().value;
if (oldestTransactionID === undefined) {
break;
}
enqueuedAtByTransactionID.delete(oldestTransactionID);
}
}

Log.info(`${RECEIPT_LOG_PREFIX} enqueued`, true, {
event: 'enqueued',
receiptTraceId,
transactionID,
command,
isOffline: getIsOffline(),
persistedQueueLength,
});
}

/**
* Logs one line per receipt still pending in the write queue, tagged with what triggered the snapshot. Stays quiet
* when nothing is pending, so the normal case makes no noise. Sent right away so it survives a hard app kill from the
* background.
*/
function logReceiptQueueSnapshot(trigger: ReceiptSnapshotTrigger) {
const isOffline = getIsOffline();
const now = Date.now();
const pendingTransactionIDs = new Set<string>();

// Include the ongoing request. Once processNextRequest moves a receipt into the ongoing slot it is the one
// actively uploading, but it no longer shows up in getAll. Without this we would skip it here and then drop it
// from the timing map in the cleanup below.
const ongoingRequest = getOngoingRequest();
const requests = ongoingRequest ? [ongoingRequest, ...getAllPersistedRequests()] : getAllPersistedRequests();

for (const request of requests) {
if (!RECEIPT_BEARING_COMMANDS.has(request.command)) {
continue;
}

const data = (request.data ?? {}) as {transactionID?: string; receipt?: {receiptTraceId?: string}};
// Skip when there is no receipt at data.receipt. SplitBill nests it inside the splits JSON, and SendMoney with
// no attached receipt has no receipt field. A row without a trace id cannot be joined to the capture log, so
// it would only add noise to the snapshot.
if (!data.receipt) {
continue;
}
const transactionID = data.transactionID;
if (transactionID) {
pendingTransactionIDs.add(transactionID);
}
const enqueuedAt = transactionID ? enqueuedAtByTransactionID.get(transactionID) : undefined;

Log.info(`${RECEIPT_LOG_PREFIX} queue snapshot`, true, {
event: 'snapshot',
trigger,
receiptTraceId: data.receipt.receiptTraceId,
transactionID,
command: request.command,
msSinceEnqueued: enqueuedAt !== undefined ? now - enqueuedAt : undefined,
isOffline,
});
}

// Drop timing entries for receipts that already left the queue, so the map only holds pending receipts.
for (const transactionID of enqueuedAtByTransactionID.keys()) {
if (pendingTransactionIDs.has(transactionID)) {
continue;
}
enqueuedAtByTransactionID.delete(transactionID);
}
}

export {mintAndStampReceiptTraceId, logReceiptCaptured, logReceiptSubmitted, logReceiptEnqueued, logReceiptQueueSnapshot, getPickerCaptureSource, RECEIPT_BEARING_COMMANDS};
export type {ReceiptCaptureSource};
Loading
Loading