From 61b50cbd1f1168574b8f7dc2cc1e2e827baae5c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Pota=CC=81c=CC=8Cek?= Date: Thu, 30 Jan 2025 16:10:16 +0100 Subject: [PATCH] use api fetch --- .../session-recorder/src/OTLPLogExporter.ts | 10 +- .../session-recorder/src/api/api-error.ts | 89 +++++ .../session-recorder/src/api/api-fetch.ts | 328 ++++++++++++++++++ packages/session-recorder/src/api/utils.ts | 89 +++++ 4 files changed, 514 insertions(+), 2 deletions(-) create mode 100644 packages/session-recorder/src/api/api-error.ts create mode 100644 packages/session-recorder/src/api/api-fetch.ts create mode 100644 packages/session-recorder/src/api/utils.ts diff --git a/packages/session-recorder/src/OTLPLogExporter.ts b/packages/session-recorder/src/OTLPLogExporter.ts index 4911527d..848c4e46 100644 --- a/packages/session-recorder/src/OTLPLogExporter.ts +++ b/packages/session-recorder/src/OTLPLogExporter.ts @@ -22,6 +22,7 @@ import type { JsonArray, JsonObject, JsonValue } from 'type-fest' import { IAnyValue, Log } from './types' import { VERSION } from './version.js' import { getSessionReplayGlobal } from './session-replay/utils' +import { apiFetch } from './api/api-fetch' interface OTLPLogExporterConfig { beaconUrl: string @@ -180,11 +181,16 @@ const compressGzipAsync = async (data: Uint8Array): Promise => const sendByFetch = async (endpoint: string, headers: HeadersInit, data: BodyInit, keepalive?: boolean) => { try { - const response = await fetch(endpoint, { + const { response } = await apiFetch(endpoint, { method: 'POST', + headers, keepalive, body: data, - headers, + abortPreviousRequest: false, + doNotConvert: true, + doNotRetryOnDocumentHidden: true, + retryCount: 100, + retryOnHttpErrorStatusCodes: true, }) console.debug('📦 dbg: sendByFetch', { ok: response.ok, keepalive }) diff --git a/packages/session-recorder/src/api/api-error.ts b/packages/session-recorder/src/api/api-error.ts new file mode 100644 index 00000000..38039d2b --- /dev/null +++ b/packages/session-recorder/src/api/api-error.ts @@ -0,0 +1,89 @@ +/** + * + * Copyright 2020-2025 Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +type ResponseTimingData = { + fetchTotalMs: number + finalRetries: number +} + +type ResponseDebugData = { + bodyUsed: boolean + headers: Array<[string, string]> | undefined + ok: boolean + redirected: boolean + type: string + url: string +} + +const getResponseDebugData = (response: Response): ResponseDebugData => ({ + type: response.type, + bodyUsed: response.bodyUsed, + headers: response.headers ? Array.from(response.headers as unknown as []) : undefined, + ok: response.ok, + redirected: response.redirected, + url: response.url, +}) + +export class ApiError extends Error { + readonly isOffline: boolean + + readonly isSignalAborted: boolean + + readonly name = 'ApiError' + + readonly responseDebugData: ResponseDebugData | undefined + + readonly visibilityState: DocumentVisibilityState + + wasBeaconFallbackUsed: boolean + + get isConnectionError(): boolean { + return this.status < 0 && !this.isAbortedByRecorder && !this.isAbortedByUserAgent + } + + get isAbortedByRecorder(): boolean { + return this.isSignalAborted + } + + get isAbortedByUserAgent(): boolean { + return this.originalError instanceof Error && this.originalError?.name === 'AbortError' && !this.isSignalAborted + } + + constructor( + message: string, + readonly status: number, + readonly responseTimingData: ResponseTimingData, + signal: AbortSignal, + readonly requestPayload: string, + response?: Response, + readonly originalError?: unknown, + readonly responseData?: Record | string | null, + readonly additionalData?: Record, + ) { + super(message) + + // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work + // Set the prototype explicitly. + Object.setPrototypeOf(this, ApiError.prototype) + + this.responseDebugData = response ? getResponseDebugData(response) : undefined + this.isSignalAborted = signal?.aborted + this.visibilityState = document.visibilityState + this.isOffline = navigator.onLine === false + this.wasBeaconFallbackUsed = false + } +} diff --git a/packages/session-recorder/src/api/api-fetch.ts b/packages/session-recorder/src/api/api-fetch.ts new file mode 100644 index 00000000..153b1fdb --- /dev/null +++ b/packages/session-recorder/src/api/api-fetch.ts @@ -0,0 +1,328 @@ +/** + * + * Copyright 2020-2025 Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import { ApiError } from './api-error' +import { waitForOnline, createUrl, isTrustedEvent } from './utils' + +type RequestInit = Parameters[1] + +type ApiParams = RequestInit & { + abortPreviousRequest: boolean + baseUrl?: string + discardExistingPath?: boolean + doNotConvert?: boolean + doNotRetryOnDocumentHidden?: boolean + logPayloadOnError?: boolean + retryCount?: number + retryInterval?: number + retryOnHttpErrorStatusCodes?: boolean + throwOnConvert?: boolean + waitForOnlineStatus?: boolean +} + +const defaultParams: Partial = { + headers: {}, +} + +const defaultHeaders = { + // actually the payload is in most cases JSON, + // but we use plain text so the preflight request is not send + // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests + 'Content-Type': 'text/plain;charset=UTF-8', +} + +const abortControllersByUrl = new Map() + +const ERROR_CODES_TO_RETRY = new Set([408, 429, 500, 502, 503, 504]) + +const MAX_HTTP_ERROR_RETRIES = 3 + +export const apiFetch = async ( + pathName: string, + { + abortPreviousRequest, + baseUrl, + body, + discardExistingPath, + doNotConvert = false, + doNotRetryOnDocumentHidden = false, + headers, + logPayloadOnError = false, + retryCount = 3, + retryInterval = 1000, + retryOnHttpErrorStatusCodes = false, + throwOnConvert = false, + waitForOnlineStatus = false, + ...params + }: ApiParams, +): Promise<{ data: T; response: Response }> => { + const finalUrl = baseUrl + ? createUrl({ baseUrl, discardExistingPath, pathName }) + : new URL(pathName, window.location.origin) + + let abortController = abortControllersByUrl.get(finalUrl.href) + + if (abortController && abortPreviousRequest) { + console.debug('Aborting previous request', finalUrl) + abortController.abort('Aborted previous request.') + } + + // cannot reuse old controller because signal could be already aborted + abortController = new AbortController() + abortControllersByUrl.set(finalUrl.href, abortController) + + const requestOptions = { + ...defaultParams, + body, + ...params, + signal: abortController.signal, + } + + requestOptions.headers = headers === undefined ? defaultHeaders : headers + + let response: Response | undefined + let responseData = null + let fetchStart = performance.now() + let fetchTotalMs = 0 + let finalRetries = -1 + let fetchError: Error | undefined + + for (let counter = 1; counter <= retryCount; counter++) { + if (waitForOnlineStatus) { + await waitForOnline() + } + + finalRetries = counter + try { + if (counter === 1) { + fetchStart = performance.now() + response = await fetch(finalUrl.href, requestOptions) + } else { + const promises = [] + const timeoutPromise = new Promise((resolve) => { + setTimeout( + () => { + resolve() + }, + retryInterval * Math.pow(2, counter - 1), + ) + }) + + promises.push(timeoutPromise) + + if (doNotRetryOnDocumentHidden) { + const visibilityHiddenPromise = new Promise((resolve) => { + const visibilityChangeListener = (visibilityChangeEvent: Event) => { + if (!isTrustedEvent(visibilityChangeEvent)) { + return + } + + if (document.visibilityState === 'hidden') { + document.removeEventListener('visibilitychange', visibilityChangeListener) + resolve() + } + } + document.addEventListener('visibilitychange', visibilityChangeListener) + + if (document.visibilityState === 'hidden') { + document.removeEventListener('visibilitychange', visibilityChangeListener) + resolve() + } + }) + + promises.push(visibilityHiddenPromise) + } + + await Promise.race(promises) + + if (doNotRetryOnDocumentHidden && document.visibilityState === 'hidden') { + // Gets caught locally and transformed to connection error + throw new Error('Document is hidden and flag `doNotRetryOnDocumentHidden` is set to true.') + } + + if (waitForOnlineStatus) { + await waitForOnline() + } + + fetchStart = performance.now() + response = await fetch(finalUrl.href, requestOptions) + } + + fetchError = undefined + } catch (error) { + fetchError = error as Error + + if (error instanceof Error && error.name === 'AbortError') { + abortControllersByUrl.delete(finalUrl.href) + throw new ApiError( + 'Request was aborted.', + -1, + { fetchTotalMs, finalRetries }, + abortController.signal, + logPayloadOnError ? JSON.stringify(body) : 'Payload not logged.', + undefined, + error, + ) + } + + if (counter < retryCount && !(doNotRetryOnDocumentHidden && document.visibilityState === 'hidden')) { + continue + } + } finally { + fetchTotalMs = performance.now() - fetchStart + } + + abortControllersByUrl.delete(finalUrl.href) + + if (!response) { + throw new ApiError( + `API request to ${finalUrl.href} failed due to connection error.`, + -1, + { + fetchTotalMs, + finalRetries, + }, + abortController.signal, + logPayloadOnError ? JSON.stringify(body) : 'Payload not logged.', + undefined, + fetchError, + ) + } + + if ( + retryOnHttpErrorStatusCodes && + ERROR_CODES_TO_RETRY.has(response.status) && + counter < retryCount && + //! To ensure protection against self DDoS when the server is down, we have decided to allow a maximum of 3 retries for HTTP errors. + counter < MAX_HTTP_ERROR_RETRIES + ) { + continue + } + + if (response.status > 399) { + try { + responseData = await response.json() + } catch (error) { + // Sometimes we receive `TypeError: failed to fetch` when converting response to json. + // It happens when headers of response are received but connection dropped during receiving body. + // When this happens we try to repeat the request. + // More about this issue: + // https://stackoverflow.com/a/72038247/5594539 + if (error instanceof Error && (error.name === 'AbortError' || error.name === 'TypeError')) { + throw new ApiError( + `API request to ${finalUrl.href} failed with status code ${response.status}.`, + response.status, + { fetchTotalMs, finalRetries }, + abortController.signal, + 'Payload not logged.', + response, + error, + ) + } + } + + try { + if (!responseData) { + responseData = await response.text() + } + } catch (error) { + // Sometimes we receive `TypeError: failed to fetch` when converting response to json. + // It happens when headers of response are received but connection dropped during receiving body. + // When this happens we try to repeat the request. + // More about this issue: + // https://stackoverflow.com/a/72038247/5594539 + if (error instanceof Error && (error.name === 'AbortError' || error.name === 'TypeError')) { + throw new ApiError( + `API request to ${finalUrl.href} failed with status code ${response.status}.`, + response.status, + { fetchTotalMs, finalRetries }, + abortController.signal, + 'Payload not logged.', + response, + error, + ) + } + } + + throw new ApiError( + `API request to ${finalUrl.href} failed with status code ${response.status}`, + response.status, + { fetchTotalMs, finalRetries }, + abortController.signal, + logPayloadOnError ? JSON.stringify(body) : 'Payload not logged.', + response, + undefined, + { responseData }, + ) + } + + if (response.status === 204) { + break + } + + if (doNotConvert) { + break + } + + try { + responseData = await response.json() + break + } catch (error) { + // Sometimes we receive `TypeError: failed to fetch` when converting response to json. + // It happens when headers of response are received but connection dropped during receiving body. + // When this happens we try to repeat the request. + // More about this issue: + // https://stackoverflow.com/a/72038247/5594539 + if ( + error instanceof Error && + (error.name === 'AbortError' || error.name === 'TypeError') && + counter < retryCount + ) { + continue + } + + if (throwOnConvert && error instanceof Error) { + const errorToThrow = + error.name === 'AbortError' || error.name === 'TypeError' + ? new ApiError( + `API request to ${finalUrl.href} failed due to connection error.`, + -1, + { + fetchTotalMs, + finalRetries, + }, + abortController.signal, + logPayloadOnError ? JSON.stringify(body) : 'Payload not logged.', + undefined, + fetchError, + ) + : new Error(`Could not convert data to JSON in ${finalUrl.href} request`) + throw errorToThrow + } else if (throwOnConvert) { + throw new Error(`Unknown error happened during API call. ${JSON.stringify(error)}`) + } else { + break + } + } + } + + if (response === undefined) { + throw new Error('Response object is undefined.') + } + + return { data: responseData, response } +} diff --git a/packages/session-recorder/src/api/utils.ts b/packages/session-recorder/src/api/utils.ts new file mode 100644 index 00000000..b9337989 --- /dev/null +++ b/packages/session-recorder/src/api/utils.ts @@ -0,0 +1,89 @@ +/** + * + * Copyright 2020-2025 Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +export const isTrustedEvent = (eventToCheck: Event): boolean => { + // Some old browsers do not have this property + // See more at: + // https://caniuse.com/?search=isTrusted + if (eventToCheck.isTrusted === undefined) { + return true + } + + return eventToCheck.isTrusted +} + +export const waitForOnline = async (): Promise => { + if (!navigator.onLine) { + await new Promise((resolve) => { + const handleOnline = () => { + window.removeEventListener('online', handleOnline) + resolve() + } + + window.addEventListener('online', handleOnline) + + if (navigator.onLine) { + resolve() + window.removeEventListener('online', handleOnline) + } + }) + } +} + +const getBaseUrlWithPrefix = (baseUrl: string): string => + /^https?:\/\//.test(baseUrl) ? baseUrl : `https://${baseUrl}` + +export const createUrl = ({ + baseUrl, + discardExistingPath, + pathName, +}: { + baseUrl: string + discardExistingPath?: boolean + pathName?: string +}): URL => { + const baseUrlWithPrefix = new URL(getBaseUrlWithPrefix(baseUrl)) + + if (!pathName) { + return baseUrlWithPrefix + } + + /** + * Appends path to origin discarding any existing path. + * Example: url: 'https://domain.com/v2', path: '/v1/logs', result: 'https://domain.com/v1/logs' + * Example: url: 'https://domain.com', path: '/v1/logs', result: 'https://domain.com/v1/logs' + * Example: url: 'https://domain.com/test', path: 'v1/logs', result: 'https://domain.com/v1/logs' + */ + if (discardExistingPath) { + return new URL(pathName, baseUrlWithPrefix.origin) + } else { + /** + * Appends path to origin respecting any existing path. + * Example: url: 'https://domain.com/v2', path: '/v1/logs', result: 'https://domain.com/v2/v1/logs' + * Example: url: 'https://domain.com', path: '/v1/logs', result: 'https://domain.com/v1/logs' + * Example: url: 'https://domain.com/test', path: 'v1/logs', result: 'https://domain.com/test/v1/logs' + */ + let baseUrlPathName = baseUrlWithPrefix.pathname + + if (baseUrlPathName.endsWith('/') && pathName.startsWith('/')) { + // remove slash to avoid double slashes in final URL + baseUrlPathName = baseUrlPathName.slice(0, -1) + } + + return new URL(`${baseUrlPathName}${pathName}`, baseUrlWithPrefix.origin) + } +}