Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,

Expand All @@ -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;
},
};
},
Expand Down
16 changes: 11 additions & 5 deletions packages/browser/src/integrations/featureFlags/statsig/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@ export type FeatureGate = {
readonly value: boolean;
};

type EventNameToEventDataMap = {
export type Experiment = {
readonly name: string;
// readonly value: Record<string, unknown>;
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<T extends EventName>(event: T, callback: (data: EventNameToEventDataMap[T]) => void): void;
}
Original file line number Diff line number Diff line change
@@ -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<T extends EventName>(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' },
]);
});
});
7 changes: 7 additions & 0 deletions packages/core/src/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/shared-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,14 +242,21 @@ 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,
_INTERNAL_addFeatureFlagToActiveSpan,
_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';
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/types/context.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,6 +17,7 @@ export interface Contexts extends Record<string, Context | undefined> {
state?: StateContext;
profile?: ProfileContext;
flags?: FeatureFlagContext;
experiments?: ExperimentContext;
}

export interface StateContext extends Record<string, unknown> {
Expand Down Expand Up @@ -139,3 +141,7 @@ export interface MissingInstrumentationContext extends Record<string, unknown> {
export interface FeatureFlagContext extends Record<string, unknown> {
values: FeatureFlag[];
}

export interface ExperimentContext extends Record<string, unknown> {
values: Experiment[];
}
1 change: 1 addition & 0 deletions packages/core/src/types/experiment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type Experiment = { readonly experiment: string; readonly groupName: string };
1 change: 1 addition & 0 deletions packages/core/src/types/featureFlag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type FeatureFlag = { readonly flag: string; readonly result: boolean };
146 changes: 146 additions & 0 deletions packages/core/src/utils/experiments.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
5 changes: 2 additions & 3 deletions packages/core/src/utils/featureFlags.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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.
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/core/test/lib/utils/featureFlags.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down