diff --git a/.changeset/send-evaluation-diagnostics.md b/.changeset/send-evaluation-diagnostics.md new file mode 100644 index 00000000..17b6e8d8 --- /dev/null +++ b/.changeset/send-evaluation-diagnostics.md @@ -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. diff --git a/packages/browser-sdk/src/bulkQueue.ts b/packages/browser-sdk/src/bulkQueue.ts index 568178e7..4ce9ff06 100644 --- a/packages/browser-sdk/src/bulkQueue.ts +++ b/packages/browser-sdk/src/bulkQueue.ts @@ -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"; @@ -39,6 +40,7 @@ export type BulkEvent = evalContext?: Record; evalRuleResults?: boolean[]; evalMissingFields?: string[]; + evalErrors?: CheckEvent["evaluationErrors"]; } | { type: "prompt-event"; diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 66b942d6..379c3278 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -1417,6 +1417,7 @@ export class ReflagClient { version: f?.targetingVersion, ruleEvaluationResults: f?.ruleEvaluationResults, missingContextFields: f?.missingContextFields, + evaluationErrors: f?.evaluationErrors, value, }) .catch(() => { @@ -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, diff --git a/packages/browser-sdk/src/flag/flagCache.ts b/packages/browser-sdk/src/flag/flagCache.ts index ddad0fd7..442e86b6 100644 --- a/packages/browser-sdk/src/flag/flagCache.ts +++ b/packages/browser-sdk/src/flag/flagCache.ts @@ -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"; @@ -28,6 +28,23 @@ function parseOptIn(optIn: any): RawFlagOptIn | null | undefined { }; } +function isEvaluationErrorArray( + value: any, +): value is NonNullable { + 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; @@ -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") @@ -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, }), diff --git a/packages/browser-sdk/src/flag/flags.ts b/packages/browser-sdk/src/flag/flags.ts index 1bdb207f..5809774b 100644 --- a/packages/browser-sdk/src/flag/flags.ts +++ b/packages/browser-sdk/src/flag/flags.ts @@ -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. */ @@ -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"]; }; }; @@ -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`; @@ -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); }); diff --git a/packages/browser-sdk/test/flagCache.test.ts b/packages/browser-sdk/test/flagCache.test.ts index 70c795b6..1e794e7c 100644 --- a/packages/browser-sdk/test/flagCache.test.ts +++ b/packages/browser-sdk/test/flagCache.test.ts @@ -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", () => { diff --git a/packages/browser-sdk/test/mocks/handlers.ts b/packages/browser-sdk/test/mocks/handlers.ts index 79a202f1..d1d1a08e 100644 --- a/packages/browser-sdk/test/mocks/handlers.ts +++ b/packages/browser-sdk/test/mocks/handlers.ts @@ -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, @@ -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.', + }, + ], }, }, }, diff --git a/packages/browser-sdk/test/usage.test.ts b/packages/browser-sdk/test/usage.test.ts index b61bf8aa..b39ac8e9 100644 --- a/packages/browser-sdk/test/usage.test.ts +++ b/packages/browser-sdk/test/usage.test.ts @@ -498,6 +498,7 @@ describe(`sends "check" events `, () => { version: 1, missingContextFields: ["field1", "field2"], ruleEvaluationResults: [false, true], + evaluationErrors: flagsResult.flagA.evaluationErrors, }, expect.any(Function), ); @@ -529,6 +530,7 @@ describe(`sends "check" events `, () => { evalResult: true, evalRuleResults: [false, true], evalMissingFields: ["field1", "field2"], + evalErrors: flagsResult.flagA.evaluationErrors, }), ]), ); @@ -580,6 +582,7 @@ describe(`sends "check" events `, () => { }, evalRuleResults: [true, false, false], evalMissingFields: ["field3"], + evalErrors: flagsResult.flagB.config?.evaluationErrors, }), ]), ); diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index 293ee46d..3548a55e 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -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; - evalRuleResults?: boolean[]; - evalMissingFields?: string[]; - } + | ({ type: "feature-flag-event" } & FlagEvent) | { type: "event"; event: string; @@ -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. * @@ -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 || {}), @@ -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, }); } @@ -1596,6 +1583,7 @@ export class ReflagClient { evalContext: context, evalRuleResults: flag.ruleEvaluationResults, evalMissingFields: flag.missingContextFields, + evalErrors: flag.evaluationErrors, }) .catch((err) => { client.logger?.error( @@ -1619,6 +1607,7 @@ export class ReflagClient { evalContext: context, evalRuleResults: config?.ruleEvaluationResults, evalMissingFields: config?.missingContextFields, + evalErrors: config?.evaluationErrors, }) .catch((err) => { client.logger?.error( diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index d7bd0123..13e79928 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -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[]; }; /** diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index 7a2e0155..90fefdbb 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -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, @@ -1989,6 +2013,7 @@ describe("ReflagClient", () => { evalContext: context, evalRuleResults: [true], evalMissingFields: [], + evalErrors: undefined, }, ]); }); @@ -2023,6 +2048,7 @@ describe("ReflagClient", () => { evalResult: false, evalRuleResults: undefined, evalMissingFields: undefined, + evalErrors: undefined, }, ]); });