Skip to content

Commit a292a60

Browse files
authored
Merge pull request #14326 from getsentry/cmanallen/open-feature-integration
feat(flags): Add OpenFeature integration
2 parents 4195a8e + afd4f78 commit a292a60

File tree

11 files changed

+349
-12
lines changed

11 files changed

+349
-12
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../../utils/fixtures';
4+
5+
import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers';
6+
7+
const FLAG_BUFFER_SIZE = 100; // Corresponds to constant in featureFlags.ts, in browser utils.
8+
9+
sentryTest('Basic test with eviction, update, and no async tasks', async ({ getLocalTestUrl, page }) => {
10+
if (shouldSkipFeatureFlagsTest()) {
11+
sentryTest.skip();
12+
}
13+
14+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
15+
return route.fulfill({
16+
status: 200,
17+
contentType: 'application/json',
18+
body: JSON.stringify({ id: 'test-id' }),
19+
});
20+
});
21+
22+
const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true });
23+
await page.goto(url);
24+
25+
await page.evaluate(bufferSize => {
26+
const client = (window as any).initialize();
27+
for (let i = 1; i <= bufferSize; i++) {
28+
client.getBooleanValue(`feat${i}`, false);
29+
}
30+
client.getBooleanValue(`feat${bufferSize + 1}`, true); // eviction
31+
client.getBooleanValue('feat3', true); // update
32+
}, FLAG_BUFFER_SIZE);
33+
34+
const reqPromise = waitForErrorRequest(page);
35+
await page.locator('#error').click();
36+
const req = await reqPromise;
37+
const event = envelopeRequestParser(req);
38+
39+
const expectedFlags = [{ flag: 'feat2', result: false }];
40+
for (let i = 4; i <= FLAG_BUFFER_SIZE; i++) {
41+
expectedFlags.push({ flag: `feat${i}`, result: false });
42+
}
43+
expectedFlags.push({ flag: `feat${FLAG_BUFFER_SIZE + 1}`, result: true });
44+
expectedFlags.push({ flag: 'feat3', result: true });
45+
46+
expect(event.contexts?.flags?.values).toEqual(expectedFlags);
47+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
window.sentryOpenFeatureIntegration = Sentry.openFeatureIntegration();
5+
6+
Sentry.init({
7+
dsn: 'https://[email protected]/1337',
8+
sampleRate: 1.0,
9+
integrations: [window.sentryOpenFeatureIntegration],
10+
});
11+
12+
window.initialize = () => {
13+
return {
14+
getBooleanValue(flag, value) {
15+
let hook = new Sentry.OpenFeatureIntegrationHook();
16+
hook.error({ flagKey: flag, defaultValue: false }, new Error('flag eval error'));
17+
return value;
18+
},
19+
};
20+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../../utils/fixtures';
4+
5+
import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers';
6+
7+
const FLAG_BUFFER_SIZE = 100; // Corresponds to constant in featureFlags.ts, in browser utils.
8+
9+
sentryTest('Flag evaluation error hook', async ({ getLocalTestUrl, page }) => {
10+
if (shouldSkipFeatureFlagsTest()) {
11+
sentryTest.skip();
12+
}
13+
14+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
15+
return route.fulfill({
16+
status: 200,
17+
contentType: 'application/json',
18+
body: JSON.stringify({ id: 'test-id' }),
19+
});
20+
});
21+
22+
const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true });
23+
await page.goto(url);
24+
25+
await page.evaluate(bufferSize => {
26+
const client = (window as any).initialize();
27+
for (let i = 1; i <= bufferSize; i++) {
28+
client.getBooleanValue(`feat${i}`, false);
29+
}
30+
client.getBooleanValue(`feat${bufferSize + 1}`, true); // eviction
31+
client.getBooleanValue('feat3', true); // update
32+
}, FLAG_BUFFER_SIZE);
33+
34+
const reqPromise = waitForErrorRequest(page);
35+
await page.locator('#error').click();
36+
const req = await reqPromise;
37+
const event = envelopeRequestParser(req);
38+
39+
// Default value is mocked as false -- these will all error and use default
40+
// value
41+
const expectedFlags = [{ flag: 'feat2', result: false }];
42+
for (let i = 4; i <= FLAG_BUFFER_SIZE; i++) {
43+
expectedFlags.push({ flag: `feat${i}`, result: false });
44+
}
45+
expectedFlags.push({ flag: `feat${FLAG_BUFFER_SIZE + 1}`, result: false });
46+
expectedFlags.push({ flag: 'feat3', result: false });
47+
48+
expect(event.contexts?.flags?.values).toEqual(expectedFlags);
49+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
window.sentryOpenFeatureIntegration = Sentry.openFeatureIntegration();
5+
6+
Sentry.init({
7+
dsn: 'https://[email protected]/1337',
8+
sampleRate: 1.0,
9+
integrations: [window.sentryOpenFeatureIntegration],
10+
});
11+
12+
window.initialize = () => {
13+
return {
14+
getBooleanValue(flag, value) {
15+
let hook = new Sentry.OpenFeatureIntegrationHook();
16+
hook.after(null, { flagKey: flag, value: value });
17+
return value;
18+
},
19+
};
20+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
document.getElementById('error').addEventListener('click', () => {
2+
throw new Error('Button triggered error');
3+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<body>
7+
<button id="error">Throw Error</button>
8+
</body>
9+
</html>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../../utils/fixtures';
4+
5+
import { envelopeRequestParser, shouldSkipFeatureFlagsTest, waitForErrorRequest } from '../../../../../utils/helpers';
6+
7+
import type { Scope } from '@sentry/browser';
8+
9+
sentryTest('Flag evaluations in forked scopes are stored separately.', async ({ getLocalTestUrl, page }) => {
10+
if (shouldSkipFeatureFlagsTest()) {
11+
sentryTest.skip();
12+
}
13+
14+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
15+
return route.fulfill({
16+
status: 200,
17+
contentType: 'application/json',
18+
body: JSON.stringify({ id: 'test-id' }),
19+
});
20+
});
21+
22+
const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true });
23+
await page.goto(url);
24+
25+
const forkedReqPromise = waitForErrorRequest(page, event => !!event.tags && event.tags.isForked === true);
26+
const mainReqPromise = waitForErrorRequest(page, event => !!event.tags && event.tags.isForked === false);
27+
28+
await page.waitForFunction(() => {
29+
const Sentry = (window as any).Sentry;
30+
const errorButton = document.querySelector('#error') as HTMLButtonElement;
31+
const client = (window as any).initialize();
32+
33+
client.getBooleanValue('shared', true);
34+
35+
Sentry.withScope((scope: Scope) => {
36+
client.getBooleanValue('forked', true);
37+
client.getBooleanValue('shared', false);
38+
scope.setTag('isForked', true);
39+
if (errorButton) {
40+
errorButton.click();
41+
}
42+
});
43+
44+
client.getBooleanValue('main', true);
45+
Sentry.getCurrentScope().setTag('isForked', false);
46+
errorButton.click();
47+
return true;
48+
});
49+
50+
const forkedReq = await forkedReqPromise;
51+
const forkedEvent = envelopeRequestParser(forkedReq);
52+
53+
const mainReq = await mainReqPromise;
54+
const mainEvent = envelopeRequestParser(mainReq);
55+
56+
expect(forkedEvent.contexts?.flags?.values).toEqual([
57+
{ flag: 'forked', result: true },
58+
{ flag: 'shared', result: false },
59+
]);
60+
61+
expect(mainEvent.contexts?.flags?.values).toEqual([
62+
{ flag: 'shared', result: true },
63+
{ flag: 'main', result: true },
64+
]);
65+
});

