diff --git a/package-lock.json b/package-lock.json index d6385ad1..6b3527ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,8 @@ "markdown-to-jsx": "^7.4.7", "prop-types": "^15.8.1", "recoil": "^0.7.7", - "regenerator-runtime": "^0.14.1" + "regenerator-runtime": "^0.14.1", + "uuid": "^11.1.1" }, "devDependencies": { "@babel/cli": "^7.24.7", @@ -13516,6 +13517,17 @@ "websocket-driver": "^0.7.4" } }, + "node_modules/sockjs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/source-map": { "version": "0.6.1", "dev": true, @@ -14440,14 +14452,16 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", - "dev": true, + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/v8-to-istanbul": { diff --git a/package.json b/package.json index 604221c9..22037c97 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,8 @@ "markdown-to-jsx": "^7.4.7", "prop-types": "^15.8.1", "recoil": "^0.7.7", - "regenerator-runtime": "^0.14.1" + "regenerator-runtime": "^0.14.1", + "uuid": "^11.1.1" }, "dependenciesNotes": {}, "peerDependencies": { diff --git a/src/components/common/help/modal.js b/src/components/common/help/modal.js index dd35d104..626e9f0e 100644 --- a/src/components/common/help/modal.js +++ b/src/components/common/help/modal.js @@ -12,7 +12,13 @@ import Divider from "components/common/modal/divider"; import except from "lib/common/object/except"; import useHttp from "lib/hooks/use-http"; import useTranslate from "lib/hooks/use-translate"; -import {activeState, baseState, errorsState, orderState} from "lib/recoil"; +import { + activeState, + baseState, + errorsState, + orderState, + requestIDsState +} from "lib/recoil"; import style from "./style.scss"; function Modal({show, setShow}) { @@ -25,6 +31,7 @@ function Modal({show, setShow}) { const base = useRecoilValue(baseState); const errors = useRecoilValue(errorsState); const order = useRecoilValue(orderState); + const requestIDs = useRecoilValue(requestIDsState); const submitting = useRef(false); if(!show) { return null; } @@ -47,7 +54,12 @@ function Modal({show, setShow}) { const params = { errors, message, - state: {active, base, order: {}}, + requestIDs, + state: { + active, + base, + order: {} + }, widget: {source: SOURCE, version: VERSION} }; diff --git a/src/components/container/hooks/use-props.js b/src/components/container/hooks/use-props.js index 87b1006e..4a10ab13 100644 --- a/src/components/container/hooks/use-props.js +++ b/src/components/container/hooks/use-props.js @@ -1,4 +1,4 @@ -import {useEffect} from "react"; +import {useEffect, useState} from "react"; import {useSetRecoilState} from "recoil"; import Cache from "lib/cache"; import slice from "lib/common/object/slice"; @@ -7,6 +7,7 @@ import Http from "lib/http"; import I18n from "lib/i18n"; import Listener from "lib/listener"; import { + appendRequestIDState, baseState, cacheState, graphqlState, @@ -14,8 +15,10 @@ import { i18nState, listenerState, localeState, - optionsState + optionsState, + requestIDState } from "lib/recoil"; +import {chainRequestID, generateWidgetID} from "lib/request-id"; const EMPTY_OPTIONS = {}; @@ -23,11 +26,15 @@ export default function useProps(props) { const setBase = useSetRecoilState(baseState); const setCache = useSetRecoilState(cacheState); const setGraphql = useSetRecoilState(graphqlState); + const appendRequestID = useSetRecoilState(appendRequestIDState); const setHttp = useSetRecoilState(httpState); + const setRequestID = useSetRecoilState(requestIDState); const setI18n = useSetRecoilState(i18nState); const setListener = useSetRecoilState(listenerState); const setLocale = useSetRecoilState(localeState); const setOptions = useSetRecoilState(optionsState); + const [fallbackListener] = useState(() => new Listener()); + const listener = props.listener || fallbackListener; const { assessmentID, benchmarkID, @@ -60,16 +67,31 @@ export default function useProps(props) { }, [props.cache]); useEffect(() => { - setHttp(props.http || new Http(slice(options, ["authKey", "host", "version"]))); - }, [props.http]); + const http = props.http || new Http(slice(options, ["authKey", "host", "version"])); + http.listener = listener; + setHttp(http); + }, [props.http, listener]); + + useEffect(() => { + const widgetID = options.requestID || generateWidgetID(); + setRequestID(widgetID); + + return listener.on("Http.request", (request) => { + const requestID = chainRequestID(widgetID); + // NOTE: Defer updating state so it happens after the render + queueMicrotask(() => appendRequestID(requestID)); + + request.options.headers = {...request.options.headers, "X-Request-Id": requestID}; + }); + }, [listener]); useEffect(() => { setI18n(props.i18n || new I18n()); }, [props.i18n]); useEffect(() => { - setListener(props.listener || new Listener()); - }, [props.listener]); + setListener(listener); + }, [listener]); useEffect(() => { const base = {}; diff --git a/src/lib/http/index.js b/src/lib/http/index.js index c0756cd0..5fb820d7 100644 --- a/src/lib/http/index.js +++ b/src/lib/http/index.js @@ -1,5 +1,6 @@ import withRetry from "./retry"; import toQueryString from "../common/object/to-query-string"; +import Listener from "../listener"; const formatArgs = ({method, options, params}) => { if(typeof options === "object") { return {method, ...options}; } @@ -21,6 +22,7 @@ export default class Http { this.authKey = authKey; this.autoRetry = autoRetry || false; this.host = host || "https://api.traitify.com"; + this.listener = new Listener(); this.retryOptions = retryOptions || {}; this.version = version || "v1"; } @@ -54,8 +56,12 @@ export default class Http { options.body = JSON.stringify(params); } + // NOTE: Allows listeners to mutate the request object + const request = {options, url}; + this.listener.trigger("Http.request", request); + const retryEnabled = this.autoRetry || retryOptions != null; - const fetchFn = () => this.fetch(url, options); + const fetchFn = () => this.fetch(request.url, request.options); const responsePromise = retryEnabled ? withRetry({fetch: fetchFn, method, options: {...this.retryOptions, ...retryOptions}}) : fetchFn(); diff --git a/src/lib/recoil/base.js b/src/lib/recoil/base.js index 600d7cd1..52a67480 100644 --- a/src/lib/recoil/base.js +++ b/src/lib/recoil/base.js @@ -22,6 +22,8 @@ export const localeState = atom({ }); export const optionsState = atom({default: null, key: "options"}); export const orderIDState = atom({default: null, key: "order-id"}); +export const requestIDState = atom({default: null, key: "request-id"}); +export const requestIDsState = atom({default: [], key: "request-ids"}); export const packageIDState = atom({default: null, key: "package-id"}); export const profileIDState = atom({default: null, key: "profile-id"}); export const skipDismissedState = atom({default: false, key: "skip-dismissed"}); @@ -35,6 +37,12 @@ export const appendErrorState = selector({ key: "append-error-state" }); +export const appendRequestIDState = selector({ + get: ({get}) => get(requestIDsState), + set: ({get, set}, newValue) => set(requestIDsState, [...get(requestIDsState), newValue]), + key: "append-request-id-state" +}); + // NOTE: Breaking up state prevents over-triggering selectors export const activeIDState = selector({ get: ({get}) => { diff --git a/src/lib/request-id.js b/src/lib/request-id.js new file mode 100644 index 00000000..16a0cbfd --- /dev/null +++ b/src/lib/request-id.js @@ -0,0 +1,4 @@ +import {v7 as uuid} from "uuid"; + +export const chainRequestID = (widgetID) => `${widgetID}.${uuid().slice(-6)}`; +export const generateWidgetID = () => `widget-${uuid()}`; diff --git a/test/components/survey/cognitive/__snapshots__/index.test.js.snap b/test/components/survey/cognitive/__snapshots__/index.test.js.snap index 083ed9d2..6208dcca 100644 --- a/test/components/survey/cognitive/__snapshots__/index.test.js.snap +++ b/test/components/survey/cognitive/__snapshots__/index.test.js.snap @@ -350,6 +350,7 @@ exports[`Survey.Cognitive submit submits query 1`] = ` "Accept": "application/json", "Authorization": "Basic dW5kZWZpbmVkOng=", "Content-Type": "application/json", + "X-Request-Id": "widget-test.aaaaaa", }, "method": "POST", }, @@ -365,6 +366,7 @@ exports[`Survey.Cognitive submit submits query with fallbacks 1`] = ` "Accept": "application/json", "Authorization": "Basic dW5kZWZpbmVkOng=", "Content-Type": "application/json", + "X-Request-Id": "widget-test.aaaaaa", }, "method": "POST", }, diff --git a/test/components/survey/generic/__snapshots__/index.test.js.snap b/test/components/survey/generic/__snapshots__/index.test.js.snap index 55610a04..f98bc331 100644 --- a/test/components/survey/generic/__snapshots__/index.test.js.snap +++ b/test/components/survey/generic/__snapshots__/index.test.js.snap @@ -178,6 +178,7 @@ exports[`Survey.Generic submit submits query 1`] = ` "Accept": "application/json", "Authorization": "Basic dW5kZWZpbmVkOng=", "Content-Type": "application/json", + "X-Request-Id": "widget-test.aaaaaa", }, "method": "POST", }, diff --git a/test/lib/http.test.js b/test/lib/http.test.js index 0838646a..f267912b 100644 --- a/test/lib/http.test.js +++ b/test/lib/http.test.js @@ -113,6 +113,47 @@ describe("Http", () => { ); }); + it("triggers request listeners with the built request", () => { + const onRequest = jest.fn().mockName("onRequest"); + http.listener.on("Http.request", onRequest); + http.request({method: "GET", path: "/profiles"}); + + expect(onRequest).toHaveBeenCalledWith({ + options: expect.objectContaining({method: "GET"}), + url: "https://api.traitify.com/v1/profiles" + }); + }); + + it("fetches with mutations made by request listeners", () => { + http.listener.on("Http.request", (request) => { request.options.headers["X-Request-Id"] = "widget-abc.aaaaaa"; }); + http.request({method: "GET", path: "/profiles"}); + + expect(lastHeaders()["X-Request-Id"]).toBe("widget-abc.aaaaaa"); + }); + + it("applies multiple request listeners", () => { + http.listener.on("Http.request", (request) => { request.options.headers["X-A"] = "a"; }); + http.listener.on("Http.request", (request) => { request.options.headers["X-B"] = "b"; }); + http.request({method: "GET", path: "/profiles"}); + + expect(lastHeaders()).toEqual(expect.objectContaining({"X-A": "a", "X-B": "b"})); + }); + + it("stops invoking a listener after it is unregistered", () => { + const onRequest = jest.fn().mockName("onRequest"); + const off = http.listener.on("Http.request", onRequest); + off(); + http.request({method: "GET", path: "/profiles"}); + + expect(onRequest).not.toHaveBeenCalled(); + }); + + it("fetches unchanged when no request listeners are registered", () => { + http.request({method: "GET", path: "/profiles"}); + + expect(lastHeaders()).not.toHaveProperty("X-Request-Id"); + }); + it("passes query params", () => { http.request({method: "GET", path: "/profiles", params: {locale_key: "en-us"}}); diff --git a/test/lib/request-id.test.js b/test/lib/request-id.test.js new file mode 100644 index 00000000..0cfa58d5 --- /dev/null +++ b/test/lib/request-id.test.js @@ -0,0 +1,23 @@ +const {chainRequestID, generateWidgetID} = jest.requireActual("lib/request-id"); + +describe("request-id", () => { + describe("generateWidgetID", () => { + it("prefixes the id with widget-", () => { + expect(generateWidgetID()).toMatch(/^widget-/); + }); + + it("generates a unique id each call", () => { + expect(generateWidgetID()).not.toBe(generateWidgetID()); + }); + }); + + describe("chainRequestID", () => { + it("appends a 6-char hex code to the widget id", () => { + expect(chainRequestID("widget-abc")).toMatch(/^widget-abc\.[0-9a-f]{6}$/); + }); + + it("generates a unique code each call", () => { + expect(chainRequestID("widget-abc")).not.toBe(chainRequestID("widget-abc")); + }); + }); +}); diff --git a/test/support/setup/container.js b/test/support/setup/container.js index db8a9f2e..3c17125a 100644 --- a/test/support/setup/container.js +++ b/test/support/setup/container.js @@ -1,3 +1,7 @@ jest.mock("lib/common/load-font"); +jest.mock("lib/request-id", () => ({ + chainRequestID: (widgetID) => `${widgetID}.aaaaaa`, + generateWidgetID: () => "widget-test" +})); afterEach(() => { jest.clearAllTimers(); });