mattermost-mobile/app/client/rest/tracking.ts
Rahim Rahman abe7a24be5
feat(MM-65145): low connectivity manager (#9283)
* feat(MM-65145): low connectivity manager
* forgot localization
* connection banner showing limited network connectivity
* network_performance_manager test
* adding some test related to network performance monitoring
* add comment about count-based sliding window
* component and hook tests
* clean up some code based on review.
* changes based on CP review
2025-12-04 10:44:33 -07:00

446 lines
17 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {defineMessage} from 'react-intl';
import {DeviceEventEmitter, Platform} from 'react-native';
import {CollectNetworkMetrics} from '@assets/config.json';
import {Events} from '@constants';
import {setServerCredentials} from '@init/credentials';
import NetworkPerformanceManager from '@managers/network_performance_manager';
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
import {NetworkRequestMetrics} from '@managers/performance_metrics_manager/constant';
import {isErrorWithStatusCode} from '@utils/errors';
import {getFormattedFileSize} from '@utils/file';
import {logDebug, logInfo} from '@utils/log';
import {semverFromServerVersion} from '@utils/server';
import * as ClientConstants from './constants';
import ClientError from './error';
import type {APIClientInterface, ClientHeaders, ClientResponse, ClientResponseMetrics, RequestOptions} from '@mattermost/react-native-network-client';
type UrlData = {
count: number;
metrics?: ClientResponseMetrics;
}
type GroupData = {
activeCount: number;
startTime: number;
totalSize: number;
totalCompressedSize: number;
urls: Record<string, UrlData>;
completionFlag: boolean;
completionTimer?: NodeJS.Timeout;
}
/**
* ParallelGroup
* @typedef {Object} ParallelGroup
* @property {number} startTime is the start time of all requests in the group
* @property {number} endTime is min end time of all requests in the group
* @property {number} latency is the max latency (in ms)
* @property {ClientResponseMetrics[]} requests is the list of requests in the group
*/
type ParallelGroup = {
startTime: number;
endTime: number;
latency: number;
requests: ClientResponseMetrics[];
}
type CategorizedRequestsResult = {
parallelGroups: ParallelGroup[];
maxConcurrency: number;
};
export const testExports = {
ParallelGroup: {} as ParallelGroup,
};
export default class ClientTracking {
apiClient: APIClientInterface;
csrfToken = '';
requestHeaders: {[x: string]: string} = {};
serverVersion = '';
urlVersion = '/api/v4';
enableLogging = false;
requestGroups: Map<string, GroupData> = new Map();
constructor(apiClient: APIClientInterface) {
this.apiClient = apiClient;
}
setClientCredentials(bearerToken: string, preauthSecret?: string) {
this.requestHeaders[ClientConstants.HEADER_AUTH] = `${ClientConstants.HEADER_BEARER} ${bearerToken}`;
if (preauthSecret) {
this.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET] = preauthSecret;
} else {
// Remove shared password header when undefined
delete this.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET];
}
setServerCredentials(this.apiClient.baseUrl, bearerToken, preauthSecret);
}
setCSRFToken(csrfToken: string) {
this.csrfToken = csrfToken;
}
getRequestHeaders(requestMethod: string) {
const headers = {...this.requestHeaders};
headers[ClientConstants.HEADER_ACCEPT]= 'application/json';
if (this.csrfToken && requestMethod.toLowerCase() !== 'get') {
headers[ClientConstants.HEADER_X_CSRF_TOKEN] = this.csrfToken;
}
return headers;
}
initTrackGroup(groupLabel: RequestGroupLabel) {
if (!this.requestGroups.has(groupLabel)) {
this.requestGroups.set(groupLabel, {
activeCount: 0,
startTime: Date.now(),
totalSize: 0,
totalCompressedSize: 0,
urls: {},
completionFlag: false,
});
}
}
trackRequest(groupLabel: RequestGroupLabel, url: string, metrics?: ClientResponseMetrics) {
this.initTrackGroup(groupLabel);
const group = this.requestGroups.get(groupLabel)!;
if (group.urls[url]) {
group.urls[url].count += 1;
} else {
group.urls[url] = {
count: 1,
metrics,
};
}
group.totalSize += metrics?.size ?? 0;
group.totalCompressedSize += metrics?.compressedSize ?? 0;
}
getAverageLatency(groupLabel: RequestGroupLabel): number {
const groupData = this.requestGroups.get(groupLabel);
if (!groupData) {
return 100;
}
const urlData = Object.entries(groupData.urls);
const sumLatency = urlData.reduce((result, url) => (result + (url[1].metrics?.latency ?? 0)), 0);
return sumLatency / urlData.length;
}
categorizeRequests(groupLabel: RequestGroupLabel): CategorizedRequestsResult {
const groupData = this.requestGroups.get(groupLabel);
if (!groupData) {
const emptyResult: CategorizedRequestsResult = {
parallelGroups: [],
maxConcurrency: 0,
};
return emptyResult;
}
const requestsMetrics = Object.entries(groupData.urls).
map((e) => e[1].metrics).
filter((m) => m != null);
requestsMetrics.sort((a, b) => a.startTime - b.startTime);
const parallelGroups: ParallelGroup[] = [];
let maxConcurrency = 0;
// First pass: group requests
for (const metrics of requestsMetrics) {
const groupIndex = parallelGroups.findIndex((g) => metrics.startTime <= g.endTime);
if (groupIndex >= 0) {
const currentGroup = parallelGroups[groupIndex];
currentGroup.requests.push(metrics);
currentGroup.endTime = Math.min(currentGroup.endTime, metrics.endTime);
currentGroup.latency = Math.max(currentGroup.latency, metrics.latency);
maxConcurrency = Math.max(maxConcurrency, currentGroup.requests.length);
} else {
// Create a new parallel group
parallelGroups.push({
startTime: metrics.startTime,
endTime: metrics.endTime,
latency: metrics.latency,
requests: [metrics],
});
}
}
// Second pass: recalculate max latency for all groups (except the last)
// The max latency should only be considered up until the startTime of the next group
for (let i = 0; i < parallelGroups.length - 1; i++) {
const currentGroup = parallelGroups[i];
let maxGroupLatency = 0;
for (const metrics of currentGroup.requests) {
let currentLatency = metrics.latency;
if ((metrics.startTime + metrics.latency) > currentGroup.endTime) {
currentLatency = currentGroup.endTime - metrics.startTime;
}
maxGroupLatency = Math.max(maxGroupLatency, currentLatency);
}
currentGroup.latency = maxGroupLatency;
}
return {parallelGroups, maxConcurrency};
}
calculateAverageSpeedWithCategories = (
parallelGroups: ParallelGroup[],
elapsedTimeInSeconds: number, // Observed total elapsed time in seconds
): { averageSpeedMbps: number; effectiveLatency: number } => {
// Step 1: Calculate total data size in bits
const totalDataBits = parallelGroups.reduce((sum, group) => {
return sum + group.requests.reduce((groupSum, req) => groupSum + (req.compressedSize * 8), 0);
}, 0);
// Step 2: Calculate effective latency (in ms)
const effectiveLatency = parallelGroups.reduce((sum, group) => sum + group.latency, 0);
// Step 3: Calculate data transfer time
const dataTransferTime = elapsedTimeInSeconds - (effectiveLatency / 1000);
// Handle edge case: if data transfer time is zero or negative.
if (dataTransferTime <= 0) {
return {averageSpeedMbps: 0, effectiveLatency};
}
// Step 4: Calculate average speed
const averageSpeedBps = totalDataBits / dataTransferTime; // Speed in bps
const averageSpeedMbps = averageSpeedBps / 1_000_000; // Convert to Mbps
return {
averageSpeedMbps,
effectiveLatency,
};
};
incrementRequestCount(groupLabel: RequestGroupLabel) {
this.initTrackGroup(groupLabel);
const group = this.requestGroups.get(groupLabel)!;
group.activeCount += 1;
}
decrementRequestCount(groupLabel: RequestGroupLabel) {
const group = this.requestGroups.get(groupLabel);
if (group) {
group.activeCount -= 1;
if (group.activeCount <= 0 && !group.completionFlag) {
this.clearCompletionTimer(groupLabel);
const latency = this.getAverageLatency(groupLabel);
group.completionTimer = setTimeout(() => {
if (this.allRequestsCompleted(groupLabel)) {
group.completionFlag = true;
this.handleRequestCompletion(groupLabel);
this.clearCompletionTimer(groupLabel);
}
}, latency);
}
}
}
clearCompletionTimer(groupLabel: RequestGroupLabel) {
const group = this.requestGroups.get(groupLabel);
if (group?.completionTimer) {
clearTimeout(group.completionTimer);
group.completionTimer = undefined;
}
}
handleRequestCompletion(groupLabel: RequestGroupLabel) {
const group = this.requestGroups.get(groupLabel);
if (group) {
const duration = Date.now() - group.startTime;
logDebug(`Group "${groupLabel}" completed.`);
this.sendTelemetryEvent(groupLabel, group, duration);
this.requestGroups.delete(groupLabel);
}
}
allRequestsCompleted(groupLabel: RequestGroupLabel): boolean {
const group = this.requestGroups.get(groupLabel);
return group ? group.activeCount <= 0 : true;
}
sendTelemetryEvent(groupLabel: RequestGroupLabel, groupData: GroupData, duration: number) {
const {totalCompressedSize, totalSize, urls: groupedUrls} = groupData;
const urls = Object.keys(groupedUrls);
const urlData = Object.entries(groupedUrls);
const dupe = urlData.filter((u) => u[1].count > 1);
const latency = this.getAverageLatency(groupLabel);
const {parallelGroups, maxConcurrency} = this.categorizeRequests(groupLabel);
const elapsedTimeInSeconds = duration / 1000;
const {averageSpeedMbps, effectiveLatency} = this.calculateAverageSpeedWithCategories(parallelGroups, elapsedTimeInSeconds);
logInfo(`Telemetry Event on ${Platform.OS} for Server ${this.apiClient.baseUrl}
Group: "${groupLabel}"
Total Requests: ${urls.length} URLs
Max Concurrency: ${maxConcurrency}
Parallel Groups: ${parallelGroups.length}
Requests in Each Group: ${parallelGroups.map((g) => g.requests.length).join(', ')}
Total Compressed Size: ${getFormattedFileSize(groupData.totalCompressedSize)}
Total Size: ${getFormattedFileSize(groupData.totalSize)}
Elapsed Time: ${elapsedTimeInSeconds} seconds (${duration} ms)
Average Request Latency: ${latency} ms
Effective Latency: ${effectiveLatency} ms
Average Speed: ${averageSpeedMbps.toFixed(4)} Mbps
`,
);
if (dupe.length) {
logDebug(`Duplicate URLs:\n${dupe.map((d, i) => `${i + 1} - ${d[0]} ${JSON.stringify(d[1])}`).join('\n')}`);
}
const {collectNetworkRequestData} = PerformanceMetricsManager;
const commonArguments = {serverUrl: this.apiClient.baseUrl, groupLabel};
const metricsData: Array<[NetworkRequestMetrics, number]> = [
[NetworkRequestMetrics.AverageSpeed, averageSpeedMbps],
[NetworkRequestMetrics.EffectiveLatency, effectiveLatency],
[NetworkRequestMetrics.ElapsedTime, elapsedTimeInSeconds],
[NetworkRequestMetrics.Latency, latency],
[NetworkRequestMetrics.TotalCompressedSize, totalCompressedSize],
[NetworkRequestMetrics.TotalRequests, urls.length],
[NetworkRequestMetrics.TotalSequentialRequests, parallelGroups.length],
[NetworkRequestMetrics.TotalSize, totalSize],
];
metricsData.forEach(([metric, value]) => {
collectNetworkRequestData(metric, value, commonArguments);
});
// Send metrics for each parallel group
parallelGroups.forEach((group) => {
collectNetworkRequestData(NetworkRequestMetrics.TotalParallelRequests, group.requests.length, commonArguments);
});
}
buildRequestOptions(options: ClientOptions): RequestOptions {
const requestOptions: RequestOptions = {
body: options.body,
headers: this.getRequestHeaders(options.method!.toLowerCase()),
};
if (options.noRetry) {
requestOptions.retryPolicyConfiguration = {retryLimit: 0};
}
if (options.timeoutInterval) {
requestOptions.timeoutInterval = options.timeoutInterval;
}
if (options.headers) {
requestOptions.headers = {...requestOptions.headers, ...options.headers};
}
return requestOptions;
}
doFetchWithTracking = async (url: string, options: ClientOptions, returnDataOnly = true) => {
let request;
const {groupLabel} = options;
const method = options.method?.toLowerCase();
switch (method) {
case 'get': request = this.apiClient!.get;
break;
case 'put': request = this.apiClient!.put;
break;
case 'post': request = this.apiClient!.post;
break;
case 'patch': request = this.apiClient!.patch;
break;
case 'delete': request = this.apiClient!.delete;
break;
default:
return {error: new ClientError(this.apiClient.baseUrl, {
message: 'Invalid request method',
intl: defineMessage({
id: 'mobile.request.invalid_request_method',
defaultMessage: 'Invalid request method',
}),
url,
})};
}
if (groupLabel && CollectNetworkMetrics) {
this.incrementRequestCount(groupLabel);
}
const performanceRequestId = NetworkPerformanceManager.startRequestTracking(this.apiClient.baseUrl, url);
let response: ClientResponse;
try {
response = await request!(url, this.buildRequestOptions(options));
} catch (error) {
NetworkPerformanceManager.cancelRequestTracking(this.apiClient.baseUrl, performanceRequestId);
const response_error = error as ClientError;
const status_code = isErrorWithStatusCode(error) ? error.status_code : undefined;
throw new ClientError(this.apiClient.baseUrl, {
message: 'Received invalid response from the server.',
intl: defineMessage({
id: 'mobile.request.invalid_response',
defaultMessage: 'Received invalid response from the server.',
}),
url,
details: response_error,
headers: response_error.headers ? response_error.headers : undefined,
status_code,
});
} finally {
if (groupLabel && CollectNetworkMetrics) {
this.decrementRequestCount(groupLabel);
}
}
const headers: ClientHeaders = response.headers || {};
if (groupLabel && CollectNetworkMetrics) {
this.trackRequest(groupLabel, url, response.metrics);
}
NetworkPerformanceManager.completeRequestTracking(this.apiClient.baseUrl, performanceRequestId, response.metrics);
const serverVersion = semverFromServerVersion(
headers[ClientConstants.HEADER_X_VERSION_ID] || headers[ClientConstants.HEADER_X_VERSION_ID.toLowerCase()],
);
const hasCacheControl = Boolean(
headers[ClientConstants.HEADER_CACHE_CONTROL] || headers[ClientConstants.HEADER_CACHE_CONTROL.toLowerCase()],
);
if (serverVersion && !hasCacheControl && this.serverVersion !== serverVersion) {
this.serverVersion = serverVersion;
DeviceEventEmitter.emit(Events.SERVER_VERSION_CHANGED, {serverUrl: this.apiClient.baseUrl, serverVersion});
}
const bearerToken = headers[ClientConstants.HEADER_TOKEN] || headers[ClientConstants.HEADER_TOKEN.toLowerCase()];
if (bearerToken) {
const existingSharedPassword = this.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET];
this.setClientCredentials(bearerToken, existingSharedPassword);
}
if (response.ok) {
return returnDataOnly ? (response.data || {}) : response;
}
throw new ClientError(this.apiClient.baseUrl, {
message: response.data?.message as string || `Response with status code ${response.code}`,
server_error_id: response.data?.id as string,
status_code: response.code,
url,
headers,
});
};
}