Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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**.
Expand Down
122 changes: 122 additions & 0 deletions lib/core/flags.md
Original file line number Diff line number Diff line change
@@ -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<Record<FlagKey, string>>` | Type of the parsed flag object. |
90 changes: 89 additions & 1 deletion lib/core/flags.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getFlags, resetFlags } from "./flags";
import { flagEnabled, getFlags, resetFlags } from "./flags";

beforeEach(() => {
sessionStorage.clear();
Expand Down Expand Up @@ -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();
});
Comment thread
mosherBT marked this conversation as resolved.
});

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");
}
);
});
32 changes: 32 additions & 0 deletions lib/core/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ const FLAG_KEYS = [
"optableForceTargeting",
"optableForceGlobalRouting",
"optableForceSkipMerge",
"optableForceTokenize",
"optableResolveId5",
"optableResolveID5ID",
] as const;

export type FlagKey = (typeof FLAG_KEYS)[number];
Expand All @@ -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
}
Comment thread
mosherBT marked this conversation as resolved.

try {
for (const key of FLAG_KEYS) {
if (!(key in flags)) {
Expand Down Expand Up @@ -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";
}
Comment thread
mosherBT marked this conversation as resolved.
9 changes: 4 additions & 5 deletions lib/core/prebid/rtd.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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
Expand Down