From 9347307372078c8713c95a7905b42b30e3c462ee Mon Sep 17 00:00:00 2001 From: mosherBT Date: Wed, 19 Aug 2026 14:36:39 -0300 Subject: [PATCH 1/2] flags: persist URL flags for the session, add flagEnabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flags were parsed from the URL on every page load but never written back, so a flag only applied to the page whose query string carried it. getFlags() already reads sessionStorage as a fallback; nothing ever populated it. - parseFlags() now persists URL-supplied flags to sessionStorage, so a flag set once holds for the rest of the tab session. A URL parameter still wins over a stored value, so "?optableDebug=0" corrects a stored "1". - Adds flagEnabled(), and switches the three buildRTD call sites to it. Flag values are strings and "0" is truthy, so "?optableForceSkipMerge=0" previously enabled the flag. Persisting values makes that stick for the session rather than one page, so the truthiness bug is fixed here rather than left to become permanent. setupAB is unaffected — it already compares against "1" and "0" explicitly, and optableControlGroup stays two-state. - Adds optableForceTokenize, optableResolveId5 and optableResolveID5ID to FLAG_KEYS. All three are in use but unrecognised, so they were neither parsed nor persisted. optableResolveID5ID becomes URL-settable for the first time; previously it could only be set by writing sessionStorage. Adds lib/core/flags.md and a README section — neither existed, so the flag names and their meanings were only discoverable by reading the source. --- README.md | 28 ++++++++++ lib/core/flags.md | 122 +++++++++++++++++++++++++++++++++++++++++ lib/core/flags.test.ts | 85 +++++++++++++++++++++++++++- lib/core/flags.ts | 29 ++++++++++ lib/core/prebid/rtd.ts | 9 ++- 5 files changed, 267 insertions(+), 6 deletions(-) create mode 100644 lib/core/flags.md 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..c931e25b --- /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 is present and not `"0"`: + +```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..fff2ad8e 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,86 @@ 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"); + const setItem = jest.spyOn(Storage.prototype, "setItem"); + getFlags(); + expect(setItem).not.toHaveBeenCalled(); + setItem.mockRestore(); + }); +}); + +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("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..ba1d2cf7 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,19 @@ export function getFlags(): Flags { export function resetFlags(): void { _flags = null; } + +/* + * True when a flag is present and 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. + * + * 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 !== undefined && 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 From f8298a508b46ee82d6501212f60bc3c4f2997dcc Mon Sep 17 00:00:00 2001 From: mosherBT Date: Thu, 20 Aug 2026 10:37:30 -0300 Subject: [PATCH 2/2] comments --- lib/core/flags.md | 2 +- lib/core/flags.test.ts | 11 ++++++++--- lib/core/flags.ts | 7 +++++-- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/core/flags.md b/lib/core/flags.md index c931e25b..b4a9b229 100644 --- a/lib/core/flags.md +++ b/lib/core/flags.md @@ -27,7 +27,7 @@ sessionStorage.removeItem("optableDebug"); Two accessors, and picking the right one matters. -**`flagEnabled(key)`** — for on/off flags. Returns `true` when the flag is present and not `"0"`: +**`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"; diff --git a/lib/core/flags.test.ts b/lib/core/flags.test.ts index fff2ad8e..4c8ee87b 100644 --- a/lib/core/flags.test.ts +++ b/lib/core/flags.test.ts @@ -104,10 +104,9 @@ describe("getFlags - URL flag persistence", () => { it("does not write flags that were only read back from sessionStorage", () => { sessionStorage.setItem("optableDebug", "1"); - const setItem = jest.spyOn(Storage.prototype, "setItem"); + (sessionStorage.setItem as jest.Mock).mockClear(); getFlags(); - expect(setItem).not.toHaveBeenCalled(); - setItem.mockRestore(); + expect(sessionStorage.setItem).not.toHaveBeenCalled(); }); }); @@ -134,6 +133,12 @@ describe("flagEnabled", () => { 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(); diff --git a/lib/core/flags.ts b/lib/core/flags.ts index ba1d2cf7..f2c862af 100644 --- a/lib/core/flags.ts +++ b/lib/core/flags.ts @@ -70,17 +70,20 @@ export function resetFlags(): void { } /* - * True when a flag is present and not explicitly disabled. + * 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 !== undefined && value !== "0"; + return !!value && value !== "0"; }