Skip to content

Commit

Permalink
Merge pull request #14326 from getsentry/cmanallen/open-feature-integ…
Browse files Browse the repository at this point in the history
…ration

feat(flags): Add OpenFeature integration
  • Loading branch information
cmanallen authored Dec 2, 2024
2 parents 4195a8e + afd4f78 commit a292a60
Show file tree
Hide file tree
Showing 11 changed files with 349 additions and 12 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { expect } from '@playwright/test';

import { sentryTest } from '../../../../../utils/fixtures';

import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers';

const FLAG_BUFFER_SIZE = 100; // Corresponds to constant in featureFlags.ts, in browser utils.

sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => {
if (shouldSkipFeatureFlagsTest()) {
sentryTest.skip();
}

await page.route('https://dsn.ingest.sentry.io/**/*', route => {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'test-id' }),
});
});

const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true });
await page.goto(url);

await page.evaluate(bufferSize => {
const client = (window as any).initialize();
for (let i = 1; i <= bufferSize; i++) {
client.getBooleanValue(`feat${i}`, false);
}
client.getBooleanValue(`feat${bufferSize + 1}`, true); // eviction
client.getBooleanValue('feat3', true); // update
}, FLAG_BUFFER_SIZE);

const reqPromise = waitForErrorRequest(page);
await page.locator('#error').click();
const req = await reqPromise;
const event = envelopeRequestParser(req);

const expectedFlags = [{ flag: 'feat2', result: false }];
for (let i = 4; i <= FLAG_BUFFER_SIZE; i++) {
expectedFlags.push({ flag: `feat${i}`, result: false });
}
expectedFlags.push({ flag: `feat${FLAG_BUFFER_SIZE + 1}`, result: true });
expectedFlags.push({ flag: 'feat3', result: true });

expect(event.contexts?.flags?.values).toEqual(expectedFlags);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;
window.sentryOpenFeatureIntegration = Sentry.openFeatureIntegration();

Sentry.init({
dsn: 'https://[email protected]/1337',
sampleRate: 1.0,
integrations: [window.sentryOpenFeatureIntegration],
});

window.initialize = () => {
return {
getBooleanValue(flag, value) {
let hook = new Sentry.OpenFeatureIntegrationHook();
hook.error({ flagKey: flag, defaultValue: false }, new Error('flag eval error'));
return value;
},
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { expect } from '@playwright/test';

import { sentryTest } from '../../../../../utils/fixtures';

import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers';

const FLAG_BUFFER_SIZE = 100; // Corresponds to constant in featureFlags.ts, in browser utils.

sentryTest('Flag evaluation error hook', async ({ getLocalTestUrl, page }) => {
if (shouldSkipFeatureFlagsTest()) {
sentryTest.skip();
}

await page.route('https://dsn.ingest.sentry.io/**/*', route => {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'test-id' }),
});
});

const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true });
await page.goto(url);

await page.evaluate(bufferSize => {
const client = (window as any).initialize();
for (let i = 1; i <= bufferSize; i++) {
client.getBooleanValue(`feat${i}`, false);
}
client.getBooleanValue(`feat${bufferSize + 1}`, true); // eviction
client.getBooleanValue('feat3', true); // update
}, FLAG_BUFFER_SIZE);

const reqPromise = waitForErrorRequest(page);
await page.locator('#error').click();
const req = await reqPromise;
const event = envelopeRequestParser(req);

// Default value is mocked as false -- these will all error and use default
// value
const expectedFlags = [{ flag: 'feat2', result: false }];
for (let i = 4; i <= FLAG_BUFFER_SIZE; i++) {
expectedFlags.push({ flag: `feat${i}`, result: false });
}
expectedFlags.push({ flag: `feat${FLAG_BUFFER_SIZE + 1}`, result: false });
expectedFlags.push({ flag: 'feat3', result: false });

expect(event.contexts?.flags?.values).toEqual(expectedFlags);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;
window.sentryOpenFeatureIntegration = Sentry.openFeatureIntegration();

Sentry.init({
dsn: 'https://[email protected]/1337',
sampleRate: 1.0,
integrations: [window.sentryOpenFeatureIntegration],
});

