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 + +

+ +# 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"