diff --git a/README.md b/README.md index 6befe60d..13fa52c0 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ JavaScript SDK for integrating with an [Optable Data Connectivity Node (DCN)](ht - [Insert oeid into your Email newsletter template](#insert-oeid-into-your-email-newsletter-template) - [Call tryIdentifyFromParams SDK API](#call-tryidentifyfromparams-sdk-api) - [Passport and Visitor ID](#passport-and-visitor-id) +- [QA and debug flags](#qa-and-debug-flags) - [Multi-Node Targeting Resolver](#multi-node-targeting-resolver) - [Usage](#usage) - [Rules](#rules) @@ -1116,6 +1117,33 @@ If the returned value is `null`, the SDK logs a one-time warning per instance to 1. The method was called before the passport was cached (e.g. before `sdk.site()` resolved). 2. The DCN is configured to not echo the passport in response bodies, in which case the client-side cache is never populated. +## QA and debug flags + +Flags are per-session overrides for exercising SDK behaviour that is otherwise decided automatically — forcing a split-test variant, bypassing consent, turning on verbose logging. They are set from the page URL and read back through `getFlags()`. + +``` +https://example.com/article?optableDebug&optableForceTargeting +``` + +A bare flag name means enabled, `=0` means explicitly off. Flags supplied in the URL are persisted to `sessionStorage`, so a flag set once stays in effect for the rest of the tab session without re-appending the query string. + +Use `flagEnabled()` for on/off flags, and `getFlags()` when a flag has more than two meanings: + +```typescript +import { flagEnabled, getFlags } from "@optable/web-sdk/lib/dist/core/flags"; + +if (flagEnabled("optableDebug")) { + console.log("[wrapper]", ...args); +} + +// optableControlGroup is two-state: "1" forces control, "0" forces treatment. +const controlGroup = getFlags().optableControlGroup; +``` + +Flag values are strings, and `"0"` is truthy in JavaScript, so do not test a raw value for truthiness — `if (getFlags().optableDebug)` is `true` for `?optableDebug=0`. Use `flagEnabled()` instead. + +These are a QA and debugging facility; none of them should be set on production traffic. For the full flag table and resolution order, see the [flags README](lib/core/flags.md). + ## Multi-Node Targeting Resolver Resolves multiple **Node Targeting Rules** based on **priority** or **aggregation**. diff --git a/lib/core/flags.md b/lib/core/flags.md new file mode 100644 index 00000000..b4a9b229 --- /dev/null +++ b/lib/core/flags.md @@ -0,0 +1,122 @@ +# QA and Debug Flags + +Flags are per-session overrides used to exercise SDK behaviour that is otherwise decided automatically — forcing a split-test variant, bypassing consent, turning on verbose logging. They are set from the page URL and read back through `getFlags()`. + +They are a QA and debugging facility. Nothing in normal operation depends on them, and none of them should be set on production traffic. + +## Setting a flag + +Append the flag name to the page URL. A bare name means enabled: + +``` +https://example.com/article?optableDebug +https://example.com/article?optableDebug=1 # same thing +https://example.com/article?optableDebug=0 # explicitly off +https://example.com/article?optableDebug&optableForceTargeting +``` + +Flags supplied in the URL are written to `sessionStorage`, so a flag set once stays in effect for the rest of the tab session — clicking through to another page keeps it on without re-appending the query string. Closing the tab clears everything. + +To clear a flag before then, remove it from `sessionStorage` directly: + +```js +sessionStorage.removeItem("optableDebug"); +``` + +## Reading flags + +Two accessors, and picking the right one matters. + +**`flagEnabled(key)`** — for on/off flags. Returns `true` when the flag carries a value that is not `"0"`. An empty value counts as disabled: + +```js +import { flagEnabled } from "@optable/web-sdk/lib/dist/core/flags"; + +if (flagEnabled("optableDebug")) { + console.log("[wrapper]", ...args); +} +``` + +**`getFlags()`** — for flags with more than two meanings, where you need the raw value: + +```js +import { getFlags } from "@optable/web-sdk/lib/dist/core/flags"; + +const controlGroup = getFlags().optableControlGroup; +if (controlGroup === "1") { + // force control +} else if (controlGroup === "0") { + // force treatment +} +``` + +> **Do not test a raw flag value for truthiness.** Values are strings, and `"0"` is truthy in JavaScript, so `if (getFlags().optableDebug)` is `true` for `?optableDebug=0`. Use `flagEnabled()` for on/off flags. + +## Available flags + +| Flag | Read by | Effect | +| --------------------------- | ------------------------ | ------------------------------------------------------------------------------------- | +| `optableDebug` | RTD module, wrapper code | Verbose logging. | +| `optableDisableConsent` | wrapper code | Bypass the CMP and treat all permissions as granted. | +| `optableControlGroup` | `setupAB` | `1` forces the control variant, `0` forces treatment. Two-state — read the raw value. | +| `optableForceTargeting` | wrapper code | Re-run targeting even when a session guard says it already ran. | +| `optableForceTokenize` | wrapper code | Re-run tokenize even when a session guard says it already ran. | +| `optableForceGlobalRouting` | `buildRTD` | Route every EID to `global` instead of per-bidder. | +| `optableForceSkipMerge` | `buildRTD` | Skip merging EIDs into the auction entirely. | +| `optableResolve1P` | wrapper code | Resolve using a first-party test identifier. | +| `optableResolve3P` | wrapper code | Resolve using a third-party test IP. | +| `optableEnableAnalytics` | wrapper code | Force analytics on, ignoring the sampling rate. | +| `optableResolveId5` | wrapper code | Return a placeholder ID5 value without loading the ID5 API. | +| `optableResolveID5ID` | wrapper code | Return a specific ID5 value without loading the ID5 API. | + +"Wrapper code" means the flag is recognised and persisted by the SDK, but acted on by the bundle built around it. Unknown query parameters are ignored — only the keys above are parsed. + +## Resolution order + +`parseFlags()` runs once per page load and the result is memoized: + +1. Read the URL query string for every known key. +2. Persist whatever was found to `sessionStorage`. +3. For keys not in the URL, fall back to the `sessionStorage` value from an earlier page in this session. + +A URL parameter therefore always beats a stored value, which is what makes a flag correctable mid-session: `?optableDebug=0` overwrites a stored `"1"`. + +Both storage steps are wrapped in `try`/`catch`, so a browser with `sessionStorage` blocked degrades to URL-only flags rather than throwing. + +## Using flags from a wrapper bundle + +A wrapper does not need its own query-string parser. Call `getFlags()` once during initialization — that parses the URL and persists it — then read flags wherever needed: + +```js +import { getFlags, flagEnabled } from "@optable/web-sdk/lib/dist/core/flags"; + +getFlags(); // parse + persist for the session + +function log(...args) { + if (flagEnabled("optableDebug")) { + console.log("[wrapper]", ...args); + } +} +``` + +Call it before anything that reads a flag. Addons that read flags internally — `setupAB` and `buildRTD` — call `getFlags()` themselves, so ordering only matters for a wrapper's own reads. + +## Testing + +`resetFlags()` clears the memoized result so the next `getFlags()` re-parses. It is intended for tests, which need to simulate successive page loads: + +```js +window.location = { search: "?optableDebug=1" }; +resetFlags(); +expect(flagEnabled("optableDebug")).toBe(true); +``` + +## API + +| Export | Signature | Description | +| ------------- | ---------------------------------- | ---------------------------------------------------------------------------- | +| `getFlags` | `() => Flags` | Parsed flags for this page load. Memoized; persists URL flags on first call. | +| `flagEnabled` | `(key: FlagKey) => boolean` | True when a flag is present and not `"0"`. Use for on/off flags. | +| `resetFlags` | `() => void` | Clears the memoized result so the next `getFlags()` re-parses. | +| `FlagKey` | union of flag names | Type of a recognised flag key. | +| `Flags` | `Partial>` | Type of the parsed flag object. | diff --git a/lib/core/flags.test.ts b/lib/core/flags.test.ts index 2994d462..4c8ee87b 100644 --- a/lib/core/flags.test.ts +++ b/lib/core/flags.test.ts @@ -1,4 +1,4 @@ -import { getFlags, resetFlags } from "./flags"; +import { flagEnabled, getFlags, resetFlags } from "./flags"; beforeEach(() => { sessionStorage.clear(); @@ -72,3 +72,91 @@ describe("getFlags - singleton", () => { expect(second.optableDebug).toBe("1"); }); }); + +describe("getFlags - URL flag persistence", () => { + it("persists a URL flag to sessionStorage", () => { + window.location = { search: "?optableDebug=1" } as Location; + resetFlags(); + getFlags(); + expect(sessionStorage.getItem("optableDebug")).toBe("1"); + }); + + it("a persisted flag still applies after navigating away from the query string", () => { + window.location = { search: "?optableForceGlobalRouting" } as Location; + resetFlags(); + getFlags(); + + // Next page load in the same session, without the query param. + window.location = { search: "" } as Location; + resetFlags(); + expect(getFlags().optableForceGlobalRouting).toBe("1"); + }); + + it("persists an explicit 0 so a two-state flag keeps its value", () => { + window.location = { search: "?optableControlGroup=0" } as Location; + resetFlags(); + getFlags(); + + window.location = { search: "" } as Location; + resetFlags(); + expect(getFlags().optableControlGroup).toBe("0"); + }); + + it("does not write flags that were only read back from sessionStorage", () => { + sessionStorage.setItem("optableDebug", "1"); + (sessionStorage.setItem as jest.Mock).mockClear(); + getFlags(); + expect(sessionStorage.setItem).not.toHaveBeenCalled(); + }); +}); + +describe("flagEnabled", () => { + it("is true for a bare flag", () => { + window.location = { search: "?optableDebug" } as Location; + resetFlags(); + expect(flagEnabled("optableDebug")).toBe(true); + }); + + it("is true for an explicit 1", () => { + window.location = { search: "?optableDebug=1" } as Location; + resetFlags(); + expect(flagEnabled("optableDebug")).toBe(true); + }); + + it("is false for an explicit 0", () => { + window.location = { search: "?optableDebug=0" } as Location; + resetFlags(); + expect(flagEnabled("optableDebug")).toBe(false); + }); + + it("is false when the flag is absent", () => { + expect(flagEnabled("optableDebug")).toBe(false); + }); + + it("is false for an empty value in sessionStorage", () => { + sessionStorage.setItem("optableDebug", ""); + resetFlags(); + expect(flagEnabled("optableDebug")).toBe(false); + }); + + it("stays false across navigation once persisted as 0", () => { + window.location = { search: "?optableDebug=0" } as Location; + resetFlags(); + expect(flagEnabled("optableDebug")).toBe(false); + + window.location = { search: "" } as Location; + resetFlags(); + expect(flagEnabled("optableDebug")).toBe(false); + }); +}); + +describe("getFlags - newly recognized keys", () => { + it.each(["optableForceTokenize", "optableResolveId5", "optableResolveID5ID"] as const)( + "reads %s from the URL", + (key) => { + window.location = { search: `?${key}=abc` } as Location; + resetFlags(); + expect(getFlags()[key]).toBe("abc"); + } + ); +}); diff --git a/lib/core/flags.ts b/lib/core/flags.ts index 052d973d..f2c862af 100644 --- a/lib/core/flags.ts +++ b/lib/core/flags.ts @@ -8,6 +8,9 @@ const FLAG_KEYS = [ "optableForceTargeting", "optableForceGlobalRouting", "optableForceSkipMerge", + "optableForceTokenize", + "optableResolveId5", + "optableResolveID5ID", ] as const; export type FlagKey = (typeof FLAG_KEYS)[number]; @@ -27,6 +30,16 @@ function parseFlags(): Flags { // URL params unavailable } + // Persist URL-supplied flags so a flag set once survives navigation within + // the session, rather than only applying to the page it was set on. + try { + for (const key of Object.keys(flags) as FlagKey[]) { + sessionStorage.setItem(key, flags[key] as string); + } + } catch { + // sessionStorage unavailable + } + try { for (const key of FLAG_KEYS) { if (!(key in flags)) { @@ -55,3 +68,22 @@ export function getFlags(): Flags { export function resetFlags(): void { _flags = null; } + +/* + * True when a flag carries a value and is not explicitly disabled. + * + * Flags carry string values ("?optableDebug" and "?optableDebug=1" both yield + * "1"), so a bare truthiness test treats the string "0" as enabled. Callers + * that only care whether a flag is on should use this rather than testing the + * raw value, so "?optableDebug=0" turns the flag off as a reader would expect. + * + * An empty value counts as disabled. A URL cannot produce one, but sessionStorage + * written by other code can. + * + * Flags with more than two states — optableControlGroup, where "1" and "0" + * select different variants — should read getFlags() and compare explicitly. + */ +export function flagEnabled(key: FlagKey): boolean { + const value = getFlags()[key]; + return !!value && value !== "0"; +} diff --git a/lib/core/prebid/rtd.ts b/lib/core/prebid/rtd.ts index b43d3318..103fe143 100644 --- a/lib/core/prebid/rtd.ts +++ b/lib/core/prebid/rtd.ts @@ -1,5 +1,5 @@ // RTD (Real-Time Data) module for Prebid.js integration -import { getFlags } from "../flags"; +import { flagEnabled } from "../flags"; // Type definitions interface EID { @@ -372,20 +372,19 @@ function liveIntentUID2(ortb2: ORTB2): boolean { } function buildRTD(options: RTDOptions = {}): RTDConfig { - const flags = getFlags(); - if (flags.optableForceGlobalRouting || options.forceGlobalRouting) { + if (flagEnabled("optableForceGlobalRouting") || options.forceGlobalRouting) { forceGlobalRouting(); } return { - enableLogging: !!flags.optableDebug || (options.enableLogging ?? false), + enableLogging: flagEnabled("optableDebug") || (options.enableLogging ?? false), log(level: string, message: string, ...args: any[]) { if (this.enableLogging) { log(level, message, ...args); } }, eidSources: options.eidSources ?? { ...defaultEIDSources }, - skipMerge: flags.optableForceSkipMerge + skipMerge: flagEnabled("optableForceSkipMerge") ? () => true : options.skipMerge !== undefined ? options.skipMerge