window.initialize = () => {
return {
getBooleanValue(flag, value) {
let hook = new Sentry.OpenFeatureIntegrationHook();
hook.after(null, { flagKey: flag, value: value });
return value;
},
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
document.getElementById('error').addEventListener('click', () => {
throw new Error('Button triggered error');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<button id="error">Throw Error</button>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { expect } from '@playwright/test';

import { sentryTest } from '../../../../../utils/fixtures';

import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers';

import type { Scope } from '@sentry/browser';

sentryTest('Flag evaluations in forked scopes are stored separately.', async ({ getLocalTestUrl, page }) => {
if (shouldSkipFeatureFlagsTest()) {
sentryTest.skip();
}

await page.route('https://dsn.ingest.sentry.io/**/*', route => {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'test-id' }),
});
});

const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true });
await page.goto(url);

const forkedReqPromise = waitForErrorRequest(page, event => !!event.tags && event.tags.isForked === true);
const mainReqPromise = waitForErrorRequest(page, event => !!event.tags && event.tags.isForked === false);

await page.waitForFunction(() => {
const Sentry = (window as any).Sentry;
const errorButton = document.querySelector('#error') as HTMLButtonElement;
const client = (window as any).initialize();

client.getBooleanValue('shared', true);

Sentry.withScope((scope: Scope) => {
client.getBooleanValue('forked', true);
client.getBooleanValue('shared', false);
scope.setTag('isForked', true);
if (errorButton) {
errorButton.click();
}
});

client.getBooleanValue('main', true);
Sentry.getCurrentScope().setTag('isForked', false);
errorButton.click();
return true;
});

const forkedReq = await forkedReqPromise;
const forkedEvent = envelopeRequestParser(forkedReq);

const mainReq = await mainReqPromise;
const mainEvent = envelopeRequestParser(mainReq);

expect(forkedEvent.contexts?.flags?.values).toEqual([
{ flag: 'forked', result: true },
{ flag: 'shared', result: false },
]);

expect(mainEvent.contexts?.flags?.values).toEqual([
{ flag: 'shared', result: true },
{ flag: 'main', result: true },
]);
});
17 changes: 5 additions & 12 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,7 @@ export {
captureFeedback,
} from '@sentry/core';

