feat: sync endpoint error handling (#2132)

Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
This commit is contained in:
Anshuman Pandey
2024-03-02 16:23:39 +05:30
committed by GitHub
parent 49c18023bd
commit 56f6dbe9a6
7 changed files with 161 additions and 95 deletions
+4 -1
View File
@@ -46,7 +46,10 @@ export class CommandQueue {
if (currentItem.checkInitialized) {
const initResult = checkInitialized();
if (initResult && initResult.ok !== true) errorHandler.handle(initResult.error);
if (initResult && initResult.ok !== true) {
errorHandler.handle(initResult.error);
continue;
}
}
const executeCommand = async () => {
+2 -1
View File
@@ -25,11 +25,12 @@ export class Config {
public update(newConfig: TJsConfigUpdateInput): void {
if (newConfig) {
const expiresAt = new Date(new Date().getTime() + 2 * 60000); // 2 minutes from now
const expiresAt = new Date(new Date().getTime() + 2 * 60000); // 2 minutes in the future
this.config = {
...this.config,
...newConfig,
status: newConfig.status || "success",
expiresAt,
};
+77 -28
View File
@@ -2,7 +2,7 @@ import type { TJsConfig, TJsConfigInput } from "@formbricks/types/js";
import { TPersonAttributes } from "@formbricks/types/people";
import { trackAction } from "./actions";
import { Config } from "./config";
import { Config, LOCAL_STORAGE_KEY } from "./config";
import {
ErrorHandler,
MissingFieldError,
@@ -12,6 +12,7 @@ import {
Result,
err,
okVoid,
wrapThrows,
} from "./errors";
import { addCleanupEventListeners, addEventListeners, removeAllEventListeners } from "./eventListeners";
import { Logger } from "./logger";
@@ -19,23 +20,46 @@ import { checkPageUrl } from "./noCodeActions";
import { updatePersonAttributes } from "./person";
import { sync } from "./sync";
import { getIsDebug } from "./utils";
import { addWidgetContainer, closeSurvey } from "./widget";
import { addWidgetContainer, removeWidgetContainer, setIsSurveyRunning } from "./widget";
const config = Config.getInstance();
const logger = Logger.getInstance();
let isInitialized = false;
export const setIsInitialized = (value: boolean) => {
isInitialized = value;
};
export const initialize = async (
c: TJsConfigInput
): Promise<Result<void, MissingFieldError | NetworkError | MissingPersonError>> => {
if (getIsDebug()) {
logger.configure({ logLevel: "debug" });
}
if (isInitialized) {
logger.debug("Already initialized, skipping initialization.");
return okVoid();
}
if (getIsDebug()) {
logger.configure({ logLevel: "debug" });
let existingConfig: TJsConfig | undefined;
try {
existingConfig = config.get();
logger.debug("Found existing configuration.");
} catch (e) {
logger.debug("No existing configuration found.");
}
// formbricks is in error state, skip initialization
if (existingConfig?.status === "error") {
logger.debug("Formbricks was set to an error state.");
if (existingConfig?.expiresAt && new Date(existingConfig.expiresAt) > new Date()) {
logger.debug("Error state is not expired, skipping initialization");
return okVoid();
} else {
logger.debug("Error state is expired. Continue with initialization.");
}
}
ErrorHandler.getInstance().printStatus();
@@ -81,13 +105,6 @@ export const initialize = async (
updatedAttributes = res.value;
}
let existingConfig: TJsConfig | undefined;
try {
existingConfig = config.get();
} catch (e) {
logger.debug("No existing configuration found.");
}
if (
existingConfig &&
existingConfig.state &&
@@ -96,29 +113,39 @@ export const initialize = async (
existingConfig.userId === c.userId &&
existingConfig.expiresAt // only accept config when they follow new config version with expiresAt
) {
logger.debug("Found existing configuration.");
logger.debug("Configuration fits init parameters.");
if (existingConfig.expiresAt < new Date()) {
logger.debug("Configuration expired.");
await sync({
apiHost: c.apiHost,
environmentId: c.environmentId,
userId: c.userId,
});
try {
await sync({
apiHost: c.apiHost,
environmentId: c.environmentId,
userId: c.userId,
});
} catch (e) {
putFormbricksInErrorState();
}
} else {
logger.debug("Configuration not expired. Extending expiration.");
config.update(existingConfig);
}
} else {
logger.debug("No valid configuration found or it has been expired. Creating new config.");
logger.debug(
"No valid configuration found or it has been expired. Resetting config and creating new one."
);
config.resetConfig();
logger.debug("Syncing.");
await sync({
apiHost: c.apiHost,
environmentId: c.environmentId,
userId: c.userId,
});
try {
await sync({
apiHost: c.apiHost,
environmentId: c.environmentId,
userId: c.userId,
});
} catch (e) {
handleErrorOnFirstInit();
}
// and track the new session event
await trackAction("New Session");
}
@@ -140,7 +167,7 @@ export const initialize = async (
addEventListeners();
addCleanupEventListeners();
isInitialized = true;
setIsInitialized(true);
logger.debug("Initialized");
// check page url if initialized after page load
@@ -149,6 +176,17 @@ export const initialize = async (
return okVoid();
};
const handleErrorOnFirstInit = () => {
// put formbricks in error state (by creating a new config) and throw error
const initialErrorConfig: Partial<TJsConfig> = {
status: "error",
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
};
// can't use config.update here because the config is not yet initialized
wrapThrows(() => localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(initialErrorConfig)))();
throw new Error("Could not initialize formbricks");
};
export const checkInitialized = (): Result<void, NotInitializedError> => {
logger.debug("Check if initialized");
if (!isInitialized || !ErrorHandler.initialized) {
@@ -163,8 +201,19 @@ export const checkInitialized = (): Result<void, NotInitializedError> => {
export const deinitalize = (): void => {
logger.debug("Deinitializing");
closeSurvey();
removeWidgetContainer();
setIsSurveyRunning(false);
removeAllEventListeners();
config.resetConfig();
isInitialized = false;
setIsInitialized(false);
};
export const putFormbricksInErrorState = (): void => {
logger.debug("Putting formbricks in error state");
// change formbricks status to error
config.update({
...config.get(),
status: "error",
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
});
deinitalize();
};
+2 -10
View File
@@ -13,7 +13,6 @@ import {
} from "./errors";
import { deinitalize, initialize } from "./initialize";
import { Logger } from "./logger";
import { sync } from "./sync";
import { closeSurvey } from "./widget";
const config = Config.getInstance();
@@ -55,15 +54,7 @@ export const updatePersonAttribute = async (
}
if (res.data.changed) {
logger.debug("Attribute updated. Syncing...");
await sync(
{
environmentId: environmentId,
apiHost: apiHost,
userId: userId,
},
true
);
logger.debug("Attribute updated in Formbricks");
}
return okVoid();
@@ -178,6 +169,7 @@ export const setPersonAttribute = async (
export const logoutPerson = async (): Promise<void> => {
deinitalize();
config.resetConfig();
};
export const resetPerson = async (): Promise<Result<void, NetworkError>> => {
+55 -48
View File
@@ -15,21 +15,42 @@ const syncWithBackend = async (
{ apiHost, environmentId, userId }: TJsSyncParams,
noCache: boolean
): Promise<Result<TJsStateSync, NetworkError>> => {
const baseUrl = `${apiHost}/api/v1/client/${environmentId}/in-app/sync`;
const urlSuffix = `?version=${import.meta.env.VERSION}`;
try {
const baseUrl = `${apiHost}/api/v1/client/${environmentId}/in-app/sync`;
const urlSuffix = `?version=${import.meta.env.VERSION}`;
let fetchOptions: RequestInit = {};
let fetchOptions: RequestInit = {};
if (noCache || getIsDebug()) {
fetchOptions.cache = "no-cache";
logger.debug("No cache option set for sync");
}
if (noCache || getIsDebug()) {
fetchOptions.cache = "no-cache";
logger.debug("No cache option set for sync");
}
// if user id is available
// if user id is not available
if (!userId) {
const url = baseUrl + urlSuffix;
// public survey
const response = await fetch(url, fetchOptions);
if (!response.ok) {
const jsonRes = await response.json();
return err({
code: "network_error",
status: response.status,
message: "Error syncing with backend",
url,
responseMessage: jsonRes.message,
});
}
return ok((await response.json()).data as TJsState);
}
// userId is available, call the api with the `userId` param
const url = `${baseUrl}/${userId}${urlSuffix}`;
if (!userId) {
const url = baseUrl + urlSuffix;
// public survey
const response = await fetch(url, fetchOptions);
if (!response.ok) {
@@ -44,38 +65,20 @@ const syncWithBackend = async (
});
}
return ok((await response.json()).data as TJsState);
const data = await response.json();
const { data: state } = data;
return ok(state as TJsStateSync);
} catch (e) {
return err(e as NetworkError);
}
// userId is available, call the api with the `userId` param
const url = `${baseUrl}/${userId}${urlSuffix}`;
const response = await fetch(url, fetchOptions);
if (!response.ok) {
const jsonRes = await response.json();
return err({
code: "network_error",
status: response.status,
message: "Error syncing with backend",
url,
responseMessage: jsonRes.message,
});
}
const data = await response.json();
const { data: state } = data;
return ok(state as TJsStateSync);
};
export const sync = async (params: TJsSyncParams, noCache = false): Promise<void> => {
try {
const syncResult = await syncWithBackend(params, noCache);
if (syncResult?.ok !== true) {
logger.error(`Sync failed: ${JSON.stringify(syncResult.error)}`);
throw syncResult.error;
}
@@ -115,8 +118,6 @@ export const sync = async (params: TJsSyncParams, noCache = false): Promise<void
userId: params.userId,
state,
});
// before finding the surveys, check for public use
} catch (error) {
logger.error(`Error during sync: ${error}`);
throw error;
@@ -175,17 +176,23 @@ export const addExpiryCheckListener = (): void => {
// add event listener to check sync with backend on regular interval
if (typeof window !== "undefined" && syncIntervalId === null) {
syncIntervalId = window.setInterval(async () => {
// check if the config has not expired yet
if (config.get().expiresAt && new Date(config.get().expiresAt) >= new Date()) {
return;
try {
// check if the config has not expired yet
if (config.get().expiresAt && new Date(config.get().expiresAt) >= new Date()) {
return;
}
logger.debug("Config has expired. Starting sync.");
await sync({
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
userId: config.get().userId,
});
} catch (e) {
logger.error(`Error during expiry check: ${e}`);
logger.debug("Extending config and try again later.");
const existingConfig = config.get();
config.update(existingConfig);
}
logger.debug("Config has expired. Starting sync.");
await sync({
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
userId: config.get().userId,
// personId: config.get().state?.person?.id,
});
}, updateInterval);
}
};
+18 -7
View File
@@ -7,23 +7,29 @@ import { TSurvey } from "@formbricks/types/surveys";
import { Config } from "./config";
import { ErrorHandler } from "./errors";
import { putFormbricksInErrorState } from "./initialize";
import { Logger } from "./logger";
import { filterPublicSurveys, sync } from "./sync";
const containerId = "formbricks-web-container";
const config = Config.getInstance();
const logger = Logger.getInstance();
const errorHandler = ErrorHandler.getInstance();
let surveyRunning = false;
let isSurveyRunning = false;
let setIsError = (_: boolean) => {};
let setIsResponseSendingFinished = (_: boolean) => {};
export const setIsSurveyRunning = (value: boolean) => {
isSurveyRunning = value;
};
export const renderWidget = async (survey: TSurvey) => {
if (surveyRunning) {
if (isSurveyRunning) {
logger.debug("A survey is already running. Skipping.");
return;
}
surveyRunning = true;
setIsSurveyRunning(false);
if (survey.delay) {
logger.debug(`Delaying survey by ${survey.delay} seconds.`);
@@ -163,7 +169,7 @@ export const renderWidget = async (survey: TSurvey) => {
export const closeSurvey = async (): Promise<void> => {
// remove container element from DOM
document.getElementById(containerId)?.remove();
removeWidgetContainer();
addWidgetContainer();
// if unidentified user, refilter the surveys
@@ -174,7 +180,7 @@ export const closeSurvey = async (): Promise<void> => {
...config.get(),
state: updatedState,
});
surveyRunning = false;
setIsSurveyRunning(false);
return;
}
@@ -188,9 +194,10 @@ export const closeSurvey = async (): Promise<void> => {
},
true
);
surveyRunning = false;
} catch (e) {
setIsSurveyRunning(false);
} catch (e: any) {
errorHandler.handle(e);
putFormbricksInErrorState();
}
};
@@ -200,6 +207,10 @@ export const addWidgetContainer = (): void => {
document.body.appendChild(containerElement);
};
export const removeWidgetContainer = (): void => {
document.getElementById(containerId)?.remove();
};
const loadFormbricksSurveysExternally = (): Promise<typeof window.formbricksSurveys> => {
const formbricksSurveysScriptSrc = import.meta.env.FORMBRICKS_SURVEYS_SCRIPT_SRC;
+3
View File
@@ -78,6 +78,7 @@ export const ZJsConfig = z.object({
userId: z.string().optional(),
state: ZJsState,
expiresAt: z.date(),
status: z.enum(["success", "error"]).optional(),
});
export type TJsConfig = z.infer<typeof ZJsConfig>;
@@ -87,6 +88,8 @@ export const ZJsConfigUpdateInput = z.object({
apiHost: z.string(),
userId: z.string().optional(),
state: ZJsState,
expiresAt: z.date().optional(),
status: z.enum(["success", "error"]).optional(),
});
export type TJsConfigUpdateInput = z.infer<typeof ZJsConfigUpdateInput>;