packages/browser/src/index.ts

+5-12
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,7 @@ export {
1515
captureFeedback,
1616
} from '@sentry/core';
1717

18-
export {
19-
replayIntegration,
20-
getReplay,
21-
} from '@sentry-internal/replay';
18+
export { replayIntegration, getReplay } from '@sentry-internal/replay';
2219
export type {
2320
ReplayEventType,
2421
ReplayEventWithTime,
@@ -36,17 +33,11 @@ export { replayCanvasIntegration } from '@sentry-internal/replay-canvas';
3633
import { feedbackAsyncIntegration } from './feedbackAsync';
3734
import { feedbackSyncIntegration } from './feedbackSync';
3835
export { feedbackAsyncIntegration, feedbackSyncIntegration, feedbackSyncIntegration as feedbackIntegration };
39-
export {
40-
getFeedback,
41-
sendFeedback,
42-
} from '@sentry-internal/feedback';
36+
export { getFeedback, sendFeedback } from '@sentry-internal/feedback';
4337

4438
export * from './metrics';
4539

46-
export {
47-
defaultRequestInstrumentationOptions,
48-
instrumentOutgoingRequests,
49-
} from './tracing/request';
40+
export { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from './tracing/request';
5041
export {
5142
browserTracingIntegration,
5243
startBrowserTracingNavigationSpan,
@@ -77,4 +68,6 @@ export type { Span } from '@sentry/types';
7768
export { makeBrowserOfflineTransport } from './transports/offline';
7869
export { browserProfilingIntegration } from './profiling/integration';
7970
export { spotlightBrowserIntegration } from './integrations/spotlight';
71+
export { copyFlagsFromScopeToEvent, insertFlagToScope } from './utils/featureFlags';
8072
export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler } from './integrations/featureFlags/launchdarkly';
73+
export { openFeatureIntegration, OpenFeatureIntegrationHook } from './integrations/featureFlags/openfeature';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { openFeatureIntegration, OpenFeatureIntegrationHook } from './integration';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* OpenFeature integration.
3+
*
4+
* Add the openFeatureIntegration() function call to your integration lists.
5+
* Add the integration hook to your OpenFeature object.
6+
* - OpenFeature.getClient().addHooks(new OpenFeatureIntegrationHook());
7+
*/
8+
import type { Client, Event, EventHint, IntegrationFn } from '@sentry/types';
9+
import type { EvaluationDetails, HookContext, HookHints, JsonValue, OpenFeatureHook } from './types';
10+
11+
import { defineIntegration } from '@sentry/core';
12+
import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags';
13+
14+
export const openFeatureIntegration = defineIntegration(() => {
15+
return {
16+
name: 'OpenFeature',
17+
18+
processEvent(event: Event, _hint: EventHint, _client: Client): Event {
19+
return copyFlagsFromScopeToEvent(event);
20+
},
21+
};
22+
}) satisfies IntegrationFn;
23+
24+
/**
25+
* OpenFeature Hook class implementation.
26+
*/
27+
export class OpenFeatureIntegrationHook implements OpenFeatureHook {
28+
/**
29+
* Successful evaluation result.
30+
*/
31+
public after(_hookContext: Readonly<HookContext<JsonValue>>, evaluationDetails: EvaluationDetails<JsonValue>): void {
32+
insertFlagToScope(evaluationDetails.flagKey, evaluationDetails.value);
33+
}
34+
35+
/**
36+
* On error evaluation result.
37+
*/
38+
public error(hookContext: Readonly<HookContext<JsonValue>>, _error: unknown, _hookHints?: HookHints): void {
39+
insertFlagToScope(hookContext.flagKey, hookContext.defaultValue);
40+
}
41+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
export type FlagValue = boolean | string | number | JsonValue;
2+
export type FlagValueType = 'boolean' | 'string' | 'number' | 'object';
3+
export type JsonArray = JsonValue[];
4+
export type JsonObject = { [key: string]: JsonValue };
5+
export type JsonValue = PrimitiveValue | JsonObject | JsonArray;
6+
export type Metadata = Record<string, string>;
7+
export type PrimitiveValue = null | boolean | string | number;
8+
export type FlagMetadata = Record<string, string | number | boolean>;
9+
export const StandardResolutionReasons = {
10+
STATIC: 'STATIC',
11+
DEFAULT: 'DEFAULT',
12+
TARGETING_MATCH: 'TARGETING_MATCH',
13+
SPLIT: 'SPLIT',
14+
CACHED: 'CACHED',
15+
DISABLED: 'DISABLED',
16+
UNKNOWN: 'UNKNOWN',
17+
STALE: 'STALE',
18+
ERROR: 'ERROR',
19+
} as const;
20+
export enum ErrorCode {
21+
PROVIDER_NOT_READY = 'PROVIDER_NOT_READY',
22+
PROVIDER_FATAL = 'PROVIDER_FATAL',
23+
FLAG_NOT_FOUND = 'FLAG_NOT_FOUND',
24+
PARSE_ERROR = 'PARSE_ERROR',
25+
TYPE_MISMATCH = 'TYPE_MISMATCH',
26+
TARGETING_KEY_MISSING = 'TARGETING_KEY_MISSING',
27+
INVALID_CONTEXT = 'INVALID_CONTEXT',
28+
GENERAL = 'GENERAL',
29+
}
30+
export interface Logger {
31+
error(...args: unknown[]): void;
32+
warn(...args: unknown[]): void;
33+
info(...args: unknown[]): void;
34+
debug(...args: unknown[]): void;
35+
}
36+
export type ResolutionReason = keyof typeof StandardResolutionReasons | (string & Record<never, never>);
37+
export type EvaluationContextValue =
38+
| PrimitiveValue
39+
| Date
40+
| { [key: string]: EvaluationContextValue }
41+
| EvaluationContextValue[];
42+
export type EvaluationContext = {
43+
targetingKey?: string;
44+
} & Record<string, EvaluationContextValue>;
45+
export interface ProviderMetadata extends Readonly<Metadata> {
46+
readonly name: string;
47+
}
48+
export interface ClientMetadata {
49+
readonly name?: string;
50+
readonly domain?: string;
51+
readonly version?: string;
52+
readonly providerMetadata: ProviderMetadata;
53+
}
54+
export type HookHints = Readonly<Record<string, unknown>>;
55+
export interface HookContext<T extends FlagValue = FlagValue> {
56+
readonly flagKey: string;
57+
readonly defaultValue: T;
58+
readonly flagValueType: FlagValueType;
59+
readonly context: Readonly<EvaluationContext>;
60+
readonly clientMetadata: ClientMetadata;
61+
readonly providerMetadata: ProviderMetadata;
62+
readonly logger: Logger;
63+
}
64+
export interface BeforeHookContext extends HookContext {
65+
context: EvaluationContext;
66+
}
67+
export type ResolutionDetails<U> = {
68+
value: U;
69+
variant?: string;
70+
flagMetadata?: FlagMetadata;
71+
reason?: ResolutionReason;
72+
errorCode?: ErrorCode;
73+
errorMessage?: string;
74+
};
75+
export type EvaluationDetails<T extends FlagValue> = {
76+
flagKey: string;
77+
flagMetadata: Readonly<FlagMetadata>;
78+
} & ResolutionDetails<T>;
79+
export interface BaseHook<T extends FlagValue = FlagValue, BeforeHookReturn = unknown, HooksReturn = unknown> {
80+
before?(hookContext: BeforeHookContext, hookHints?: HookHints): BeforeHookReturn;
81+
after?(
82+
hookContext: Readonly<HookContext<T>>,
83+
evaluationDetails: EvaluationDetails<T>,
84+
hookHints?: HookHints,
85+
): HooksReturn;
86+
error?(hookContext: Readonly<HookContext<T>>, error: unknown, hookHints?: HookHints): HooksReturn;
87+
finally?(hookContext: Readonly<HookContext<T>>, hookHints?: HookHints): HooksReturn;
88+
}
89+
export type OpenFeatureHook = BaseHook<FlagValue, void, void>;

0 commit comments

Comments
 (0)