From 487116401828c59a1fdcaea4e7547c5b768ef760 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Wed, 8 Jul 2026 09:09:10 +0200 Subject: [PATCH 1/4] add useCancelSendMessageSpanOnSkeleton --- src/CONST/index.ts | 8 ++++ .../useCancelSendMessageSpanOnSkeleton.ts | 41 +++++++++++++++++++ src/libs/telemetry/activeSpans.ts | 20 ++++++++- src/pages/inbox/ReportActions.tsx | 17 ++++++-- .../report/ReportActionsLoadingSkeleton.tsx | 30 ++++++++++++++ .../report/ReportActionsSkeletonGuard.tsx | 20 +++++++-- 6 files changed, 127 insertions(+), 9 deletions(-) create mode 100644 src/hooks/useCancelSendMessageSpanOnSkeleton.ts create mode 100644 src/pages/inbox/report/ReportActionsLoadingSkeleton.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 887e89a783a6..dc2231f3d08c 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2192,6 +2192,7 @@ const CONST = { ATTRIBUTE_REPORT_ID: 'report_id', ATTRIBUTE_MESSAGE_LENGTH: 'message_length', ATTRIBUTE_CANCELED: 'canceled', + ATTRIBUTE_CANCELED_BY_SKELETON: 'canceled_by_skeleton', ATTRIBUTE_ROUTE_FROM: 'route_from', ATTRIBUTE_ROUTE_TO: 'route_to', ATTRIBUTE_MIN_DURATION: 'min_duration', @@ -2221,6 +2222,13 @@ const CONST = { ATTRIBUTE_SOURCE: 'source', ATTRIBUTE_ODOMETER_IMAGE_TYPE: 'odometer_image_type', ATTRIBUTE_DURATION_SINCE_NATIVE_APP_STARTUP_MS: 'duration_since_native_app_startup_ms', + /** Which report-actions skeleton cancelled a send-message span (value of the canceled_by_skeleton attribute). */ + CANCELED_BY_SKELETON: { + REPORT_ACTIONS_REPORT_DATA_LOADING: 'report_actions_report_data_loading', + REPORT_ACTIONS_APP_LOAD: 'report_actions_app_load', + SKELETON_GUARD_LOADING: 'skeleton_guard_loading', + SKELETON_GUARD_DERIVED_TIMING: 'skeleton_guard_derived_timing', + }, /** Follow-up action after expense submit (action-based; used as submit_follow_up_action in span). */ SUBMIT_FOLLOW_UP_ACTION: { DISMISS_MODAL_AND_OPEN_REPORT: 'dismiss_modal_and_open_report', diff --git a/src/hooks/useCancelSendMessageSpanOnSkeleton.ts b/src/hooks/useCancelSendMessageSpanOnSkeleton.ts new file mode 100644 index 000000000000..7857b74b340e --- /dev/null +++ b/src/hooks/useCancelSendMessageSpanOnSkeleton.ts @@ -0,0 +1,41 @@ +import {cancelSpanByInstance} from '@libs/telemetry/activeSpans'; + +import CONST from '@src/CONST'; + +import type {ValueOf} from 'type-fest'; + +import * as Sentry from '@sentry/react-native'; +import {useEffect} from 'react'; + +/** Identifies which report-actions skeleton cancelled a send-message span (stamped as canceled_by_skeleton). */ +type SkeletonName = ValueOf; + +/** + * Call from a component that is mounted exactly while a report-actions skeleton is on screen. A message sent + * while the skeleton shows can't render, so its send-message span would never end. This cancels any such span + * for `reportID` (matched by `report_id`, since the span id uses a random report-action id) and tags it with + * `skeletonName` so Sentry shows which skeleton caused it. + */ +function useCancelSendMessageSpanOnSkeleton(reportID: string | undefined, skeletonName: SkeletonName) { + useEffect(() => { + if (!reportID) { + return; + } + const client = Sentry.getClient(); + if (!client) { + return; + } + // `client.on` returns an unsubscribe function, used as the effect cleanup. + return client.on('spanStart', (span) => { + const {op, data} = Sentry.spanToJSON(span); + if (op !== CONST.TELEMETRY.SPAN_SEND_MESSAGE || data[CONST.TELEMETRY.ATTRIBUTE_REPORT_ID] !== reportID) { + return; + } + // Defer so activeSpans has registered the span (set right after the `startInactiveSpan` that emits this). + queueMicrotask(() => cancelSpanByInstance(span, {[CONST.TELEMETRY.ATTRIBUTE_CANCELED_BY_SKELETON]: skeletonName})); + }); + }, [reportID, skeletonName]); +} + +export default useCancelSendMessageSpanOnSkeleton; +export type {SkeletonName}; diff --git a/src/libs/telemetry/activeSpans.ts b/src/libs/telemetry/activeSpans.ts index c7313b8a03ac..fe8c3c9423c3 100644 --- a/src/libs/telemetry/activeSpans.ts +++ b/src/libs/telemetry/activeSpans.ts @@ -1,6 +1,6 @@ import CONST from '@src/CONST'; -import type {SpanAttributeValue, StartSpanOptions} from '@sentry/core'; +import type {Span, SpanAttributeValue, StartSpanOptions} from '@sentry/core'; import {SPAN_STATUS_OK} from '@sentry/core'; import * as Sentry from '@sentry/react-native'; @@ -94,6 +94,22 @@ function cancelSpansByPrefix(prefix: string) { } } +/** + * Cancel a tracked span by its Sentry span instance rather than its id (e.g. from a lifecycle listener that + * only has the raw span). Optionally stamps attributes first. No-op if the span isn't tracked. + */ +function cancelSpanByInstance(target: Span, attributes?: Record) { + for (const [spanID, entry] of activeSpans.entries()) { + if (entry.span === target) { + if (attributes) { + entry.span.setAttributes(attributes); + } + cancelSpan(spanID); + return; + } + } +} + function getSpan(spanId: string) { return activeSpans.get(spanId)?.span; } @@ -104,4 +120,4 @@ function endSpanWithAttributes(spanId: string, attributes: Record; + return ( + + ); } if (shouldDisplayMoneyRequestActionsList) { @@ -77,7 +83,12 @@ function ReportActions() { } if (shouldShowAppLoadSkeleton) { - return ; + return ( + + ); } return ( diff --git a/src/pages/inbox/report/ReportActionsLoadingSkeleton.tsx b/src/pages/inbox/report/ReportActionsLoadingSkeleton.tsx new file mode 100644 index 000000000000..e692a041b327 --- /dev/null +++ b/src/pages/inbox/report/ReportActionsLoadingSkeleton.tsx @@ -0,0 +1,30 @@ +import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; + +import useCancelSendMessageSpanOnSkeleton from '@hooks/useCancelSendMessageSpanOnSkeleton'; +import type {SkeletonName} from '@hooks/useCancelSendMessageSpanOnSkeleton'; + +import React from 'react'; + +type ReportActionsLoadingSkeletonProps = { + /** The report whose actions list is loading */ + reportID: string | undefined; + + /** Which skeleton this is, stamped on any send-message span it cancels */ + skeletonName: SkeletonName; + + /** Whether the skeleton rows animate */ + shouldAnimate?: boolean; +}; + +/** + * Report-actions loading skeleton. Mounted only while the skeleton shows, so it hosts the hook that cancels + * the otherwise never-ending send-message span (tagged with `skeletonName`). + */ +function ReportActionsLoadingSkeleton({reportID, skeletonName, shouldAnimate = true}: ReportActionsLoadingSkeletonProps) { + useCancelSendMessageSpanOnSkeleton(reportID, skeletonName); + return ; +} + +ReportActionsLoadingSkeleton.displayName = 'ReportActionsLoadingSkeleton'; + +export default ReportActionsLoadingSkeleton; diff --git a/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx b/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx index d4f350d3ea77..f16caa7e2e89 100644 --- a/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx +++ b/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx @@ -1,16 +1,17 @@ -import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; - import useCopySelectionHelper from '@hooks/useCopySelectionHelper'; import useMarkOpenReportEndOnSkeleton from '@hooks/useMarkOpenReportEndOnSkeleton'; import usePendingConciergeResponse from '@hooks/usePendingConciergeResponse'; import useReportActionsListModel from '@hooks/useReportActionsListModel'; import useStartConciergeSession from '@hooks/useStartConciergeSession'; +import CONST from '@src/CONST'; + import type {ReactNode} from 'react'; import React from 'react'; import {computeReportActionsSkeletonState, ReportActionsListActionsContext, ReportActionsListStateContext} from './ReportActionsListContext'; +import ReportActionsLoadingSkeleton from './ReportActionsLoadingSkeleton'; type ReportActionsSkeletonGuardProps = { /** The ID of the report to display actions for */ @@ -49,11 +50,22 @@ function ReportActionsSkeletonGuard({reportID, children}: ReportActionsSkeletonG useMarkOpenReportEndOnSkeleton(report, shouldShowInitialSkeleton); if (shouldShowLoadingSkeleton) { - return ; + return ( + + ); } if (shouldShowDerivedTimingSkeleton) { - return ; + return ( + + ); } return ( From baa78e412949737be4d242ef1259ebccd2ba5ebd Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Wed, 8 Jul 2026 09:40:52 +0200 Subject: [PATCH 2/4] add unit test --- .../useCancelSendMessageSpanOnSkeletonTest.ts | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 tests/unit/useCancelSendMessageSpanOnSkeletonTest.ts diff --git a/tests/unit/useCancelSendMessageSpanOnSkeletonTest.ts b/tests/unit/useCancelSendMessageSpanOnSkeletonTest.ts new file mode 100644 index 000000000000..e33178c9d0db --- /dev/null +++ b/tests/unit/useCancelSendMessageSpanOnSkeletonTest.ts @@ -0,0 +1,139 @@ +import {renderHook} from '@testing-library/react-native'; + +import useCancelSendMessageSpanOnSkeleton from '@hooks/useCancelSendMessageSpanOnSkeleton'; + +import {cancelAllSpans, getSpan, startSpan} from '@libs/telemetry/activeSpans'; + +import CONST from '@src/CONST'; + +import * as Sentry from '@sentry/react-native'; + +type SpanStartListener = (span: unknown) => void; + +jest.mock('@sentry/react-native', () => { + const spanStartListeners = new Set(); + const client = { + on: (hook: string, callback: SpanStartListener) => { + if (hook !== 'spanStart') { + return () => {}; + } + spanStartListeners.add(callback); + return () => spanStartListeners.delete(callback); + }, + }; + return { + getClient: () => client, + startInactiveSpan: (options: {op?: string; attributes?: Record}) => { + const span = { + op: options?.op, + attributes: {...(options?.attributes ?? {})} as Record, + setAttribute(key: string, value: unknown) { + this.attributes[key] = value; + }, + setAttributes(attrs: Record) { + Object.assign(this.attributes, attrs); + }, + setStatus() {}, + end() {}, + }; + // The real SDK emits spanStart synchronously during span creation, before startInactiveSpan returns. + for (const listener of spanStartListeners) { + listener(span); + } + return span; + }, + spanToJSON: (span: {op?: string; attributes: Record}) => ({op: span.op, data: span.attributes}), + }; +}); + +/** Start a send-message span the way the composer does, for a given report. Returns the (typed-as-Sentry) span. */ +function sendMessageWhileLoading(reportID: string) { + const spanID = `${CONST.TELEMETRY.SPAN_SEND_MESSAGE}_${Math.random()}`; + const span = startSpan(spanID, { + name: 'send-message', + op: CONST.TELEMETRY.SPAN_SEND_MESSAGE, + attributes: {[CONST.TELEMETRY.ATTRIBUTE_REPORT_ID]: reportID}, + }); + return {spanID, span}; +} + +/** Flush the queueMicrotask the hook schedules before cancelling. */ +function flushMicrotasks() { + return Promise.resolve(); +} + +afterEach(() => { + cancelAllSpans(); +}); + +describe('useCancelSendMessageSpanOnSkeleton', () => { + it('cancels a send-message span sent while the skeleton for that report is showing', async () => { + renderHook(() => useCancelSendMessageSpanOnSkeleton('reportA', CONST.TELEMETRY.CANCELED_BY_SKELETON.SKELETON_GUARD_LOADING)); + + const {spanID, span} = sendMessageWhileLoading('reportA'); + if (!span) { + throw new Error('Expected a span to be started'); + } + await flushMicrotasks(); + + const {data} = Sentry.spanToJSON(span); + expect(data[CONST.TELEMETRY.ATTRIBUTE_CANCELED]).toBe(true); + expect(data[CONST.TELEMETRY.ATTRIBUTE_CANCELED_BY_SKELETON]).toBe(CONST.TELEMETRY.CANCELED_BY_SKELETON.SKELETON_GUARD_LOADING); + // Truly cancelled: ended and removed from the tracking map. + expect(getSpan(spanID)).toBeUndefined(); + }); + + it('does NOT cancel a send-message span for a different report (two reports open, one loading)', async () => { + renderHook(() => useCancelSendMessageSpanOnSkeleton('loadingReport', CONST.TELEMETRY.CANCELED_BY_SKELETON.SKELETON_GUARD_LOADING)); + + const {spanID, span} = sendMessageWhileLoading('otherReport'); + if (!span) { + throw new Error('Expected a span to be started'); + } + await flushMicrotasks(); + + expect(Sentry.spanToJSON(span).data[CONST.TELEMETRY.ATTRIBUTE_CANCELED]).toBeUndefined(); + expect(getSpan(spanID)).toBeDefined(); + }); + + it('does NOT cancel spans of a different op', async () => { + renderHook(() => useCancelSendMessageSpanOnSkeleton('reportOp', CONST.TELEMETRY.CANCELED_BY_SKELETON.SKELETON_GUARD_LOADING)); + + const spanID = `not-send-message_${Math.random()}`; + const span = startSpan(spanID, {name: 'other', op: 'ManualOpenReport', attributes: {[CONST.TELEMETRY.ATTRIBUTE_REPORT_ID]: 'reportOp'}}); + if (!span) { + throw new Error('Expected a span to be started'); + } + await flushMicrotasks(); + + expect(Sentry.spanToJSON(span).data[CONST.TELEMETRY.ATTRIBUTE_CANCELED]).toBeUndefined(); + expect(getSpan(spanID)).toBeDefined(); + }); + + it('does NOT cancel spans started after the skeleton unmounts', async () => { + const {unmount} = renderHook(() => useCancelSendMessageSpanOnSkeleton('reportUnmount', CONST.TELEMETRY.CANCELED_BY_SKELETON.SKELETON_GUARD_LOADING)); + unmount(); + + const {spanID, span} = sendMessageWhileLoading('reportUnmount'); + if (!span) { + throw new Error('Expected a span to be started'); + } + await flushMicrotasks(); + + expect(Sentry.spanToJSON(span).data[CONST.TELEMETRY.ATTRIBUTE_CANCELED]).toBeUndefined(); + expect(getSpan(spanID)).toBeDefined(); + }); + + it('does nothing without a reportID', async () => { + renderHook(() => useCancelSendMessageSpanOnSkeleton(undefined, CONST.TELEMETRY.CANCELED_BY_SKELETON.SKELETON_GUARD_LOADING)); + + const {spanID, span} = sendMessageWhileLoading('reportNoId'); + if (!span) { + throw new Error('Expected a span to be started'); + } + await flushMicrotasks(); + + expect(Sentry.spanToJSON(span).data[CONST.TELEMETRY.ATTRIBUTE_CANCELED]).toBeUndefined(); + expect(getSpan(spanID)).toBeDefined(); + }); +}); From fca0b8c14d64f1542cf27925623851c1b44389ae Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Wed, 8 Jul 2026 13:31:58 +0200 Subject: [PATCH 3/4] Update Send Message guide doc --- contributingGuides/OBSERVABILITY_METRICS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/contributingGuides/OBSERVABILITY_METRICS.md b/contributingGuides/OBSERVABILITY_METRICS.md index 6b008ced8530..7c4003294222 100644 --- a/contributingGuides/OBSERVABILITY_METRICS.md +++ b/contributingGuides/OBSERVABILITY_METRICS.md @@ -154,7 +154,10 @@ This document lists all implemented telemetry metrics in the Expensify App. - User sees: Their message appears in chat - Technical: Message text rendered in report ([`src/pages/home/report/comment/TextCommentFragment.tsx`](https://github.com/Expensify/App/blob/8f123f449f1a4533830b18a1040c9a5f1949821d/src/pages/home/report/comment/TextCommentFragment.tsx#L70)) **Span ID**: Based on reportID -**Attributes**: `report_id`, `message_length` +**Attributes**: `report_id`, `message_length`, `canceled_by_skeleton` +**Cancellation (report-actions skeleton)**: While a report-actions skeleton is on screen, we listen for `ManualSendMessage` spans started for that report and cancel them immediately, tagging `canceled: true` plus `canceled_by_skeleton` with the skeleton that caused it. +- `canceled_by_skeleton` values (`CONST.TELEMETRY.CANCELED_BY_SKELETON`) based on skeleton condition +**Cancellation (report unmount / navigate away)**: If the user leaves the report before their message renders, any pending `ManualSendMessage` span is cancelled via `cancelSpansByPrefix()` to avoid orphaned spans. Cancelled this way the span gets `canceled: true` but **no** `canceled_by_skeleton` (a blanket cancel by span-id prefix, not scoped to one `report_id`). ## Failure Rates From ebbc5bceedf27b3db5c1441cb1f72ad838b0056f Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Wed, 8 Jul 2026 13:48:04 +0200 Subject: [PATCH 4/4] add missing cancelSpanByInstance to stubs --- server/stubs/telemetry-activeSpans.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/stubs/telemetry-activeSpans.ts b/server/stubs/telemetry-activeSpans.ts index cf28b5fbbbed..6688db4a7b5d 100644 --- a/server/stubs/telemetry-activeSpans.ts +++ b/server/stubs/telemetry-activeSpans.ts @@ -19,8 +19,10 @@ function getSpan() { function cancelSpan() {} +function cancelSpanByInstance() {} + function cancelAllSpans() {} function cancelSpansByPrefix() {} -export {startSpan, endSpan, endSpanWithAttributes, getSpan, cancelSpan, cancelAllSpans, cancelSpansByPrefix}; +export {startSpan, endSpan, endSpanWithAttributes, getSpan, cancelSpan, cancelSpanByInstance, cancelAllSpans, cancelSpansByPrefix};