export {
replayIntegration,
getReplay,
} from '@sentry-internal/replay';
export { replayIntegration, getReplay } from '@sentry-internal/replay';
export type {
ReplayEventType,
ReplayEventWithTime,
Expand All @@ -36,17 +33,11 @@ export { replayCanvasIntegration } from '@sentry-internal/replay-canvas';
import { feedbackAsyncIntegration } from './feedbackAsync';
import { feedbackSyncIntegration } from './feedbackSync';
export { feedbackAsyncIntegration, feedbackSyncIntegration, feedbackSyncIntegration as feedbackIntegration };
export {
getFeedback,
sendFeedback,
} from '@sentry-internal/feedback';
export { getFeedback, sendFeedback } from '@sentry-internal/feedback';

export * from './metrics';

export {
defaultRequestInstrumentationOptions,
instrumentOutgoingRequests,
} from './tracing/request';
export { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from './tracing/request';
export {
browserTracingIntegration,
startBrowserTracingNavigationSpan,
Expand Down Expand Up @@ -77,4 +68,6 @@ export type { Span } from '@sentry/types';
export { makeBrowserOfflineTransport } from './transports/offline';
export { browserProfilingIntegration } from './profiling/integration';
export { spotlightBrowserIntegration } from './integrations/spotlight';
export { copyFlagsFromScopeToEvent, insertFlagToScope } from './utils/featureFlags';
export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler } from './integrations/featureFlags/launchdarkly';
export { openFeatureIntegration, OpenFeatureIntegrationHook } from './integrations/featureFlags/openfeature';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { openFeatureIntegration, OpenFeatureIntegrationHook } from './integration';
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* OpenFeature integration.
*
* Add the openFeatureIntegration() function call to your integration lists.
* Add the integration hook to your OpenFeature object.
* - OpenFeature.getClient().addHooks(new OpenFeatureIntegrationHook());
*/
import type { Client, Event, EventHint, IntegrationFn } from '@sentry/types';
import type { EvaluationDetails, HookContext, HookHints, JsonValue, OpenFeatureHook } from './types';

import { defineIntegration } from '@sentry/core';
import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags';

export const openFeatureIntegration = defineIntegration(() => {
return {
name: 'OpenFeature',

processEvent(event: Event, _hint: EventHint, _client: Client): Event {
return copyFlagsFromScopeToEvent(event);
},
};
}) satisfies IntegrationFn;

/**
* OpenFeature Hook class implementation.
*/
export class OpenFeatureIntegrationHook implements OpenFeatureHook {
/**
* Successful evaluation result.
*/
public after(_hookContext: Readonly<HookContext<JsonValue>>, evaluationDetails: EvaluationDetails<JsonValue>): void {
insertFlagToScope(evaluationDetails.flagKey, evaluationDetails.value);
}

/**
* On error evaluation result.
*/
public error(hookContext: Readonly<HookContext<JsonValue>>, _error: unknown, _hookHints?: HookHints): void {
insertFlagToScope(hookContext.flagKey, hookContext.defaultValue);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
export type FlagValue = boolean | string | number | JsonValue;
export type FlagValueType = 'boolean' | 'string' | 'number' | 'object';
export type JsonArray = JsonValue[];
export type JsonObject = { [key: string]: JsonValue };
export type JsonValue = PrimitiveValue | JsonObject | JsonArray;
export type Metadata = Record<string, string>;
export type PrimitiveValue = null | boolean | string | number;
export type FlagMetadata = Record<string, string | number | boolean>;
export const StandardResolutionReasons = {
STATIC: 'STATIC',
DEFAULT: 'DEFAULT',
TARGETING_MATCH: 'TARGETING_MATCH',
SPLIT: 'SPLIT',
CACHED: 'CACHED',
DISABLED: 'DISABLED',
UNKNOWN: 'UNKNOWN',
STALE: 'STALE',
ERROR: 'ERROR',
} as const;
export enum ErrorCode {
PROVIDER_NOT_READY = 'PROVIDER_NOT_READY',
PROVIDER_FATAL = 'PROVIDER_FATAL',
FLAG_NOT_FOUND = 'FLAG_NOT_FOUND',
PARSE_ERROR = 'PARSE_ERROR',
TYPE_MISMATCH = 'TYPE_MISMATCH',
TARGETING_KEY_MISSING = 'TARGETING_KEY_MISSING',
INVALID_CONTEXT = 'INVALID_CONTEXT',
GENERAL = 'GENERAL',
}
export interface Logger {
error(...args: unknown[]): void;
warn(...args: unknown[]): void;
info(...args: unknown[]): void;
debug(...args: unknown[]): void;
}
export type ResolutionReason = keyof typeof StandardResolutionReasons | (string & Record<never, never>);
export type EvaluationContextValue =
| PrimitiveValue
| Date
| { [key: string]: EvaluationContextValue }
| EvaluationContextValue[];
export type EvaluationContext = {
targetingKey?: string;
} & Record<string, EvaluationContextValue>;
export interface ProviderMetadata extends Readonly<Metadata> {
readonly name: string;
}
export interface ClientMetadata {
readonly name?: string;
readonly domain?: string;
readonly version?: string;
readonly providerMetadata: ProviderMetadata;
}
export type HookHints = Readonly<Record<string, unknown>>;
export interface HookContext<T extends FlagValue = FlagValue> {
readonly flagKey: string;
readonly defaultValue: T;
readonly flagValueType: FlagValueType;
readonly context: Readonly<EvaluationContext>;
readonly clientMetadata: ClientMetadata;
readonly providerMetadata: ProviderMetadata;
readonly logger: Logger;
}
export interface BeforeHookContext extends HookContext {
context: EvaluationContext;
}
export type ResolutionDetails<U> = {
value: U;
variant?: string;
flagMetadata?: FlagMetadata;
reason?: ResolutionReason;
errorCode?: ErrorCode;
errorMessage?: string;
};
export type EvaluationDetails<T extends FlagValue> = {
flagKey: string;
flagMetadata: Readonly<FlagMetadata>;
} & ResolutionDetails<T>;
export interface BaseHook<T extends FlagValue = FlagValue, BeforeHookReturn = unknown, HooksReturn = unknown> {
before?(hookContext: BeforeHookContext, hookHints?: HookHints): BeforeHookReturn;
after?(
hookContext: Readonly<HookContext<T>>,
evaluationDetails: EvaluationDetails<T>,
hookHints?: HookHints,
): HooksReturn;
error?(hookContext: Readonly<HookContext<T>>, error: unknown, hookHints?: HookHints): HooksReturn;
finally?(hookContext: Readonly<HookContext<T>>, hookHints?: HookHints): HooksReturn;
}
export type OpenFeatureHook = BaseHook<FlagValue, void, void>;

0 comments on commit a292a60

Please sign in to comment.