Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/send-evaluation-diagnostics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@reflag/node-sdk": patch
"@reflag/browser-sdk": patch
"@reflag/react-sdk": patch
---

Include non-fatal flag evaluation diagnostics in check events sent by the Node, browser, and React SDKs.
2 changes: 2 additions & 0 deletions packages/browser-sdk/src/bulkQueue.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BULK_QUEUE_FLUSH_DELAY_MS, BULK_QUEUE_MAX_SIZE } from "./config";
import type { CheckEvent } from "./flag/flags";
import { Logger } from "./logger";
import { logResponseError } from "./utils/responseError";

Expand Down Expand Up @@ -39,6 +40,7 @@ export type BulkEvent =
evalContext?: Record<string, any>;
evalRuleResults?: boolean[];
evalMissingFields?: string[];
evalErrors?: CheckEvent["evaluationErrors"];
}
| {
type: "prompt-event";
Expand Down
2 changes: 2 additions & 0 deletions packages/browser-sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1417,6 +1417,7 @@ export class ReflagClient {
version: f?.targetingVersion,
ruleEvaluationResults: f?.ruleEvaluationResults,
missingContextFields: f?.missingContextFields,
evaluationErrors: f?.evaluationErrors,
value,
})
.catch(() => {
Expand All @@ -1432,6 +1433,7 @@ export class ReflagClient {
version: f?.config?.version,
ruleEvaluationResults: f?.config?.ruleEvaluationResults,
missingContextFields: f?.config?.missingContextFields,
evaluationErrors: f?.config?.evaluationErrors,
value: f?.config && {
key: f.config.key,
payload: f.config.payload,
Expand Down
24 changes: 23 additions & 1 deletion packages/browser-sdk/src/flag/flagCache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { StorageAdapter } from "../storage";
import { RawFlagOptIn, RawFlags } from "./flags";
import { RawFlag, RawFlagOptIn, RawFlags } from "./flags";
import { isValidFlagStateVersion } from "./flagStateVersion";

const DEFAULT_STORAGE_KEY = "__reflag_fetched_flags";
Expand Down Expand Up @@ -28,6 +28,23 @@ function parseOptIn(optIn: any): RawFlagOptIn | null | undefined {
};
}

function isEvaluationErrorArray(
value: any,
): value is NonNullable<RawFlag["evaluationErrors"]> {
return (
Array.isArray(value) &&
value.every(
(error) =>
isObject(error) &&
typeof error.code === "string" &&
typeof error.field === "string" &&
typeof error.message === "string" &&
(typeof error.operator === "undefined" ||
typeof error.operator === "string"),
)
);
}

interface cacheEntry {
expireAt: number;
staleAt: number;
Expand Down Expand Up @@ -57,6 +74,10 @@ export function parseAPIFlagsResponse(flagsInput: any): RawFlags | undefined {
!Array.isArray(flag.missingContextFields)) ||
(flag.ruleEvaluationResults &&
!Array.isArray(flag.ruleEvaluationResults)) ||
(typeof flag.evaluationErrors !== "undefined" &&
!isEvaluationErrorArray(flag.evaluationErrors)) ||
(typeof flag.config?.evaluationErrors !== "undefined" &&
!isEvaluationErrorArray(flag.config.evaluationErrors)) ||
(typeof flag.optInEnabled !== "undefined" &&
typeof flag.optInEnabled !== "boolean") ||
(typeof flag.optIn !== "undefined" && typeof optIn === "undefined")
Expand All @@ -71,6 +92,7 @@ export function parseAPIFlagsResponse(flagsInput: any): RawFlags | undefined {
config: flag.config,
missingContextFields: flag.missingContextFields,
ruleEvaluationResults: flag.ruleEvaluationResults,
evaluationErrors: flag.evaluationErrors,
...(typeof flag.optInEnabled !== "undefined" && {
optInEnabled: flag.optInEnabled,
}),
Expand Down
32 changes: 25 additions & 7 deletions packages/browser-sdk/src/flag/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,20 @@ export type RawFlag = {

/**
* Missing context fields.
* @deprecated Use `evaluationErrors` and check for `MISSING_CONTEXT_FIELD`.
*/
missingContextFields?: string[];

/**
* Non-fatal diagnostics produced while evaluating targeting rules.
*/
evaluationErrors?: Array<{
code: string;
field: string;
operator?: string;
message: string;
}>;

/**
* Whether end-user opt-in is enabled for this flag.
*/
Expand Down Expand Up @@ -126,8 +137,14 @@ export type RawFlag = {

/**
* The missing context fields.
* @deprecated Use `evaluationErrors` and check for `MISSING_CONTEXT_FIELD`.
*/
missingContextFields?: string[];

/**
* Non-fatal diagnostics produced while evaluating targeting rules.
*/
evaluationErrors?: RawFlag["evaluationErrors"];
};
};

Expand Down Expand Up @@ -243,8 +260,14 @@ export interface CheckEvent {

/**
* Missing context fields.
* @deprecated Use `evaluationErrors` and check for `MISSING_CONTEXT_FIELD`.
*/
missingContextFields?: string[];

/**
* Non-fatal diagnostics produced while evaluating targeting rules.
*/
evaluationErrors?: RawFlag["evaluationErrors"];
}

const storageOverridesKey = `__reflag_overrides`;
Expand Down Expand Up @@ -663,18 +686,13 @@ export class FlagsClient {
evalResult: checkEvent.value,
evalRuleResults: checkEvent.ruleEvaluationResults,
evalMissingFields: checkEvent.missingContextFields,
evalErrors: checkEvent.evaluationErrors,
};

if (this.enqueueBulkEvent) {
this.enqueueBulkEvent({
type: "feature-flag-event",
action: payload.action,
key: payload.key,
targetingVersion: payload.targetingVersion,
evalContext: payload.evalContext,
evalResult: payload.evalResult,
evalRuleResults: payload.evalRuleResults,
evalMissingFields: payload.evalMissingFields,
...payload,
}).catch((e: any) => {
this.logger.warn(`failed to enqueue flag check event`, e);
});
Expand Down
13 changes: 13 additions & 0 deletions packages/browser-sdk/test/flagCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,19 @@ describe("parseAPIFlagsResponse", () => {
test("rejects malformed flag entries without throwing", () => {
expect(parseAPIFlagsResponse({ flagA: null })).toBeUndefined();
});

test("rejects malformed evaluation errors", () => {
expect(
parseAPIFlagsResponse({
flagA: {
isEnabled: true,
key: "flagA",
targetingVersion: 1,
evaluationErrors: [{ code: "MISSING_CONTEXT_FIELD" }],
},
}),
).toBeUndefined();
});
});

describe("cache", () => {
Expand Down
15 changes: 15 additions & 0 deletions packages/browser-sdk/test/mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ export const flagResponse = {
config: undefined,
ruleEvaluationResults: [false, true],
missingContextFields: ["field1", "field2"],
evaluationErrors: [
{
code: "MISSING_CONTEXT_FIELD",
field: "field1",
message: 'Context field "field1" is required.',
},
],
},
flagB: {
isEnabled: true,
Expand All @@ -25,6 +32,14 @@ export const flagResponse = {
payload: { model: "gpt-something", temperature: 0.5 },
ruleEvaluationResults: [true, false, false],
missingContextFields: ["field3"],
evaluationErrors: [
{
code: "UNSUPPORTED_ARRAY_OPERATOR",
field: "field3",
operator: "IS",
message: 'Operator "IS" does not support array values.',
},
],
},
},
},
Expand Down
3 changes: 3 additions & 0 deletions packages/browser-sdk/test/usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,7 @@ describe(`sends "check" events `, () => {
version: 1,
missingContextFields: ["field1", "field2"],
ruleEvaluationResults: [false, true],
evaluationErrors: flagsResult.flagA.evaluationErrors,
},
expect.any(Function),
);
Expand Down Expand Up @@ -529,6 +530,7 @@ describe(`sends "check" events `, () => {
evalResult: true,
evalRuleResults: [false, true],
evalMissingFields: ["field1", "field2"],
evalErrors: flagsResult.flagA.evaluationErrors,
}),
]),
);
Expand Down Expand Up @@ -580,6 +582,7 @@ describe(`sends "check" events `, () => {
},
evalRuleResults: [true, false, false],
evalMissingFields: ["field3"],
evalErrors: flagsResult.flagB.config?.evaluationErrors,
}),
]),
);
Expand Down
29 changes: 9 additions & 20 deletions packages/node-sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,19 +145,7 @@ type BulkEvent =
attributes?: Attributes;
context?: TrackingMeta;
}
| {
type: "feature-flag-event";
action: "check" | "check-config";
key: string;
targetingVersion?: number;
evalResult:
| boolean
| { key: string; payload: any }
| { key: undefined; payload: undefined };
evalContext?: Record<string, any>;
evalRuleResults?: boolean[];
evalMissingFields?: string[];
}
| ({ type: "feature-flag-event" } & FlagEvent)
| {
type: "event";
event: string;
Expand Down Expand Up @@ -1250,6 +1238,7 @@ export class ReflagClient {
* @param event.evalContext - The evaluation context of the flag to send.
* @param event.evalRuleResults - The evaluation rule results of the flag to send.
* @param event.evalMissingFields - The evaluation missing fields of the flag to send.
* @param event.evalErrors - The non-fatal evaluation diagnostics of the flag to send.
*
* @throws An error if the event is invalid.
*
Expand Down Expand Up @@ -1290,6 +1279,10 @@ export class ReflagClient {
Array.isArray(event.evalMissingFields),
"event missing fields must be an array",
);
ok(
event.evalErrors === undefined || Array.isArray(event.evalErrors),
"event evaluation errors must be an array",
);

const contextKey = new URLSearchParams(
flattenJSON(event.evalContext || {}),
Expand All @@ -1315,13 +1308,7 @@ export class ReflagClient {

await this.batchBuffer.add({
type: "feature-flag-event",
action: event.action,
key: event.key,
targetingVersion: event.targetingVersion,
evalContext: event.evalContext,
evalResult: event.evalResult,
evalRuleResults: event.evalRuleResults,
evalMissingFields: event.evalMissingFields,
...event,
});
}

Expand Down Expand Up @@ -1596,6 +1583,7 @@ export class ReflagClient {
evalContext: context,
evalRuleResults: flag.ruleEvaluationResults,
evalMissingFields: flag.missingContextFields,
evalErrors: flag.evaluationErrors,
})
.catch((err) => {
client.logger?.error(
Expand All @@ -1619,6 +1607,7 @@ export class ReflagClient {
evalContext: context,
evalRuleResults: config?.ruleEvaluationResults,
evalMissingFields: config?.missingContextFields,
evalErrors: config?.evaluationErrors,
})
.catch((err) => {
client.logger?.error(
Expand Down
6 changes: 6 additions & 0 deletions packages/node-sdk/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,14 @@ export type FlagEvent = {

/**
* The missing fields in the evaluation context (optional).
* @deprecated Use `evalErrors` and check for `MISSING_CONTEXT_FIELD`.
**/
evalMissingFields?: string[];

/**
* Non-fatal diagnostics produced while evaluating targeting rules (optional).
**/
evalErrors?: EvaluationError[];
};

/**
Expand Down
26 changes: 26 additions & 0 deletions packages/node-sdk/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1716,10 +1716,34 @@ describe("ReflagClient", () => {
evalContext: context,
evalRuleResults: [true],
evalMissingFields: [],
evalErrors: undefined,
},
]);
});

it("`isEnabled` sends evaluation errors", async () => {
const context = {
company,
user,
other: otherContext,
};

await client.initialize();
expect(client.getFlag(context, "flag2").isEnabled).toBe(false);
await client.flush();

const checkEvents = httpClient.post.mock.calls
.flatMap((call) => call[2])
.filter((item) => item.action === "check");

expect(checkEvents).toEqual([
expect.objectContaining({
key: "flag2",
evalErrors: [missingContextFieldError("attributeKey")],
}),
]);
});

it("`isEnabled` warns about missing context fields", async () => {
const context = {
company,
Expand Down Expand Up @@ -1989,6 +2013,7 @@ describe("ReflagClient", () => {
evalContext: context,
evalRuleResults: [true],
evalMissingFields: [],
evalErrors: undefined,
},
]);
});
Expand Down Expand Up @@ -2023,6 +2048,7 @@ describe("ReflagClient", () => {
evalResult: false,
evalRuleResults: undefined,
evalMissingFields: undefined,
evalErrors: undefined,
},
]);
});
Expand Down
Loading