diff --git a/packages/browser/src/integrations/featureFlags/statsig/integration.ts b/packages/browser/src/integrations/featureFlags/statsig/integration.ts index 2dd28cc6f691..201f6100d6cb 100644 --- a/packages/browser/src/integrations/featureFlags/statsig/integration.ts +++ b/packages/browser/src/integrations/featureFlags/statsig/integration.ts @@ -3,9 +3,12 @@ import { _INTERNAL_addFeatureFlagToActiveSpan, _INTERNAL_copyFlagsFromScopeToEvent, _INTERNAL_insertFlagToScope, + _INTERNAL_insertExperimentsToScope, + _INTERNAL_addExperimentToActiveSpan, + _INTERNAL_copyExperimentsFromScopeToEvent, defineIntegration, } from '@sentry/core/browser'; -import type { FeatureGate, StatsigClient } from './types'; +import type { Experiment, FeatureGate, StatsigClient } from './types'; /** * Sentry integration for capturing feature flag evaluations from the Statsig js-client SDK. @@ -31,7 +34,13 @@ import type { FeatureGate, StatsigClient } from './types'; * ``` */ export const statsigIntegration = defineIntegration( - ({ featureFlagClient: statsigClient }: { featureFlagClient: StatsigClient }) => { + ({ + featureFlagClient: statsigClient, + includeExperiments = false, + }: { + featureFlagClient: StatsigClient; + includeExperiments?: boolean; + }) => { return { name: 'Statsig' as const, @@ -40,10 +49,17 @@ export const statsigIntegration = defineIntegration( _INTERNAL_insertFlagToScope(event.gate.name, event.gate.value); _INTERNAL_addFeatureFlagToActiveSpan(event.gate.name, event.gate.value); }); + if (includeExperiments) { + statsigClient.on('experiment_evaluation', (event: { experiment: Experiment }) => { + _INTERNAL_insertExperimentsToScope(event.experiment.name, event.experiment.groupName); + _INTERNAL_addExperimentToActiveSpan(event.experiment.name, event.experiment.groupName); + }); + } }, processEvent(event: Event, _hint: EventHint, _client: Client): Event { - return _INTERNAL_copyFlagsFromScopeToEvent(event); + const withFlags = _INTERNAL_copyFlagsFromScopeToEvent(event); + return includeExperiments ? _INTERNAL_copyExperimentsFromScopeToEvent(withFlags) : withFlags; }, }; }, diff --git a/packages/browser/src/integrations/featureFlags/statsig/types.ts b/packages/browser/src/integrations/featureFlags/statsig/types.ts index ff0ea820d31f..9685801ce6c7 100644 --- a/packages/browser/src/integrations/featureFlags/statsig/types.ts +++ b/packages/browser/src/integrations/featureFlags/statsig/types.ts @@ -3,13 +3,19 @@ export type FeatureGate = { readonly value: boolean; }; -type EventNameToEventDataMap = { +export type Experiment = { + readonly name: string; + // readonly value: Record; + readonly groupName: string; +}; + +export type EventNameToEventDataMap = { + experiment_evaluation: { experiment: Experiment }; gate_evaluation: { gate: FeatureGate }; }; +export type EventName = keyof EventNameToEventDataMap; + export interface StatsigClient { - on( - event: keyof EventNameToEventDataMap, - callback: (data: EventNameToEventDataMap[keyof EventNameToEventDataMap]) => void, - ): void; + on(event: T, callback: (data: EventNameToEventDataMap[T]) => void): void; } diff --git a/packages/browser/test/integrations/featureFlags/statsig/integration.test.ts b/packages/browser/test/integrations/featureFlags/statsig/integration.test.ts new file mode 100644 index 000000000000..a682ecffa438 --- /dev/null +++ b/packages/browser/test/integrations/featureFlags/statsig/integration.test.ts @@ -0,0 +1,68 @@ +import { getCurrentScope } from '@sentry/core/browser'; +import { afterEach, describe, expect, it } from 'vitest'; +import { statsigIntegration } from '../../../../src/integrations/featureFlags/statsig'; +import type { + EventName, + EventNameToEventDataMap, + StatsigClient, +} from '../../../../src/integrations/featureFlags/statsig/types'; + +type GateCallback = (data: EventNameToEventDataMap['gate_evaluation']) => void; +type ExperimentCallback = (data: EventNameToEventDataMap['experiment_evaluation']) => void; + +class MockStatsigClient implements StatsigClient { + gateCallbacks: GateCallback[] = []; + experimentCallbacks: ExperimentCallback[] = []; + + public on(event: T, callback: (data: EventNameToEventDataMap[T]) => void): void { + if (event === 'experiment_evaluation') { + this.experimentCallbacks.push(callback as ExperimentCallback); + } else if (event === 'gate_evaluation') { + this.gateCallbacks.push(callback as GateCallback); + } + } + + public setGate(name: string, value: boolean) { + this.gateCallbacks.forEach(gateCallback => gateCallback({ gate: { name, value } })); + } + + public setExperiment(name: string, groupName: string) { + this.experimentCallbacks.forEach(experimentCallback => experimentCallback({ experiment: { name, groupName } })); + } +} + +describe('statsigIntegration', () => { + afterEach(() => { + getCurrentScope().clear(); + }); + + it('adds to flags', () => { + const statsigClient = new MockStatsigClient(); + const integration = statsigIntegration({ featureFlagClient: statsigClient }); + // @ts-expect-error I was too lazy to figure out how to mock a Client. + integration.setup(); + + statsigClient.setGate('gate_name', true); + statsigClient.setGate('other_gate_name', false); + + expect(getCurrentScope().getScopeData().contexts.flags?.values).toEqual([ + { flag: 'gate_name', result: true }, + { flag: 'other_gate_name', result: false }, + ]); + }); + + it('adds to experiments', () => { + const statsigClient = new MockStatsigClient(); + const integration = statsigIntegration({ featureFlagClient: statsigClient, includeExperiments: true }); + // @ts-expect-error I was too lazy to figure out how to mock a Client. + integration.setup(); + + statsigClient.setExperiment('experiment_name', 'control'); + statsigClient.setExperiment('other_experiment_name', 'treatment'); + + expect(getCurrentScope().getScopeData().contexts.experiments?.values).toEqual([ + { experiment: 'experiment_name', groupName: 'control' }, + { experiment: 'other_experiment_name', groupName: 'treatment' }, + ]); + }); +}); diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index bde4fdcc405d..a9a1748f105d 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -195,6 +195,13 @@ export class Scope { values: [...this._contexts.flags.values], }; } + if (this._contexts.experiments) { + // We need to copy the `values` array so insertions on a cloned scope + // won't affect the original array. + newScope._contexts.experiments = { + values: [...this._contexts.experiments.values], + }; + } newScope._user = this._user; newScope._level = this._level; diff --git a/packages/core/src/shared-exports.ts b/packages/core/src/shared-exports.ts index 339520c5d194..fceaa040defa 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -242,7 +242,7 @@ export type { GoogleGenAIIstrumentedMethod } from './tracing/google-genai/types' export { SpanBuffer } from './tracing/spans/spanBuffer'; export { hasSpanStreamingEnabled } from './tracing/spans/hasSpanStreamingEnabled'; export { spanStreamingIntegration } from './integrations/spanStreaming'; -export type { FeatureFlag } from './utils/featureFlags'; +export type { FeatureFlag } from './types/featureFlag'; export { _INTERNAL_copyFlagsFromScopeToEvent, _INTERNAL_insertFlagToScope, @@ -250,6 +250,13 @@ export { _INTERNAL_FLAG_BUFFER_SIZE, _INTERNAL_MAX_FLAGS_PER_SPAN, } from './utils/featureFlags'; +export { + _INTERNAL_copyExperimentsFromScopeToEvent, + _INTERNAL_insertExperimentsToScope, + _INTERNAL_addExperimentToActiveSpan, + _INTERNAL_EXPERIMENT_BUFFER_SIZE, + _INTERNAL_MAX_EXPERIMENTS_PER_SPAN, +} from './utils/experiments'; export { applyAggregateErrorsToEvent } from './utils/aggregate-errors'; export { getBreadcrumbLogLevelFromHttpStatusCode } from './utils/breadcrumb-log-level'; export { dsnFromString, dsnToString, makeDsn } from './utils/dsn'; diff --git a/packages/core/src/types/context.ts b/packages/core/src/types/context.ts index 9e52ff9d6834..79c77a50b985 100644 --- a/packages/core/src/types/context.ts +++ b/packages/core/src/types/context.ts @@ -1,4 +1,5 @@ -import type { FeatureFlag } from '../utils/featureFlags'; +import type { Experiment } from '../types/experiment'; +import type { FeatureFlag } from '../types/featureFlag'; import type { SpanLinkJSON } from './link'; import type { Primitive } from './misc'; import type { SpanOrigin } from './span'; @@ -16,6 +17,7 @@ export interface Contexts extends Record { state?: StateContext; profile?: ProfileContext; flags?: FeatureFlagContext; + experiments?: ExperimentContext; } export interface StateContext extends Record { @@ -139,3 +141,7 @@ export interface MissingInstrumentationContext extends Record { export interface FeatureFlagContext extends Record { values: FeatureFlag[]; } + +export interface ExperimentContext extends Record { + values: Experiment[]; +} diff --git a/packages/core/src/types/experiment.ts b/packages/core/src/types/experiment.ts new file mode 100644 index 000000000000..398f3f7889f0 --- /dev/null +++ b/packages/core/src/types/experiment.ts @@ -0,0 +1 @@ +export type Experiment = { readonly experiment: string; readonly groupName: string }; diff --git a/packages/core/src/types/featureFlag.ts b/packages/core/src/types/featureFlag.ts new file mode 100644 index 000000000000..f80e17ef7f9d --- /dev/null +++ b/packages/core/src/types/featureFlag.ts @@ -0,0 +1 @@ +export type FeatureFlag = { readonly flag: string; readonly result: boolean }; diff --git a/packages/core/src/utils/experiments.ts b/packages/core/src/utils/experiments.ts new file mode 100644 index 000000000000..cf9f4bc18046 --- /dev/null +++ b/packages/core/src/utils/experiments.ts @@ -0,0 +1,146 @@ +import { getCurrentScope } from '../currentScopes'; +import { DEBUG_BUILD } from '../debug-build'; +import type { Event } from '../types/event'; +import type { Experiment } from '../types/experiment'; +import { debug } from './debug-logger'; +import { getActiveSpan, spanToJSON } from './spanUtils'; + +export const _INTERNAL_EXPERIMENT_BUFFER_SIZE = 100; + +export const _INTERNAL_MAX_EXPERIMENTS_PER_SPAN = 10; + +const SPAN_EXPERIMENT_ATTRIBUTE_PREFIX = 'experiment.evaluation.'; + +export function _INTERNAL_copyExperimentsFromScopeToEvent(event: Event): Event { + if (event.type) { + // No need to add the experiment context to transaction events. + // Spans already get the experiment.evaluation attributes. + return event; + } + + const scope = getCurrentScope(); + const experimentContext = scope.getScopeData().contexts.experiments; + const experimentBuffer = experimentContext ? experimentContext.values : []; + + if (!experimentBuffer.length) { + return event; + } + + if (event.contexts === undefined) { + event.contexts = {}; + } + event.contexts.experiments = { values: [...experimentBuffer] }; + return event; +} + +/** + * Inserts an experiment into the current scope's context while maintaining ordered LRU properties. + * Not thread-safe. After inserting: + * - The experiment buffer is sorted in order of recency, with the newest evaluation at the end. + * - The names in the buffer are always unique. + * - The length of the buffer never exceeds `maxSize`. + * + * @param name Name of the experiment to insert. + * @param gropuName Group name of the experiment. + * @param maxSize Max number of experiments the buffer should store. Default value should always be used in production. + */ +export function _INTERNAL_insertExperimentsToScope( + name: string, + groupName: unknown, + maxSize: number = _INTERNAL_EXPERIMENT_BUFFER_SIZE, +): void { + const scopeContexts = getCurrentScope().getScopeData().contexts; + if (!scopeContexts.experiments) { + scopeContexts.experiments = { values: [] }; + } + const experiments = scopeContexts.experiments.values; + _INTERNAL_insertToExperimentBuffer(experiments, name, groupName, maxSize); +} + +/** + * Exported for tests only. Currently only accepts string values (otherwise no-op). + * Inserts an experiment into an Experiment array while maintaining the following properties: + * - Experiments are sorted in order of recency, with the newest evaluation at the end. + * - The experiment names are always unique. + * - The length of the array never exceeds `maxSize`. + * + * @param experiments The buffer to insert the experiment into. + * @param name Name of the experiment to insert. + * @param groupName Group name chosen for this experiment. + * @param maxSize Max number of experiments the buffer should store. Default value should always be used in production. + */ +export function _INTERNAL_insertToExperimentBuffer( + experiments: Experiment[], + name: string, + groupName: unknown, + maxSize: number, +): void { + if (typeof groupName !== 'string') { + return; + } + + if (experiments.length > maxSize) { + DEBUG_BUILD && + debug.error(`[Experiments] insertToExperimentBuffer called on a buffer larger than maxSize=${maxSize}`); + return; + } + + // Check if the experiment is already in the buffer - O(n) + const index = experiments.findIndex(f => f.experiment === name); + + if (index !== -1) { + // The experiment was found, remove it from its current position - O(n) + experiments.splice(index, 1); + } + + if (experiments.length === maxSize) { + // If at capacity, pop the earliest experiment - O(n) + experiments.shift(); + } + + // Push the experiment to the end - O(1) + experiments.push({ + experiment: name, + groupName, + }); +} + +/** + * Records an experiment evaluation for the active span. This is a no-op for non-string values. + * The experiment and its value is stored in span attributes with the `experiment.evaluation` prefix. Once the + * unique experiments for a span reaches maxExperimentsPerSpan, subsequent experiments are dropped. + * + * @param name Name of the experiment. + * @param groupName Group name of the experiment. Non-string values are ignored. + * @param maxExperimentsPerSpan Max number of experiments a buffer should store. Default value should always be used in production. + */ +export function _INTERNAL_addExperimentToActiveSpan( + name: string, + groupName: unknown, + maxExperimentsPerSpan: number = _INTERNAL_MAX_EXPERIMENTS_PER_SPAN, +): void { + if (typeof groupName !== 'string') { + return; + } + + const span = getActiveSpan(); + if (!span) { + return; + } + + const attributes = spanToJSON(span).data; + + // If the experiment already exists, always update it + if (`${SPAN_EXPERIMENT_ATTRIBUTE_PREFIX}${name}` in attributes) { + span.setAttribute(`${SPAN_EXPERIMENT_ATTRIBUTE_PREFIX}${name}`, groupName); + return; + } + + // Else, add the experiment to the span if we have not reached the max number of experiments. + const numOfAddedExperiments = Object.keys(attributes).filter(key => + key.startsWith(SPAN_EXPERIMENT_ATTRIBUTE_PREFIX), + ).length; + if (numOfAddedExperiments < maxExperimentsPerSpan) { + span.setAttribute(`${SPAN_EXPERIMENT_ATTRIBUTE_PREFIX}${name}`, groupName); + } +} diff --git a/packages/core/src/utils/featureFlags.ts b/packages/core/src/utils/featureFlags.ts index caafe439473c..786d451e51da 100644 --- a/packages/core/src/utils/featureFlags.ts +++ b/packages/core/src/utils/featureFlags.ts @@ -1,6 +1,7 @@ import { getCurrentScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; -import { type Event } from '../types/event'; +import type { Event } from '../types/event'; +import type { FeatureFlag } from '../types/featureFlag'; import { debug } from './debug-logger'; import { getActiveSpan, spanToJSON } from './spanUtils'; @@ -10,8 +11,6 @@ import { getActiveSpan, spanToJSON } from './spanUtils'; * from oldest to newest. */ -export type FeatureFlag = { readonly flag: string; readonly result: boolean }; - /** * Max size of the LRU flag buffer stored in Sentry scope and event contexts. */ diff --git a/packages/core/test/lib/utils/featureFlags.test.ts b/packages/core/test/lib/utils/featureFlags.test.ts index e591a00d1304..6fcc7be5b4f6 100644 --- a/packages/core/test/lib/utils/featureFlags.test.ts +++ b/packages/core/test/lib/utils/featureFlags.test.ts @@ -1,11 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { getCurrentScope } from '../../../src/currentScopes'; +import { type FeatureFlag } from '../../../src/types/featureFlag'; import { debug } from '../../../src/utils/debug-logger'; import { _INTERNAL_copyFlagsFromScopeToEvent, _INTERNAL_insertFlagToScope, _INTERNAL_insertToFlagBuffer, - type FeatureFlag, } from '../../../src/utils/featureFlags'; import * as currentScopeModule from '../../../src/currentScopes';