Skip to content

Commit 70ccac1

Browse files
committed
Dup integration and utils to core, todos in index and integration
1 parent 3c13997 commit 70ccac1

File tree

6 files changed

+355
-2
lines changed

6 files changed

+355
-2
lines changed

packages/browser/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,13 @@ export {
6161
instrumentSupabaseClient,
6262
zodErrorsIntegration,
6363
thirdPartyErrorFilterIntegration,
64+
featureFlagsIntegration,
6465
} from '@sentry/core';
65-
export type { Span } from '@sentry/core';
66+
export type { Span, FeatureFlagsIntegration } from '@sentry/core';
6667
export { makeBrowserOfflineTransport } from './transports/offline';
6768
export { browserProfilingIntegration } from './profiling/integration';
6869
export { spotlightBrowserIntegration } from './integrations/spotlight';
6970
export { browserSessionIntegration } from './integrations/browsersession';
70-
export { featureFlagsIntegration, type FeatureFlagsIntegration } from './integrations/featureFlags';
7171
export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler } from './integrations/featureFlags/launchdarkly';
7272
export { openFeatureIntegration, OpenFeatureIntegrationHook } from './integrations/featureFlags/openfeature';
7373
export { unleashIntegration } from './integrations/featureFlags/unleash';

packages/core/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export { supabaseIntegration, instrumentSupabaseClient } from './integrations/su
112112
export { zodErrorsIntegration } from './integrations/zoderrors';
113113
export { thirdPartyErrorFilterIntegration } from './integrations/third-party-errors-filter';
114114
export { consoleIntegration } from './integrations/console';
115+
export { featureFlagsIntegration, type FeatureFlagsIntegration } from './integrations/featureFlags';
115116

