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
5 changes: 4 additions & 1 deletion contributingGuides/OBSERVABILITY_METRICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion server/stubs/telemetry-activeSpans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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};
8 changes: 8 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
41 changes: 41 additions & 0 deletions src/hooks/useCancelSendMessageSpanOnSkeleton.ts
Original file line number Diff line number Diff line change
@@ -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<typeof CONST.TELEMETRY.CANCELED_BY_SKELETON>;

/**
* 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};
20 changes: 18 additions & 2 deletions src/libs/telemetry/activeSpans.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, SpanAttributeValue>) {
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;
}
Expand All @@ -104,4 +120,4 @@ function endSpanWithAttributes(spanId: string, attributes: Record<string, SpanAt
endSpan(spanId);
}

export {startSpan, endSpan, endSpanWithAttributes, getSpan, cancelSpan, cancelAllSpans, cancelSpansByPrefix};
export {startSpan, endSpan, endSpanWithAttributes, getSpan, cancelSpan, cancelSpanByInstance, cancelAllSpans, cancelSpansByPrefix};
17 changes: 14 additions & 3 deletions src/pages/inbox/ReportActions.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import MoneyRequestReportActionsList from '@components/MoneyRequestReportView/MoneyRequestReportActionsList';
import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView';

import useMarkOpenReportEndOnSkeleton from '@hooks/useMarkOpenReportEndOnSkeleton';
import useNetwork from '@hooks/useNetwork';
Expand All @@ -11,6 +10,7 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import {getAllNonDeletedTransactions, shouldDisplayReportTableView, shouldWaitForTransactions as shouldWaitForTransactionsUtil} from '@libs/MoneyRequestReportUtils';
import {isConciergeChatReport, isInvoiceReport, isMoneyRequestReport} from '@libs/ReportUtils';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';

import {useRoute} from '@react-navigation/native';
Expand All @@ -19,6 +19,7 @@ import React from 'react';
import type ReportScreenNavigationProps from './types';

import ReportActionsList from './report/ReportActionsList';
import ReportActionsLoadingSkeleton from './report/ReportActionsLoadingSkeleton';
import UserTypingEventListener from './report/UserTypingEventListener';

const defaultReportLoadingState = {
Expand Down Expand Up @@ -69,15 +70,25 @@ function ReportActions() {
useMarkOpenReportEndOnSkeleton(report, shouldShowAppLoadSkeleton);

if (!report || shouldWaitForTransactions) {
return <ReportActionsSkeletonView />;
return (
<ReportActionsLoadingSkeleton
reportID={reportIDFromRoute}
skeletonName={CONST.TELEMETRY.CANCELED_BY_SKELETON.REPORT_ACTIONS_REPORT_DATA_LOADING}
/>
);
}

if (shouldDisplayMoneyRequestActionsList) {
return <MoneyRequestReportActionsList />;
}

if (shouldShowAppLoadSkeleton) {
return <ReportActionsSkeletonView />;
return (
<ReportActionsLoadingSkeleton
reportID={reportIDFromRoute}
skeletonName={CONST.TELEMETRY.CANCELED_BY_SKELETON.REPORT_ACTIONS_APP_LOAD}
/>
);
}

return (
Expand Down
30 changes: 30 additions & 0 deletions src/pages/inbox/report/ReportActionsLoadingSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -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 <ReportActionsSkeletonView shouldAnimate={shouldAnimate} />;
}

ReportActionsLoadingSkeleton.displayName = 'ReportActionsLoadingSkeleton';

export default ReportActionsLoadingSkeleton;
20 changes: 16 additions & 4 deletions src/pages/inbox/report/ReportActionsSkeletonGuard.tsx
Original file line number Diff line number Diff line change
@@ -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 */
Expand Down Expand Up @@ -49,11 +50,22 @@ function ReportActionsSkeletonGuard({reportID, children}: ReportActionsSkeletonG
useMarkOpenReportEndOnSkeleton(report, shouldShowInitialSkeleton);

if (shouldShowLoadingSkeleton) {
return <ReportActionsSkeletonView />;
return (
<ReportActionsLoadingSkeleton
reportID={reportID}
skeletonName={CONST.TELEMETRY.CANCELED_BY_SKELETON.SKELETON_GUARD_LOADING}
/>
);
}

if (shouldShowDerivedTimingSkeleton) {
return <ReportActionsSkeletonView shouldAnimate={false} />;
return (
<ReportActionsLoadingSkeleton
reportID={reportID}
skeletonName={CONST.TELEMETRY.CANCELED_BY_SKELETON.SKELETON_GUARD_DERIVED_TIMING}
shouldAnimate={false}
/>
);
}

return (
Expand Down
139 changes: 139 additions & 0 deletions tests/unit/useCancelSendMessageSpanOnSkeletonTest.ts
Original file line number Diff line number Diff line change
@@ -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<SpanStartListener>();
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<string, unknown>}) => {
const span = {
op: options?.op,
attributes: {...(options?.attributes ?? {})} as Record<string, unknown>,
setAttribute(key: string, value: unknown) {
this.attributes[key] = value;
},
setAttributes(attrs: Record<string, unknown>) {
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<string, unknown>}) => ({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();
});
});
Loading