From dea7ad06d68e7d4795b6eafefad322c9b36a1840 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Fri, 10 Jul 2026 17:07:01 +0200 Subject: [PATCH] Add Share Extension receipt telemetry: stamp trace id, log capture and submit milestones Co-Authored-By: Claude Opus 4.8 (1M context) --- src/libs/telemetry/ReceiptObservability.ts | 2 +- src/pages/Share/SubmitDetailsPage.tsx | 15 ++++++ tests/ui/SubmitDetailsPageTest.tsx | 56 ++++++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/libs/telemetry/ReceiptObservability.ts b/src/libs/telemetry/ReceiptObservability.ts index d8e1dbfc22b9..0d11f36cb6a6 100644 --- a/src/libs/telemetry/ReceiptObservability.ts +++ b/src/libs/telemetry/ReceiptObservability.ts @@ -15,7 +15,7 @@ const RECEIPT_LOG_PREFIX = '[Receipt]'; type ReceiptSnapshotTrigger = 'signOut' | 'background' | 'foreground'; /** How a receipt entered the app. */ -type ReceiptCaptureSource = 'camera' | 'gallery' | 'file' | 'replace'; +type ReceiptCaptureSource = 'camera' | 'gallery' | 'file' | 'replace' | 'share'; /** * Maps the picker capture path to a source. On native the picker is the OS gallery. On web the same callback fires diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index 4d6dedc73249..8badb93386bb 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -31,6 +31,7 @@ import { import {setMoneyRequestReceipt} from '@libs/actions/IOU/Receipt'; import {requestMoney, trackExpense} from '@libs/actions/IOU/TrackExpense'; import type {GPSPoint as GpsPoint} from '@libs/actions/IOU/types/TrackExpenseTransactionParams'; +import {WRITE_COMMANDS} from '@libs/API/types'; import DateUtils from '@libs/DateUtils'; import {getFileName, readFileAsync} from '@libs/fileDownload/FileUtils'; import getCurrentPosition from '@libs/getCurrentPosition'; @@ -46,6 +47,7 @@ import {hasOnlyPersonalPolicies as hasOnlyPersonalPoliciesUtil, isGroupPolicy} f import {shouldValidateFile} from '@libs/ReceiptUtils'; import {isMoneyRequestReport, isSelfDM} from '@libs/ReportUtils'; import {cancelSpan, endSpan} from '@libs/telemetry/activeSpans'; +import {logReceiptCaptured, logReceiptSubmitted, mintAndStampReceiptTraceId} from '@libs/telemetry/ReceiptObservability'; import {getDefaultTaxCode, getIsFromGlobalCreate, getTaxValue} from '@libs/TransactionUtils'; import DraftWorkspaceOpener from '@pages/iou/request/step/confirmation/DraftWorkspaceOpener'; @@ -258,6 +260,15 @@ function SubmitDetailsPage({ // `transaction.transactionID` is the draft placeholder; mirror the action's `existingTransactionID ?? rand64()` chain so cleanup nav targets the created expense. const optimisticTransactionID = existingTransactionID ?? rand64(); + // This path skips createTransaction, so log the submit milestone here to map the draft id to the final one. + logReceiptSubmitted({ + receiptTraceId: receipt.receiptTraceId, + draftTransactionID: transaction.transactionID, + transactionID: optimisticTransactionID, + command: isSelfDM(report) ? WRITE_COMMANDS.TRACK_EXPENSE : WRITE_COMMANDS.REQUEST_MONEY, + iouType, + }); + if (isSelfDM(report)) { trackExpense({ report: report ?? {reportID: reportOrAccountID}, @@ -361,6 +372,10 @@ function SubmitDetailsPage({ const onSuccess = (file: File, locationPermissionGranted?: boolean) => { const receipt: Receipt = file; receipt.state = file && CONST.IOU.RECEIPT_STATE.SCAN_READY; + // The share flow builds the receipt here by hand and skips buildReceiptFiles, so this is the only place to stamp + // the trace id and log the capture. + const receiptTraceId = mintAndStampReceiptTraceId(receipt); + logReceiptCaptured({file: receipt, captureSource: 'share', receiptTraceId}); if (!locationPermissionGranted) { finishRequestAndNavigate(receipt); return; diff --git a/tests/ui/SubmitDetailsPageTest.tsx b/tests/ui/SubmitDetailsPageTest.tsx index 315b0e6c9ceb..15de1a09bb84 100644 --- a/tests/ui/SubmitDetailsPageTest.tsx +++ b/tests/ui/SubmitDetailsPageTest.tsx @@ -4,6 +4,8 @@ /* eslint-disable @typescript-eslint/no-unsafe-return */ import {act, fireEvent, render, screen} from '@testing-library/react-native'; +import Log from '@libs/Log'; + import SubmitDetailsPage from '@pages/Share/SubmitDetailsPage'; import CONST from '@src/CONST'; @@ -177,4 +179,58 @@ describe('SubmitDetailsPage', () => { expect(requestMoneyArg?.optimisticTransactionID).not.toBe(CONST.IOU.OPTIMISTIC_TRANSACTION_ID); expect(cleanupArg?.transactionID).toBe(requestMoneyArg?.optimisticTransactionID); }); + + it('stamps the shared receipt with a trace id and logs the capture and submit milestones with source share', async () => { + // Capture each [Receipt] log line into a typed list so assertions never have to cast mock.calls. + const receiptLogs: Array<{message: string; params: Record}> = []; + const logInfoSpy = jest.spyOn(Log, 'info').mockImplementation((message, _sendNow, params) => { + if (message.includes('[Receipt]') && !!params && typeof params === 'object' && !Array.isArray(params) && !(params instanceof Error)) { + receiptLogs.push({message, params}); + } + }); + + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${SHARED_REPORT_ID}`, createTestReport()); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, createDraftTransaction()); + await Onyx.merge(ONYXKEYS.SHARE_TEMP_FILE, {content: 'file://shared.jpg', mimeType: 'image/jpeg'}); + await Onyx.merge(ONYXKEYS.NVP_LAST_LOCATION_PERMISSION_PROMPT, new Date().toISOString()); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [PARTICIPANT_ACCOUNT_ID]: {accountID: PARTICIPANT_ACCOUNT_ID, login: 'participant@example.com', displayName: 'Participant'}, + }); + }); + + render( + , + ); + + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByTestId('mock-confirm-button')); + await waitForBatchedUpdatesWithAct(); + + // The trace id minted at capture travels into the request receipt, so the enqueued and snapshot logs can join back to the capture log. + const requestMoneyArg = jest.mocked(TrackExpense.requestMoney).mock.calls.at(0)?.[0]; + const receiptTraceId = requestMoneyArg?.transactionParams?.receipt?.receiptTraceId; + expect(typeof receiptTraceId).toBe('string'); + expect(receiptTraceId).toBeTruthy(); + + // The capture milestone fires for the share entry point. + const captured = receiptLogs.find((line) => line.params.event === 'captured'); + expect(captured?.params).toMatchObject({event: 'captured', captureSource: 'share', receiptTraceId}); + + // The submit milestone maps the fixed draft id to the final transaction id. + const submitted = receiptLogs.find((line) => line.params.event === 'submitted'); + expect(submitted?.params).toMatchObject({ + event: 'submitted', + receiptTraceId, + draftTransactionID: CONST.IOU.OPTIMISTIC_TRANSACTION_ID, + transactionID: requestMoneyArg?.optimisticTransactionID, + }); + + logInfoSpy.mockRestore(); + }); });