116117
export { profiler } from './profiling';
117118
export { instrumentFetchRequest } from './fetch';
@@ -123,6 +124,8 @@ export { _INTERNAL_captureLog, _INTERNAL_flushLogsBuffer, _INTERNAL_captureSeria
123124
export { consoleLoggingIntegration } from './logs/console-integration';
124125

125126
export type { FeatureFlag } from './featureFlags';
127+
// TODO: ^export from utils?
128+
// TODO: how to export utils without making public?
126129

127130
export { applyAggregateErrorsToEvent } from './utils-hoist/aggregate-errors';
128131
export { getBreadcrumbLogLevelFromHttpStatusCode } from './utils-hoist/breadcrumb-log-level';
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { type Client } from '../../client';
2+
import { defineIntegration } from '../../integration';
3+
import { type Event, type EventHint } from '../../types-hoist/event';
4+
import { type Integration, type IntegrationFn } from '../../types-hoist/integration';
5+
import { type Span } from '../../types-hoist/span';
6+
import {
7+
bufferSpanFeatureFlag,
8+
copyFlagsFromScopeToEvent,
9+
freezeSpanFeatureFlags,
10+
insertFlagToScope,
11+
} from '../../utils/featureFlags';
12+
13+
export interface FeatureFlagsIntegration extends Integration {
14+
addFeatureFlag: (name: string, value: unknown) => void;
15+
}
16+
17+
/**
18+
* Sentry integration for buffering feature flag evaluations manually with an API, and
19+
* capturing them on error events and spans.
20+
*
21+
* See the [feature flag documentation](https://develop.sentry.dev/sdk/expected-features/#feature-flags) for more information.
22+
*
23+
* @example
24+
* ```
25+
* import * as Sentry from '@sentry/*'; //TODO:
26+
* import { type FeatureFlagsIntegration } from '@sentry/*';
27+
*
28+
* // Setup
29+
* Sentry.init(..., integrations: [Sentry.featureFlagsIntegration()])
30+
*
31+
* // Verify
32+
* const flagsIntegration = Sentry.getClient()?.getIntegrationByName<FeatureFlagsIntegration>('FeatureFlags');
33+
* if (flagsIntegration) {
34+
* flagsIntegration.addFeatureFlag('my-flag', true);
35+
* } else {
36+
* // check your setup
37+
* }
38+
* Sentry.captureException(Exception('broke')); // 'my-flag' should be captured to this Sentry event.
39+
* ```
40+
*/
41+
export const featureFlagsIntegration = defineIntegration(() => {
42+
return {
43+
name: 'FeatureFlags',
44+
45+
setup(client: Client) {
46+
client.on('spanEnd', (span: Span) => {
47+
freezeSpanFeatureFlags(span);
48+
});
49+
},
50+
51+
processEvent(event: Event, _hint: EventHint, _client: Client): Event {
52+
return copyFlagsFromScopeToEvent(event);
53+
},
54+
55+
addFeatureFlag(name: string, value: unknown): void {
56+
insertFlagToScope(name, value);
57+
bufferSpanFeatureFlag(name, value);
58+
},
59+
};
60+
}) as IntegrationFn<FeatureFlagsIntegration>;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { featureFlagsIntegration, type FeatureFlagsIntegration } from './featureFlagsIntegration';
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { getCurrentScope } from '../currentScopes';
2+
import { DEBUG_BUILD } from '../debug-build';
3+
import { type FeatureFlag } from '../featureFlags';
4+
import { type Event } from '../types-hoist/event';
5+
import { type Span } from '../types-hoist/span';
6+
import { logger } from '../utils-hoist/logger';
7+
import { GLOBAL_OBJ } from '../utils-hoist/worldwide';
8+
import { getActiveSpan } from './spanUtils';
9+
10+
/**
11+
* Ordered LRU cache for storing feature flags in the scope context. The name
12+
* of each flag in the buffer is unique, and the output of getAll() is ordered
13+
* from oldest to newest.
14+
*/
15+
16+
/**
17+
* Max size of the LRU flag buffer stored in Sentry scope and event contexts.
18+
*/
19+
export const FLAG_BUFFER_SIZE = 100;
20+
21+
/**
22+
* Max number of flag evaluations to record per span.
23+
*/
24+
export const MAX_FLAGS_PER_SPAN = 10;
25+
26+
// Global map of spans to feature flag buffers. Populated by feature flag integrations.
27+
GLOBAL_OBJ._spanToFlagBufferMap = new WeakMap<Span, FeatureFlag[]>();
28+
29+
const SPAN_FLAG_ATTRIBUTE_PREFIX = 'flag.evaluation.';
30+
31+
/**
32+
* Copies feature flags that are in current scope context to the event context
33+
*/
34+
export function copyFlagsFromScopeToEvent(event: Event): Event {
35+
const scope = getCurrentScope();
36+
const flagContext = scope.getScopeData().contexts.flags;
37+
const flagBuffer = flagContext ? flagContext.values : [];
38+
39+
if (!flagBuffer.length) {
40+
return event;
41+
}
42+
43+
if (event.contexts === undefined) {
44+
event.contexts = {};
45+
}
46+
event.contexts.flags = { values: [...flagBuffer] };
47+
return event;
48+
}
49+
50+
/**
51+
* Inserts a flag into the current scope's context while maintaining ordered LRU properties.
52+
* Not thread-safe. After inserting:
53+
* - The flag buffer is sorted in order of recency, with the newest evaluation at the end.
54+
* - The names in the buffer are always unique.
55+
* - The length of the buffer never exceeds `maxSize`.
56+
*
57+
* @param name Name of the feature flag to insert.
58+
* @param value Value of the feature flag.
59+
* @param maxSize Max number of flags the buffer should store. Default value should always be used in production.
60+
*/
61+
export function insertFlagToScope(name: string, value: unknown, maxSize: number = FLAG_BUFFER_SIZE): void {
62+
const scopeContexts = getCurrentScope().getScopeData().contexts;
63+
if (!scopeContexts.flags) {
64+
scopeContexts.flags = { values: [] };
65+
}
66+
const flags = scopeContexts.flags.values as FeatureFlag[];
67+
insertToFlagBuffer(flags, name, value, maxSize);
68+
}
69+
70+
/**
71+
* Exported for tests only. Currently only accepts boolean values (otherwise no-op).
72+
* Inserts a flag into a FeatureFlag array while maintaining the following properties:
73+
* - Flags are sorted in order of recency, with the newest evaluation at the end.
74+
* - The flag names are always unique.
75+
* - The length of the array never exceeds `maxSize`.
76+
*
77+
* @param flags The buffer to insert the flag into.
78+
* @param name Name of the feature flag to insert.
79+
* @param value Value of the feature flag.
80+
* @param maxSize Max number of flags the buffer should store. Default value should always be used in production.
81+
* @param allowEviction If true, the oldest flag is evicted when the buffer is full. Otherwise the new flag is dropped.
82+
*/
83+
export function insertToFlagBuffer(
84+
flags: FeatureFlag[],
85+
name: string,
86+
value: unknown,
87+
maxSize: number,
88+
allowEviction: boolean = true,
89+
): void {
90+
if (typeof value !== 'boolean') {
91+
return;
92+
}
93+
94+
if (flags.length > maxSize) {
95+
DEBUG_BUILD && logger.error(`[Feature Flags] insertToFlagBuffer called on a buffer larger than maxSize=${maxSize}`);
96+
return;
97+
}
98+
99+
// Check if the flag is already in the buffer - O(n)
100+
const index = flags.findIndex(f => f.flag === name);
101+
102+
if (index !== -1) {
103+
// The flag was found, remove it from its current position - O(n)
104+
flags.splice(index, 1);
105+
}
106+
107+
if (flags.length === maxSize) {
108+
if (allowEviction) {
109+
// If at capacity, pop the earliest flag - O(n)
110+
flags.shift();
111+
} else {
112+
return;
113+
}
114+
}
115+
116+
// Push the flag to the end - O(1)
117+
flags.push({
118+
flag: name,
119+
result: value,
120+
});
121+
}
122+
123+
/**
124+
* Records a feature flag evaluation for the active span, adding it to a weak map of flag buffers. This is a no-op for non-boolean values.
125+
* The keys in each buffer are unique. Once the buffer for a span reaches maxFlagsPerSpan, subsequent flags are dropped.
126+
*
127+
* @param name Name of the feature flag.
128+
* @param value Value of the feature flag. Non-boolean values are ignored.
129+
* @param maxFlagsPerSpan Max number of flags a buffer should store. Default value should always be used in production.
130+
*/
131+
export function bufferSpanFeatureFlag(
132+
name: string,
133+
value: unknown,
134+
maxFlagsPerSpan: number = MAX_FLAGS_PER_SPAN,
135+
): void {
136+
const spanFlagMap = GLOBAL_OBJ._spanToFlagBufferMap;
137+
if (!spanFlagMap || typeof value !== 'boolean') {
138+
return;
139+
}
140+
141+
const span = getActiveSpan();
142+
if (span) {
143+
const flags = spanFlagMap.get(span) || [];
144+
insertToFlagBuffer(flags, name, value, maxFlagsPerSpan, false);
145+
spanFlagMap.set(span, flags);
146+
}
147+
}
148+
149+
/**
150+
* Add the buffered feature flags for a span to the span attributes. Call this on span end.
151+
*
152+
* @param span Span to add flags to.
153+
*/
154+
export function freezeSpanFeatureFlags(span: Span): void {
155+
const flags = GLOBAL_OBJ._spanToFlagBufferMap?.get(span);
156+
if (flags) {
157+
span.setAttributes(
158+
Object.fromEntries(flags.map(flag => [`${SPAN_FLAG_ATTRIBUTE_PREFIX}${flag.flag}`, flag.result])),
159+
);
160+
}
161+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
import { getCurrentScope } from '../../../src/currentScopes';
3+
import { type FeatureFlag } from '../../../src/featureFlags';
4+
import { insertFlagToScope, insertToFlagBuffer } from '../../../src/utils/featureFlags';
5+
import { logger } from '../../../src/utils-hoist/logger';
6+
7+
describe('flags', () => {
8+
describe('insertFlagToScope()', () => {
9+
it('adds flags to the current scope context', () => {
10+
const maxSize = 3;
11+
insertFlagToScope('feat1', true, maxSize);
12+
insertFlagToScope('feat2', true, maxSize);
13+
insertFlagToScope('feat3', true, maxSize);
14+
insertFlagToScope('feat4', true, maxSize);
15+
16+
const scope = getCurrentScope();
17+
expect(scope.getScopeData().contexts.flags?.values).toEqual([
18+
{ flag: 'feat2', result: true },
19+
{ flag: 'feat3', result: true },
20+
{ flag: 'feat4', result: true },
21+
]);
22+
});
23+
});
24+
25+
describe('insertToFlagBuffer()', () => {
26+
const loggerSpy = vi.spyOn(logger, 'error');
27+
28+
afterEach(() => {
29+
loggerSpy.mockClear();
30+
});
31+
32+
it('maintains ordering and evicts the oldest entry', () => {
33+
const buffer: FeatureFlag[] = [];
34+
const maxSize = 3;
35+
insertToFlagBuffer(buffer, 'feat1', true, maxSize);
36+
insertToFlagBuffer(buffer, 'feat2', true, maxSize);
37+
insertToFlagBuffer(buffer, 'feat3', true, maxSize);
38+
insertToFlagBuffer(buffer, 'feat4', true, maxSize);
39+
40+
expect(buffer).toEqual([
41+
{ flag: 'feat2', result: true },
42+
{ flag: 'feat3', result: true },
43+
{ flag: 'feat4', result: true },
44+
]);
45+
});
46+
47+
it('does not duplicate same-name flags and updates order and values', () => {
48+
const buffer: FeatureFlag[] = [];
49+
const maxSize = 3;
50+
insertToFlagBuffer(buffer, 'feat1', true, maxSize);
51+
insertToFlagBuffer(buffer, 'feat2', true, maxSize);
52+
insertToFlagBuffer(buffer, 'feat3', true, maxSize);
53+
insertToFlagBuffer(buffer, 'feat3', false, maxSize);
54+
insertToFlagBuffer(buffer, 'feat1', false, maxSize);
55+
56+
expect(buffer).toEqual([
57+
{ flag: 'feat2', result: true },
58+
{ flag: 'feat3', result: false },
59+
{ flag: 'feat1', result: false },
60+
]);
61+
});
62+
63+
it('drops new entries when allowEviction is false and buffer is full', () => {
64+
const buffer: FeatureFlag[] = [];
65+
const maxSize = 0;
66+
insertToFlagBuffer(buffer, 'feat1', true, maxSize, false);
67+
insertToFlagBuffer(buffer, 'feat2', true, maxSize, false);
68+
insertToFlagBuffer(buffer, 'feat3', true, maxSize, false);
69+
70+
expect(buffer).toEqual([]);
71+
});
72+
73+
it('still updates order and values when allowEviction is false and buffer is full', () => {
74+
const buffer: FeatureFlag[] = [];
75+
const maxSize = 1;
76+
insertToFlagBuffer(buffer, 'feat1', false, maxSize, false);
77+
insertToFlagBuffer(buffer, 'feat1', true, maxSize, false);
78+
79+
expect(buffer).toEqual([{ flag: 'feat1', result: true }]);
80+
});
81+
82+
it('does not allocate unnecessary space', () => {
83+
const buffer: FeatureFlag[] = [];
84+
const maxSize = 1000;
85+
insertToFlagBuffer(buffer, 'feat1', true, maxSize);
86+
insertToFlagBuffer(buffer, 'feat2', true, maxSize);
87+
88+
expect(buffer).toEqual([
89+
{ flag: 'feat1', result: true },
90+
{ flag: 'feat2', result: true },
91+
]);
92+
});
93+
94+
it('does not accept non-boolean values', () => {
95+
const buffer: FeatureFlag[] = [];
96+
const maxSize = 1000;
97+
insertToFlagBuffer(buffer, 'feat1', 1, maxSize);
98+
insertToFlagBuffer(buffer, 'feat2', 'string', maxSize);
99+
100+
expect(buffer).toEqual([]);
101+
});
102+
103+
it('logs error and is a no-op when buffer is larger than maxSize', () => {
104+
const buffer: FeatureFlag[] = [
105+
{ flag: 'feat1', result: true },
106+
{ flag: 'feat2', result: true },
107+
];
108+
109+
insertToFlagBuffer(buffer, 'feat1', true, 1);
110+
expect(loggerSpy).toHaveBeenCalledWith(
111+
expect.stringContaining('[Feature Flags] insertToFlagBuffer called on a buffer larger than maxSize'),
112+
);
113+
expect(buffer).toEqual([
114+
{ flag: 'feat1', result: true },
115+
{ flag: 'feat2', result: true },
116+
]);
117+
118+
insertToFlagBuffer(buffer, 'feat1', true, -2);
119+
expect(loggerSpy).toHaveBeenCalledWith(
120+
expect.stringContaining('[Feature Flags] insertToFlagBuffer called on a buffer larger than maxSize'),
121+
);
122+
expect(buffer).toEqual([
123+
{ flag: 'feat1', result: true },
124+
{ flag: 'feat2', result: true },
125+
]);
126+
});
127+
});
128+
});

0 commit comments

Comments
 (0)