diff --git a/package.json b/package.json
index ce3f91932c64..b776421b71b1 100644
--- a/package.json
+++ b/package.json
@@ -63,6 +63,7 @@
"packages/gatsby",
"packages/google-cloud-serverless",
"packages/integration-shims",
+ "packages/launchdarkly",
"packages/nestjs",
"packages/nextjs",
"packages/node",
diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts
index ff89c0d593a9..65e5821a3d1f 100644
--- a/packages/core/src/scope.ts
+++ b/packages/core/src/scope.ts
@@ -21,7 +21,13 @@ import type {
SeverityLevel,
User,
} from '@sentry/types';
-import { dateTimestampInSeconds, generatePropagationContext, isPlainObject, logger, uuid4 } from '@sentry/utils';
+import {
+ dateTimestampInSeconds,
+ generatePropagationContext,
+ isPlainObject,
+ logger,
+ uuid4,
+} from '@sentry/utils';
import { updateSession } from './session';
import { _getSpanForScope, _setSpanForScope } from './utils/spanOnScope';
@@ -122,6 +128,13 @@ class ScopeClass implements ScopeInterface {
newScope._tags = { ...this._tags };
newScope._extra = { ...this._extra };
newScope._contexts = { ...this._contexts };
+ if (this._contexts.flags) {
+ // The flags context needs a deep copy.
+ newScope._contexts.flags = {
+ values: [...this._contexts.flags.values]
+ }
+ }
+
newScope._user = this._user;
newScope._level = this._level;
newScope._session = this._session;
diff --git a/packages/launchdarkly/.eslintignore b/packages/launchdarkly/.eslintignore
new file mode 100644
index 000000000000..b38db2f296ff
--- /dev/null
+++ b/packages/launchdarkly/.eslintignore
@@ -0,0 +1,2 @@
+node_modules/
+build/
diff --git a/packages/launchdarkly/.eslintrc.js b/packages/launchdarkly/.eslintrc.js
new file mode 100644
index 000000000000..dc39a10b354b
--- /dev/null
+++ b/packages/launchdarkly/.eslintrc.js
@@ -0,0 +1,39 @@
+// Note: All paths are relative to the directory in which eslint is being run, rather than the directory where this file
+// lives
+
+// ESLint config docs: https://eslint.org/docs/user-guide/configuring/
+
+module.exports = {
+ extends: ['../../.eslintrc.js'],
+ overrides: [
+ {
+ files: ['src/**/*.ts'],
+ },
+ {
+ files: ['test.setup.ts', 'vitest.config.ts'],
+ parserOptions: {
+ project: ['tsconfig.test.json'],
+ },
+ rules: {
+ 'no-console': 'off',
+ },
+ },
+ {
+ files: ['test/**/*.ts'],
+
+ rules: {
+ // most of these errors come from `new Promise(process.nextTick)`
+ '@typescript-eslint/unbound-method': 'off',
+ // TODO: decide if we want to enable this again after the migration
+ // We can take the freedom to be a bit more lenient with tests
+ '@typescript-eslint/no-floating-promises': 'off',
+ },
+ },
+ {
+ files: ['src/types/deprecated.ts'],
+ rules: {
+ '@typescript-eslint/naming-convention': 'off',
+ },
+ },
+ ],
+};
diff --git a/packages/launchdarkly/.gitignore b/packages/launchdarkly/.gitignore
new file mode 100644
index 000000000000..363d3467c6fa
--- /dev/null
+++ b/packages/launchdarkly/.gitignore
@@ -0,0 +1,4 @@
+node_modules
+/*.tgz
+.eslintcache
+build
diff --git a/packages/launchdarkly/LICENSE b/packages/launchdarkly/LICENSE
new file mode 100644
index 000000000000..6bfafc44539c
--- /dev/null
+++ b/packages/launchdarkly/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023-2024 Functional Software, Inc. dba Sentry
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/launchdarkly/README.md b/packages/launchdarkly/README.md
new file mode 100644
index 000000000000..d35afcc2fce5
--- /dev/null
+++ b/packages/launchdarkly/README.md
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+# Sentry Integration for LaunchDarkly
+
+This SDK is **considered experimental and in a beta state**. It may experience breaking changes, and may be discontinued
+at any time. Please reach out on [GitHub](https://github.com/getsentry/sentry-javascript/issues/new/choose) if you have
+any feedback/concerns.
+
+## Installation
+
+## Configuration
diff --git a/packages/launchdarkly/package.json b/packages/launchdarkly/package.json
new file mode 100644
index 000000000000..7d4075bca4e7
--- /dev/null
+++ b/packages/launchdarkly/package.json
@@ -0,0 +1,74 @@
+{
+ "name": "@sentry/launchdarkly",
+ "version": "8.35.0",
+ "description": "Sentry SDK integration for Launch Darkly feature flagging",
+ "repository": "git://github.com/getsentry/sentry-javascript.git",
+ "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/launchdarkly",
+ "author": "Sentry",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.18"
+ },
+ "files": [
+ "/build/npm"
+ ],
+ "main": "build/npm/cjs/index.js",
+ "module": "build/npm/esm/index.js",
+ "types": "build/npm/types/index.d.ts",
+ "exports": {
+ "./package.json": "./package.json",
+ ".": {
+ "import": {
+ "types": "./build/npm/types/index.d.ts",
+ "default": "./build/npm/esm/index.js"
+ },
+ "require": {
+ "types": "./build/npm/types/index.d.ts",
+ "default": "./build/npm/cjs/index.js"
+ }
+ }
+ },
+ "typesVersions": {
+ "<4.9": {
+ "build/npm/types/index.d.ts": [
+ "build/npm/types-ts3.8/index.d.ts"
+ ]
+ }
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "dependencies": {
+ "@sentry/browser": "^8.35.0",
+ "@sentry/core": "8.35.0",
+ "@sentry/types": "8.35.0",
+ "@sentry/utils": "8.35.0",
+ "launchdarkly-js-client-sdk": "^3.5.0"
+ },
+ "scripts": {
+ "build": "run-p build:transpile build:types build:bundle",
+ "build:transpile": "rollup -c rollup.npm.config.mjs",
+ "build:bundle": "rollup -c rollup.bundle.config.mjs",
+ "build:dev": "run-p build:transpile build:types",
+ "build:types": "run-s build:types:core build:types:downlevel",
+ "build:types:core": "tsc -p tsconfig.types.json",
+ "build:types:downlevel": "yarn downlevel-dts build/npm/types build/npm/types-ts3.8 --to ts3.8 && yarn node ./scripts/shim-preact-export.js",
+ "build:watch": "run-p build:transpile:watch build:bundle:watch build:types:watch",
+ "build:dev:watch": "run-p build:transpile:watch build:types:watch",
+ "build:transpile:watch": "yarn build:transpile --watch",
+ "build:bundle:watch": "yarn build:bundle --watch",
+ "build:types:watch": "tsc -p tsconfig.types.json --watch",
+ "build:tarball": "npm pack",
+ "circularDepCheck": "madge --circular src/index.ts",
+ "clean": "rimraf build sentry-internal-launchdarkly-*.tgz",
+ "fix": "eslint . --format stylish --fix",
+ "lint": "eslint . --format stylish",
+ "test": "jest",
+ "test:watch": "jest --watch",
+ "yalc:publish": "yalc publish --push --sig"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ },
+ "sideEffects": false
+}
diff --git a/packages/launchdarkly/rollup.bundle.config.mjs b/packages/launchdarkly/rollup.bundle.config.mjs
new file mode 100644
index 000000000000..f79e2cd63d7e
--- /dev/null
+++ b/packages/launchdarkly/rollup.bundle.config.mjs
@@ -0,0 +1,12 @@
+import { makeBaseBundleConfig, makeBundleConfigVariants } from '@sentry-internal/rollup-utils';
+
+const baseBundleConfig = makeBaseBundleConfig({
+ bundleType: 'addon',
+ entrypoints: ['src/index.ts'],
+ licenseTitle: '@sentry/launchdarkly',
+ outputFileBase: () => 'bundles/launchdarkly',
+});
+
+const builds = makeBundleConfigVariants(baseBundleConfig);
+
+export default builds;
diff --git a/packages/launchdarkly/rollup.npm.config.mjs b/packages/launchdarkly/rollup.npm.config.mjs
new file mode 100644
index 000000000000..3b4431fa6829
--- /dev/null
+++ b/packages/launchdarkly/rollup.npm.config.mjs
@@ -0,0 +1,19 @@
+import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils';
+
+export default makeNPMConfigVariants(
+ makeBaseNPMConfig({
+ hasBundles: true,
+ packageSpecificConfig: {
+ output: {
+ // set exports to 'named' or 'auto' so that rollup doesn't warn
+ exports: 'named',
+ // set preserveModules to false because for Replay we actually want
+ // to bundle everything into one file.
+ preserveModules:
+ process.env.SENTRY_BUILD_PRESERVE_MODULES === undefined
+ ? false
+ : Boolean(process.env.SENTRY_BUILD_PRESERVE_MODULES),
+ },
+ },
+ }),
+);
diff --git a/packages/launchdarkly/scripts/shim-preact-export.js b/packages/launchdarkly/scripts/shim-preact-export.js
new file mode 100644
index 000000000000..bd74e4da0a05
--- /dev/null
+++ b/packages/launchdarkly/scripts/shim-preact-export.js
@@ -0,0 +1,75 @@
+// preact does not support more modern TypeScript versions, which breaks our users that depend on older
+// TypeScript versions. To fix this, we shim the types from preact to be any and remove the dependency on preact
+// for types directly. This script is meant to be run after the build/npm/types-ts3.8 directory is created.
+
+// Path: build/npm/types-ts3.8/global.d.ts
+
+const fs = require('fs');
+const path = require('path');
+
+/**
+ * This regex looks for preact imports we can replace and shim out.
+ *
+ * Example:
+ * import { ComponentChildren, VNode } from 'preact';
+ */
+const preactImportRegex = /import\s*{\s*([\w\s,]+)\s*}\s*from\s*'preact'\s*;?/;
+
+function walk(dir) {
+ const files = fs.readdirSync(dir);
+ files.forEach(file => {
+ const filePath = path.join(dir, file);
+ const stat = fs.lstatSync(filePath);
+ if (stat.isDirectory()) {
+ walk(filePath);
+ } else {
+ if (filePath.endsWith('.d.ts')) {
+ const content = fs.readFileSync(filePath, 'utf8');
+ const capture = preactImportRegex.exec(content);
+ if (capture) {
+ const groups = capture[1].split(',').map(s => s.trim());
+
+ // This generates a shim snippet to replace the type imports from preact
+ // It generates a snippet based on the capture groups of preactImportRegex.
+ //
+ // Example:
+ //
+ // import type { ComponentChildren, VNode } from 'preact';
+ // becomes
+ // type ComponentChildren: any;
+ // type VNode: any;
+ const snippet = groups.reduce((acc, curr) => {
+ const searchableValue = curr.includes(' as ') ? curr.split(' as ')[1] : curr;
+
+ // look to see if imported as value, then we have to use declare const
+ if (content.includes(`typeof ${searchableValue}`)) {
+ return `${acc}declare const ${searchableValue}: any;\n`;
+ }
+
+ // look to see if generic type like Foo
+ if (content.includes(`${searchableValue}<`)) {
+ return `${acc}type ${searchableValue} = any;\n`;
+ }
+
+ // otherwise we can just leave as type
+ return `${acc}type ${searchableValue} = any;\n`;
+ }, '');
+
+ // we then can remove the import from preact
+ const newContent = content.replace(preactImportRegex, '// replaced import from preact');
+
+ // and write the new content to the file
+ fs.writeFileSync(filePath, snippet + newContent, 'utf8');
+ }
+ }
+ }
+ });
+}
+
+function run() {
+ // recurse through build/npm/types-ts3.8 directory
+ const dir = path.join('build', 'npm', 'types-ts3.8');
+ walk(dir);
+}
+
+run();
diff --git a/packages/launchdarkly/src/core/integration.ts b/packages/launchdarkly/src/core/integration.ts
new file mode 100644
index 000000000000..e9702251b048
--- /dev/null
+++ b/packages/launchdarkly/src/core/integration.ts
@@ -0,0 +1,69 @@
+/* eslint-disable @sentry-internal/sdk/no-class-field-initializers */
+
+import * as Sentry from '@sentry/browser';
+import type { Client as SentryClient, Event, EventHint, IntegrationFn } from '@sentry/types';
+import type { LDContext, LDEvaluationDetail, LDInspectionFlagUsedHandler } from 'launchdarkly-js-client-sdk';
+import type { LaunchDarklyOptions } from '../types';
+import { insertToFlagBuffer } from '@sentry/utils';
+
+/**
+ * Sentry integration for capturing feature flags from LaunchDarkly.
+ *
+ * See the [feature flag documentation](TODO:) for more information.
+ *
+ * @example
+ * ```
+ * import {SentryInspector, launchDarklyIntegration} from '@sentry/launchdarkly';
+ * import {LDClient} from 'launchdarkly-js-client-sdk';
+ *
+ * Sentry.init(..., integrations: [launchDarklyIntegration()])
+ * const ldClient = LDClient.initialize(..., inspectors: [SentryInspector]);
+ * ```
+ */
+export const launchDarklyIntegration = ((_options?: LaunchDarklyOptions) => {
+ return {
+ name: 'launchdarkly',
+
+ processEvent(event: Event, _hint: EventHint, _client: SentryClient): Event {
+ const scope = Sentry.getCurrentScope();
+ const flagContext = scope.getScopeData().contexts.flags;
+
+ if (event.contexts === undefined) {
+ event.contexts = {};
+ }
+ event.contexts.flags = flagContext;
+ return event;
+ },
+ };
+}) satisfies IntegrationFn;
+
+/**
+ * LaunchDarkly hook that listens for flag evaluations and updates the
+ * flagBuffer in our current scope.
+ *
+ * This needs to be registered separately in the LDClient, after initializing
+ * Sentry.
+ */
+export class SentryInspector implements LDInspectionFlagUsedHandler {
+ public name = 'sentry-flag-auditor';
+
+ public type = 'flag-used' as const;
+
+ // We don't want the handler to impact the performance of the user's flag evaluations.
+ public synchronous = false;
+
+ /**
+ * Handle a flag evaluation by storing its name and value on the current scope.
+ */
+ public method(flagKey: string, flagDetail: LDEvaluationDetail, _context: LDContext): void {
+ if (typeof flagDetail.value === 'boolean') {
+ const scopeContexts = Sentry.getCurrentScope().getScopeData().contexts;
+ if (!scopeContexts.flags) {
+ scopeContexts.flags = {values: []}
+ }
+ const flagBuffer = scopeContexts.flags.values;
+ insertToFlagBuffer(flagBuffer, flagKey, flagDetail.value);
+ }
+ return;
+ }
+}
diff --git a/packages/launchdarkly/src/index.ts b/packages/launchdarkly/src/index.ts
new file mode 100644
index 000000000000..8952a4bc1264
--- /dev/null
+++ b/packages/launchdarkly/src/index.ts
@@ -0,0 +1,6 @@
+// This file is used as entry point to generate the npm package and CDN bundles.
+
+export { launchDarklyIntegration } from './core/integration';
+
+// export type {
+// } from './types';
diff --git a/packages/launchdarkly/src/types.ts b/packages/launchdarkly/src/types.ts
new file mode 100644
index 000000000000..6516bc7a8ff7
--- /dev/null
+++ b/packages/launchdarkly/src/types.ts
@@ -0,0 +1 @@
+export type LaunchDarklyOptions = Record; //TODO:
diff --git a/packages/launchdarkly/test.setup.ts b/packages/launchdarkly/test.setup.ts
new file mode 100644
index 000000000000..05a762e60d50
--- /dev/null
+++ b/packages/launchdarkly/test.setup.ts
@@ -0,0 +1,255 @@
+import { printDiffOrStringify } from 'jest-matcher-utils';
+import { vi } from 'vitest';
+import type { Mocked, MockedFunction } from 'vitest';
+
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
+import { getClient } from '@sentry/core';
+import type { ReplayRecordingData, Transport } from '@sentry/types';
+import * as SentryUtils from '@sentry/utils';
+
+import type { ReplayContainer, Session } from './src/types';
+
+type MockTransport = MockedFunction;
+
+vi.spyOn(SentryUtils, 'isBrowser').mockImplementation(() => true);
+
+type EnvelopeHeader = {
+ event_id: string;
+ sent_at: string;
+ sdk: {
+ name: string;
+ version?: string;
+ };
+};
+
+type ReplayEventHeader = { type: 'replay_event' };
+type ReplayEventPayload = Record;
+type RecordingHeader = { type: 'replay_recording'; length: number };
+type RecordingPayloadHeader = Record;
+type SentReplayExpected = {
+ envelopeHeader?: EnvelopeHeader;
+ replayEventHeader?: ReplayEventHeader;
+ replayEventPayload?: ReplayEventPayload;
+ recordingHeader?: RecordingHeader;
+ recordingPayloadHeader?: RecordingPayloadHeader;
+ recordingData?: ReplayRecordingData;
+};
+
+// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
+const toHaveSameSession = function (received: Mocked, expected: undefined | Session) {
+ const pass = this.equals(received.session?.id, expected?.id) as boolean;
+
+ const options = {
+ isNot: this.isNot,
+ promise: this.promise,
+ };
+
+ return {
+ pass,
+ message: () =>
+ `${this.utils.matcherHint('toHaveSameSession', undefined, undefined, options)}\n\n${printDiffOrStringify(
+ expected,
+ received.session,
+ 'Expected',
+ 'Received',
+ )}`,
+ };
+};
+
+type Result = {
+ passed: boolean;
+ key: string;
+ expectedVal: SentReplayExpected[keyof SentReplayExpected];
+ actualVal: SentReplayExpected[keyof SentReplayExpected];
+};
+type Call = [
+ EnvelopeHeader,
+ [
+ [ReplayEventHeader | undefined, ReplayEventPayload | undefined],
+ [RecordingHeader | undefined, RecordingPayloadHeader | undefined],
+ ],
+];
+type CheckCallForSentReplayResult = { pass: boolean; call: Call | undefined; results: Result[] };
+
+function checkCallForSentReplay(
+ call: Call | undefined,
+ expected?: SentReplayExpected | { sample: SentReplayExpected; inverse: boolean },
+): CheckCallForSentReplayResult {
+ const envelopeHeader = call?.[0];
+ const envelopeItems = call?.[1] || [[], []];
+ const [[replayEventHeader, replayEventPayload], [recordingHeader, recordingPayload] = []] = envelopeItems;
+
+ // @ts-expect-error recordingPayload is always a string in our tests
+ const [recordingPayloadHeader, recordingData] = recordingPayload?.split('\n') || [];
+
+ const actualObj: Required = {
+ // @ts-expect-error Custom envelope
+ envelopeHeader: envelopeHeader,
+ // @ts-expect-error Custom envelope
+ replayEventHeader: replayEventHeader,
+ // @ts-expect-error Custom envelope
+ replayEventPayload: replayEventPayload,
+ // @ts-expect-error Custom envelope
+ recordingHeader: recordingHeader,
+ recordingPayloadHeader: recordingPayloadHeader && JSON.parse(recordingPayloadHeader),
+ recordingData,
+ };
+
+ const isObjectContaining = expected && 'sample' in expected && 'inverse' in expected;
+ const expectedObj = isObjectContaining
+ ? (expected as { sample: SentReplayExpected }).sample
+ : (expected as SentReplayExpected);
+
+ if (isObjectContaining) {
+ // eslint-disable-next-line no-console
+ console.warn('`expect.objectContaining` is unnecessary when using the `toHaveSentReplay` matcher');
+ }
+
+ const results = expected
+ ? Object.keys(expectedObj)
+ .map(key => {
+ const actualVal = actualObj[key as keyof SentReplayExpected];
+ const expectedVal = expectedObj[key as keyof SentReplayExpected];
+ const passed = !expectedVal || this.equals(actualVal, expectedVal);
+
+ return { passed, key, expectedVal, actualVal };
+ })
+ .filter(({ passed }) => !passed)
+ : [];
+
+ const pass = Boolean(call && (!expected || results.length === 0));
+
+ return {
+ pass,
+ call,
+ results,
+ };
+}
+
+/**
+ * Only want calls that send replay events, i.e. ignore error events
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function getReplayCalls(calls: any[][][]): any[][][] {
+ return calls
+ .map(call => {
+ const arg = call[0];
+ if (arg.length !== 2) {
+ return [];
+ }
+
+ if (!arg[1][0].find(({ type }: { type: string }) => ['replay_event', 'replay_recording'].includes(type))) {
+ return [];
+ }
+
+ return [arg];
+ })
+ .filter(Boolean);
+}
+
+/**
+ * Checks all calls to `fetch` and ensures a replay was uploaded by
+ * checking the `fetch()` request's body.
+ */
+// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
+const toHaveSentReplay = function (
+ _received: Mocked,
+ expected?: SentReplayExpected | { sample: SentReplayExpected; inverse: boolean },
+) {
+ const { calls } = (getClient()?.getTransport()?.send as MockTransport).mock;
+
+ let result: CheckCallForSentReplayResult;
+
+ const expectedKeysLength = expected
+ ? ('sample' in expected ? Object.keys(expected.sample) : Object.keys(expected)).length
+ : 0;
+
+ const replayCalls = getReplayCalls(calls);
+
+ for (const currentCall of replayCalls) {
+ result = checkCallForSentReplay.call(this, currentCall[0], expected);
+ if (result.pass) {
+ break;
+ }
+
+ // stop on the first call where any of the expected obj passes
+ if (result.results.length < expectedKeysLength) {
+ break;
+ }
+ }
+
+ // @ts-expect-error use before assigned
+ const { results, call, pass } = result;
+
+ const options = {
+ isNot: this.isNot,
+ promise: this.promise,
+ };
+
+ return {
+ pass,
+ message: () =>
+ !call
+ ? pass
+ ? 'Expected Replay to not have been sent, but a request was attempted'
+ : 'Expected Replay to have been sent, but a request was not attempted'
+ : `${this.utils.matcherHint('toHaveSentReplay', undefined, undefined, options)}\n\n${results
+ .map(({ key, expectedVal, actualVal }: Result) =>
+ printDiffOrStringify(expectedVal, actualVal, `Expected (key: ${key})`, `Received (key: ${key})`),
+ )
+ .join('\n')}`,
+ };
+};
+
+/**
+ * Checks the last call to `fetch` and ensures a replay was uploaded by
+ * checking the `fetch()` request's body.
+ */
+// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
+const toHaveLastSentReplay = function (
+ _received: Mocked,
+ expected?: SentReplayExpected | { sample: SentReplayExpected; inverse: boolean },
+) {
+ const { calls } = (getClient()?.getTransport()?.send as MockTransport).mock;
+ const replayCalls = getReplayCalls(calls);
+
+ const lastCall = replayCalls[calls.length - 1]?.[0];
+
+ const { results, call, pass } = checkCallForSentReplay.call(this, lastCall, expected);
+
+ const options = {
+ isNot: this.isNot,
+ promise: this.promise,
+ };
+
+ return {
+ pass,
+ message: () =>
+ !call
+ ? pass
+ ? 'Expected Replay to not have been sent, but a request was attempted'
+ : 'Expected Replay to have last been sent, but a request was not attempted'
+ : `${this.utils.matcherHint('toHaveSentReplay', undefined, undefined, options)}\n\n${results
+ .map(({ key, expectedVal, actualVal }: Result) =>
+ printDiffOrStringify(expectedVal, actualVal, `Expected (key: ${key})`, `Received (key: ${key})`),
+ )
+ .join('\n')}`,
+ };
+};
+
+expect.extend({
+ toHaveSameSession,
+ toHaveSentReplay,
+ toHaveLastSentReplay,
+});
+
+interface CustomMatchers {
+ toHaveSentReplay(expected?: SentReplayExpected): R;
+ toHaveLastSentReplay(expected?: SentReplayExpected): R;
+ toHaveSameSession(expected: undefined | Session): R;
+}
+
+declare module 'vitest' {
+ type Assertion = CustomMatchers;
+ type AsymmetricMatchersContaining = CustomMatchers;
+}
diff --git a/packages/launchdarkly/tsconfig.json b/packages/launchdarkly/tsconfig.json
new file mode 100644
index 000000000000..cd1b8207ea06
--- /dev/null
+++ b/packages/launchdarkly/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "lib": ["DOM", "ES2018"],
+ "module": "esnext"
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/packages/launchdarkly/tsconfig.test.json b/packages/launchdarkly/tsconfig.test.json
new file mode 100644
index 000000000000..bb7130d948c0
--- /dev/null
+++ b/packages/launchdarkly/tsconfig.test.json
@@ -0,0 +1,15 @@
+{
+ "extends": "./tsconfig.json",
+
+ "include": ["test/**/*.ts", "vitest.config.ts", "test.setup.ts"],
+
+ "compilerOptions": {
+ "types": ["node"],
+ "esModuleInterop": true,
+ "allowJs": true,
+ "noImplicitAny": true,
+ "noImplicitThis": false,
+ "strictNullChecks": true,
+ "strictPropertyInitialization": false
+ }
+}
diff --git a/packages/launchdarkly/tsconfig.types.json b/packages/launchdarkly/tsconfig.types.json
new file mode 100644
index 000000000000..374fd9bc9364
--- /dev/null
+++ b/packages/launchdarkly/tsconfig.types.json
@@ -0,0 +1,10 @@
+{
+ "extends": "./tsconfig.json",
+
+ "compilerOptions": {
+ "declaration": true,
+ "declarationMap": true,
+ "emitDeclarationOnly": true,
+ "outDir": "build/npm/types"
+ }
+}
diff --git a/packages/launchdarkly/vitest.config.ts b/packages/launchdarkly/vitest.config.ts
new file mode 100644
index 000000000000..976d9c37074d
--- /dev/null
+++ b/packages/launchdarkly/vitest.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from 'vitest/config';
+
+import baseConfig from '../../vite/vite.config';
+
+export default defineConfig({
+ ...baseConfig,
+ test: {
+ ...baseConfig.test,
+ setupFiles: ['./test.setup.ts'],
+ reporters: ['default'],
+ },
+});
diff --git a/packages/types/src/context.ts b/packages/types/src/context.ts
index 10fc61420e25..3e757928923a 100644
--- a/packages/types/src/context.ts
+++ b/packages/types/src/context.ts
@@ -1,5 +1,6 @@
import type { Primitive } from './misc';
import type { SpanOrigin } from './span';
+import type { FeatureFlag } from './flags'
export type Context = Record;
@@ -13,6 +14,7 @@ export interface Contexts extends Record {
cloud_resource?: CloudResourceContext;
state?: StateContext;
profile?: ProfileContext;
+ flags?: FeatureFlagContext;
}
export interface StateContext extends Record {
@@ -124,3 +126,8 @@ export interface MissingInstrumentationContext extends Record {
package: string;
['javascript.is_cjs']?: boolean;
}
+
+export interface FeatureFlagContext extends Record {
+ // This should only be modified by @sentry/util methods (insertToFlagBuffer).
+ readonly values: FeatureFlag[];
+}
diff --git a/packages/types/src/flags.ts b/packages/types/src/flags.ts
new file mode 100644
index 000000000000..c117fbd0d686
--- /dev/null
+++ b/packages/types/src/flags.ts
@@ -0,0 +1,2 @@
+// Key names match the type used by Sentry frontend.
+export type FeatureFlag = { readonly flag: string; readonly result: boolean };
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index b100c1e9c26a..bf187a15edb5 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -56,6 +56,7 @@ export type { Event, EventHint, EventType, ErrorEvent, TransactionEvent } from '
export type { EventProcessor } from './eventprocessor';
export type { Exception } from './exception';
export type { Extra, Extras } from './extra';
+export type { FeatureFlag } from './flags';
// eslint-disable-next-line deprecation/deprecation
export type { Hub } from './hub';
export type { Integration, IntegrationClass, IntegrationFn } from './integration';
diff --git a/packages/utils/src/flags.ts b/packages/utils/src/flags.ts
new file mode 100644
index 000000000000..2cc02b989577
--- /dev/null
+++ b/packages/utils/src/flags.ts
@@ -0,0 +1,38 @@
+import type { FeatureFlag } from '@sentry/types';
+
+/**
+ * Ordered LRU cache for storing feature flags in the scope context. The name
+ * of each flag in the buffer is unique, and the output of getAll() is ordered
+ * from oldest to newest.
+ */
+
+export const FLAG_BUFFER_SIZE = 100;
+
+/**
+ * Insert into a FeatureFlag array while maintaining ordered LRU properties.
+ * After inserting:
+ * - The flag is guaranteed to be at the end of `flags`.
+ * - No other flags with the same name exist in `flags`.
+ * - The length of `flags` does not exceed FLAG_BUFFER_SIZE. If needed, the
+ * oldest inserted flag is evicted.
+ */
+export function insertToFlagBuffer(flags: FeatureFlag[], name: string, value: boolean): void {
+ // Check if the flag is already in the buffer
+ const index = flags.findIndex(f => f.flag === name);
+
+ if (index !== -1) {
+ // The flag was found, remove it from its current position - O(n)
+ flags.splice(index, 1);
+ }
+
+ if (flags.length === FLAG_BUFFER_SIZE) {
+ // If at capacity, pop the earliest flag - O(n)
+ flags.shift();
+ }
+
+ // Push the flag to the end - O(1)
+ flags.push({
+ flag: name,
+ result: value,
+ });
+}
diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts
index 4a2d68ca0d8b..1f8a86960a03 100644
--- a/packages/utils/src/index.ts
+++ b/packages/utils/src/index.ts
@@ -40,3 +40,4 @@ export * from './buildPolyfills';
export * from './propagationContext';
export * from './vercelWaitUntil';
export * from './version';
+export * from './flags';
diff --git a/yarn.lock b/yarn.lock
index 652c5721f5b6..4e2971138219 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8390,6 +8390,14 @@
"@sentry/cli-win32-i686" "2.37.0"
"@sentry/cli-win32-x64" "2.37.0"
+"@sentry/core@8.35.0":
+ version "8.35.0"
+ resolved "https://registry.yarnpkg.com/@sentry/core/-/core-8.35.0.tgz#17090f4d2d3bb983d9d99ecd2d27f4e9e107e0b0"
+ integrity sha512-Ci0Nmtw5ETWLqQJGY4dyF+iWh7PWKy6k303fCEoEmqj2czDrKJCp7yHBNV0XYbo00prj2ZTbCr6I7albYiyONA==
+ dependencies:
+ "@sentry/types" "8.35.0"
+ "@sentry/utils" "8.35.0"
+
"@sentry/rollup-plugin@2.22.6":
version "2.22.6"
resolved "https://registry.yarnpkg.com/@sentry/rollup-plugin/-/rollup-plugin-2.22.6.tgz#74e9ab69729ee024a497b21b66be3b1992e786d5"
@@ -8398,6 +8406,18 @@
"@sentry/bundler-plugin-core" "2.22.6"
unplugin "1.0.1"
+"@sentry/types@8.35.0":
+ version "8.35.0"
+ resolved "https://registry.yarnpkg.com/@sentry/types/-/types-8.35.0.tgz#535c807800f7e378f61416f30177c0ef81b95012"
+ integrity sha512-AVEZjb16MlYPifiDDvJ19dPQyDn0jlrtC1PHs6ZKO+Rzyz+2EX2BRdszvanqArldexPoU1p5Bn2w81XZNXThBA==
+
+"@sentry/utils@8.35.0":
+ version "8.35.0"
+ resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-8.35.0.tgz#1e099fcbc60040091c79f028a83226c145d588ee"
+ integrity sha512-MdMb6+uXjqND7qIPWhulubpSeHzia6HtxeJa8jYI09OCvIcmNGPydv/Gx/LZBwosfMHrLdTWcFH7Y7aCxrq7cg==
+ dependencies:
+ "@sentry/types" "8.35.0"
+
"@sentry/vite-plugin@2.22.6", "@sentry/vite-plugin@^2.22.6":
version "2.22.6"
resolved "https://registry.yarnpkg.com/@sentry/vite-plugin/-/vite-plugin-2.22.6.tgz#d08a1ede05f137636d5b3c61845d24c0114f0d76"
@@ -18029,6 +18049,11 @@ fake-indexeddb@^4.0.1:
dependencies:
realistic-structured-clone "^3.0.0"
+fast-deep-equal@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49"
+ integrity sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==
+
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
@@ -22272,6 +22297,23 @@ launch-editor@^2.9.1:
picocolors "^1.0.0"
shell-quote "^1.8.1"
+launchdarkly-js-client-sdk@^3.5.0:
+ version "3.5.0"
+ resolved "https://registry.yarnpkg.com/launchdarkly-js-client-sdk/-/launchdarkly-js-client-sdk-3.5.0.tgz#cb0e3d6fc21e56750aa86fcaf75733d7f510946f"
+ integrity sha512-3dgxC9S8K2ix6qjdArjZGOJPtAytgfQTuE+vWgjWJK7725rpYbuqbHghIFr5B0+WyWyVBYANldjWd1JdtYLwsw==
+ dependencies:
+ escape-string-regexp "^4.0.0"
+ launchdarkly-js-sdk-common "5.4.0"
+
+launchdarkly-js-sdk-common@5.4.0:
+ version "5.4.0"
+ resolved "https://registry.yarnpkg.com/launchdarkly-js-sdk-common/-/launchdarkly-js-sdk-common-5.4.0.tgz#c9787daebe0b583b01d2334218524ea142c85001"
+ integrity sha512-Kb3SDcB6S0HUpFNBZgtEt0YUV/fVkyg+gODfaOCJQ0Y0ApxLKNmmJBZOrPE2qIdzw536u4BqEjtaJdqJWCEElg==
+ dependencies:
+ base64-js "^1.3.0"
+ fast-deep-equal "^2.0.1"
+ uuid "^8.0.0"
+
lazystream@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/lazystream/-/lazystream-1.0.1.tgz#494c831062f1f9408251ec44db1cba29242a2638"