├── .eslintrc.js ├── .github └── workflows │ ├── LRS.yml │ ├── kong.yml │ ├── release-bot.yml │ ├── scheduler.yml │ └── tests.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── LICENSE ├── README.md ├── babel.config.js ├── docs ├── README.md └── generated.md ├── index.d.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── ConfigEvaluation.ts ├── ConfigSpec.ts ├── Diagnostics.ts ├── DynamicConfig.ts ├── ErrorBoundary.ts ├── Errors.ts ├── EvaluationDetails.ts ├── EvaluationReason.ts ├── Evaluator.ts ├── FeatureGate.ts ├── InitializationDetails.ts ├── Layer.ts ├── LogEvent.ts ├── LogEventProcessor.ts ├── OutputLogger.ts ├── SDKFlags.ts ├── SpecStore.ts ├── StatsigInstanceUtils.ts ├── StatsigOptions.ts ├── StatsigServer.ts ├── StatsigUser.ts ├── UserPersistentStorageHandler.ts ├── __tests__ │ ├── BootstrapWithDataAdapter.data.ts │ ├── BootstrapWithDataAdapter.test.ts │ ├── ClientInitializeResponseConsistency.test.ts │ ├── ClientInitializeResponseOverride.test.ts │ ├── ConfigGroupName.test.ts │ ├── ConfigSpec.test.ts │ ├── ConfigSyncDiagnostics.test.ts │ ├── CustomDcsUrl.test.ts │ ├── DataAdapter.test.ts │ ├── DefaultValueFallbackLogging.test.ts │ ├── Diagnostics.test.ts │ ├── DiagnosticsCoreAPI.test.ts │ ├── DynamicConfig.test.ts │ ├── ErrorBoundary.test.ts │ ├── EvalCallback.test.ts │ ├── EvalCallbacks.test.ts │ ├── EvaluationDetails.test.ts │ ├── Evaluator.test.ts │ ├── Exports.test.ts │ ├── ExposureLogging.test.ts │ ├── FlushTimer.test.ts │ ├── FlushWithTimeout.test.ts │ ├── InitDetails.test.ts │ ├── InitDiagnostics.test.ts │ ├── InitStrategy.test.ts │ ├── InitTimeout.test.ts │ ├── Layer.test.ts │ ├── LayerExposure.test.ts │ ├── LocalModeOverride.test.ts │ ├── NetworkOverrideFunc.test.ts │ ├── OutputLogger.test.ts │ ├── PersistentAssignment.test.ts │ ├── ResetSync.test.ts │ ├── RulesetsEvalConsistency.test.ts │ ├── SDKFlags.test.ts │ ├── SafeShutdown.test.ts │ ├── SpecStore.test.ts │ ├── StatsigE2EBrowserTest.test.ts │ ├── StatsigE2ETest.test.ts │ ├── StatsigErrorBoundaryUsage.test.ts │ ├── StatsigTestUtils.ts │ ├── StatsigUserDeleteUndefinedFields.test.ts │ ├── TestDataAdapter.ts │ ├── TypedDynamicConfig.test.ts │ ├── TypedLayer.test.ts │ ├── data │ │ ├── download_config_spec.json │ │ ├── download_config_specs_group_name_test.json │ │ ├── download_config_specs_sticky_experiments.json │ │ ├── download_config_specs_sticky_experiments_inactive.json │ │ ├── eval_details_download_config_specs.json │ │ ├── exposure_logging_dcs.json │ │ ├── initialize_response.json │ │ ├── layer_exposure_download_config_specs.json │ │ └── rulesets_e2e_full_dcs.json │ ├── index.test.ts │ └── jest.setup.js ├── index.ts ├── interfaces │ ├── IDataAdapter.ts │ └── IUserPersistentStorage.ts ├── test_utils │ └── CheckGateTestUtils.ts └── utils │ ├── Base64.ts │ ├── Dispatcher.ts │ ├── EvaluatorUtils.ts │ ├── Hashing.ts │ ├── IDListUtil.ts │ ├── LogEventValidator.ts │ ├── Sha256.ts │ ├── StatsigContext.ts │ ├── StatsigFetcher.ts │ ├── __tests__ │ ├── Sha256.test.ts │ ├── StatsigFetcher.test.ts │ ├── parseUserAgent.test.ts │ └── utils.test.ts │ ├── asyncify.ts │ ├── core.ts │ ├── getEncodedBody.ts │ ├── parseUserAgent.ts │ └── safeFetch.ts └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | parserOptions: { 5 | project: './tsconfig.json', 6 | ecmaVersion: 2020, 7 | sourceType: 'module', 8 | }, 9 | plugins: ['@typescript-eslint', 'simple-import-sort'], 10 | rules: { 11 | 'simple-import-sort/imports': 'error', 12 | 'no-console': 'error', 13 | '@typescript-eslint/no-explicit-any': 'off', 14 | '@typescript-eslint/no-unused-vars': ['off', { varsIgnorePattern: '^_' }], 15 | '@typescript-eslint/no-for-in-array': 'off', 16 | '@typescript-eslint/no-unsafe-assignment': 'warn', 17 | '@typescript-eslint/no-unsafe-argument': 'warn', 18 | '@typescript-eslint/no-unsafe-member-access': 'warn', 19 | '@typescript-eslint/no-unsafe-return': 'warn', 20 | '@typescript-eslint/no-unsafe-call': 'warn', 21 | '@typescript-eslint/no-misused-promises': [ 22 | 'error', 23 | { 24 | checksVoidReturn: false, 25 | }, 26 | ], 27 | '@typescript-eslint/no-floating-promises': ['warn', { ignoreVoid: true }], 28 | '@typescript-eslint/restrict-plus-operands': 'warn', 29 | }, 30 | extends: [ 31 | 'eslint:recommended', 32 | 'plugin:@typescript-eslint/eslint-recommended', 33 | 'plugin:@typescript-eslint/recommended', 34 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 35 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 36 | ], 37 | ignorePatterns: [ 38 | '**/node_modules/*', 39 | '**/dist/*', 40 | '**/*.test.ts', 41 | '**/__tests__/*.ts', 42 | ], 43 | overrides: [ 44 | // Exceptions 45 | { 46 | files: ['**/safeFetch.ts', '**/StatsigContext.ts'], 47 | rules: { 48 | '@typescript-eslint/ban-ts-comment': 'off', 49 | }, 50 | }, 51 | { 52 | files: ['**/core.ts'], 53 | rules: { 54 | '@typescript-eslint/no-var-requires': 'off', 55 | }, 56 | }, 57 | { 58 | files: ['**/DynamicConfig.ts', '**/Layer.ts'], 59 | rules: { 60 | '@typescript-eslint/ban-ts-comment': 'off', 61 | }, 62 | }, 63 | ], 64 | }; 65 | -------------------------------------------------------------------------------- /.github/workflows/LRS.yml: -------------------------------------------------------------------------------- 1 | name: Long Running SDK CD 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | 9 | env: 10 | api_key: ${{ secrets.LRS_SERVER_KEY }} 11 | image_tag: nodejs 12 | sdk_repo: private-node-js-server-sdk 13 | 14 | jobs: 15 | CD: 16 | timeout-minutes: 10 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout LRS repo 20 | uses: actions/checkout@v4 21 | with: 22 | repository: statsig-io/long-running-sdk 23 | ref: main 24 | token: ${{ secrets.ROIM }} 25 | - name: Checkout SDK repo 26 | uses: actions/checkout@v4 27 | with: 28 | repository: statsig-io/${{ env.sdk_repo }} 29 | ref: main 30 | token: ${{ secrets.ROIM }} 31 | path: ./nodejs/${{ env.sdk_repo }} 32 | - name: Set up QEMU 33 | uses: docker/setup-qemu-action@v1 34 | - name: Set up Docker Buildx 35 | uses: docker/setup-buildx-action@v1 36 | - name: Login to DockerHub 37 | uses: docker/login-action@v1 38 | with: 39 | username: statsig 40 | password: ${{ secrets.DOCKERHUB_TOKEN }} 41 | - name: Build and push 42 | id: docker_build 43 | uses: docker/build-push-action@v5 44 | with: 45 | context: . 46 | file: nodejs/Dockerfile 47 | push: true 48 | tags: statsig/long-running-sdk:${{ env.image_tag }} 49 | github-token: ${{ secrets.GH_CI_CD_PAT }} 50 | build-args: secret_key=${{env.api_key}} 51 | platforms: linux/amd64 52 | -------------------------------------------------------------------------------- /.github/workflows/kong.yml: -------------------------------------------------------------------------------- 1 | name: KONG 2 | 3 | env: 4 | test_api_key: ${{ secrets.KONG_SERVER_SDK_KEY }} 5 | test_client_key: ${{ secrets. KONG_CLIENT_SDK_KEY }} 6 | repo_pat: ${{ secrets.KONG_FINE_GRAINED_REPO_PAT }} 7 | FORCE_COLOR: true 8 | 9 | on: 10 | workflow_dispatch: 11 | inputs: 12 | kong-branch: 13 | description: 'Kong branch name' 14 | type: string 15 | required: false 16 | pull_request: 17 | branches: [main] 18 | push: 19 | branches: [main] 20 | 21 | jobs: 22 | KONG: 23 | timeout-minutes: 15 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Get KONG 27 | run: | 28 | if [[ -n "${{ inputs.kong-branch }}" && ${{ github.event_name }} == "workflow_dispatch" ]]; then 29 | git clone -b ${{ inputs.kong-branch }} https://oauth2:$repo_pat@github.com/statsig-io/kong.git . 30 | else 31 | git clone https://oauth2:$repo_pat@github.com/statsig-io/kong.git . 32 | fi 33 | 34 | - uses: actions/setup-node@v4 35 | with: 36 | node-version: '16.x' 37 | 38 | - name: Install Deps 39 | run: npm install 40 | 41 | - name: Setup Node SDK 42 | run: npm run kong -- setup nodejs -v 43 | 44 | - name: Build Bridge 45 | run: npm run kong -- build nodejs -v 46 | 47 | - name: Run Tests 48 | run: npm run kong -- test nodejs -v -r 49 | 50 | - name: Run Measure Base Benchmark Score 51 | if: github.ref == 'refs/heads/main' 52 | run: npm run kong -- regression_test nodejs -p base -v 53 | 54 | # - name: Upload base benchmark score 55 | # if: github.ref == 'refs/heads/main' 56 | # uses: actions/upload-artifact@v3 57 | # with: 58 | # name: nodejs-perf 59 | # path: /tmp/perf/nodejs_perf_score.txt 60 | # retention-days: 5 61 | 62 | # - name: Run regression test 63 | # if: github.ref != 'refs/heads/main' 64 | # run: npm run kong -- regression_test nodejs -p test -v 65 | -------------------------------------------------------------------------------- /.github/workflows/release-bot.yml: -------------------------------------------------------------------------------- 1 | name: Release Bot 2 | 3 | on: 4 | pull_request: 5 | types: [opened, reopened, closed] 6 | branches: [main, stable] 7 | release: 8 | types: [released, prereleased] 9 | 10 | jobs: 11 | run: 12 | if: startsWith(github.head_ref, 'releases/') || github.event_name == 'release' 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: statsig-io/statsig-publish-sdk-action@main 16 | with: 17 | kong-private-key: ${{ secrets.KONG_GH_APP_PRIVATE_KEY }} 18 | npm-token: ${{ secrets.NPM_AUTOMATION_KEY }} 19 | -------------------------------------------------------------------------------- /.github/workflows/scheduler.yml: -------------------------------------------------------------------------------- 1 | name: Scheduler 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '0 0/4 * * *' 7 | 8 | jobs: 9 | trigger-runs: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Trigger Scheduled Test Runs 13 | if: github.event.repository.private 14 | uses: actions/github-script@v6 15 | with: 16 | script: | 17 | const args = { 18 | owner: context.repo.owner, 19 | repo: context.repo.repo, 20 | ref: 'main', 21 | } 22 | 23 | // Kong 24 | github.rest.actions.createWorkflowDispatch({ 25 | ...args, 26 | workflow_id: 'kong.yml' 27 | }) 28 | 29 | // Tests 30 | github.rest.actions.createWorkflowDispatch({ 31 | ...args, 32 | workflow_id: 'tests.yml' 33 | }) 34 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | branches: [main] 7 | push: 8 | branches: [main] 9 | 10 | jobs: 11 | jest: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: cache .npm 16 | uses: actions/cache@v4 17 | env: 18 | cache-name: npm 19 | with: 20 | path: ~/.npm 21 | key: ${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 22 | - run: npm ci 23 | - run: npm run test 24 | env: 25 | test_api_key: ${{ secrets.SDK_CONSISTENCY_TEST_COMPANY_API_KEY }} 26 | eslint: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v4 30 | with: 31 | ref: ${{ github.head_ref }} 32 | - name: cache .npm 33 | uses: actions/cache@v4 34 | env: 35 | cache-name: npm 36 | with: 37 | path: ~/.npm 38 | key: ${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 39 | - run: npm install 40 | - name: Lint 41 | run: npm run lint:github 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /docs/* -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 80, 6 | tabWidth: 2, 7 | }; 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License (ISC) 2 | Copyright (c) 2022, Statsig, Inc. 3 | 4 | Permission to use, copy, modify, and/or distribute this software for any purpose 5 | with or without fee is hereby granted, provided that the above copyright notice 6 | and this permission notice appear in all copies. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 9 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 10 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 11 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS 12 | OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER 13 | TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF 14 | THIS SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Statsig Node Server SDK 2 | 3 | [![npm version](https://badge.fury.io/js/statsig-node.svg)](https://badge.fury.io/js/statsig-node) [![tests](https://github.com/statsig-io/private-node-js-server-sdk/actions/workflows/tests.yml/badge.svg)](https://github.com/statsig-io/private-node-js-server-sdk/actions/workflows/tests.yml) 4 | 5 | The Node.js SDK for multi-user, server side environments. If you need a SDK for another language or single user client environment, check out our [other SDKs](https://docs.statsig.com/#sdks). 6 | 7 | Statsig helps you move faster with Feature Gates (Feature Flags) and Dynamic Configs. It also allows you to run A/B tests to validate your new features and understand their impact on your KPIs. If you're new to Statsig, create an account at [statsig.com](https://www.statsig.com). 8 | 9 | ## Getting Started 10 | 11 | Check out our [SDK docs](https://docs.statsig.com/server/nodejsServerSDK) to get started. 12 | 13 | ## Testing 14 | 15 | Each server SDK is tested at multiple levels - from unit to integration and e2e tests. Our internal e2e test harness runs daily against each server SDK, while unit and integration tests can be seen in the respective github repos of each SDK. For node, the `RulesetsEvalConsistency.test.js` runs a validation test on local rule/condition evaluation for node against the results in the statsig backend. 16 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { node: 'current' } }], 4 | '@babel/preset-typescript', 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This module contains types and interfaces 3 | * to allow for customizations of SDK features. 4 | */ 5 | declare module 'statsig-node/interfaces' { 6 | export type AdapterResponse = { 7 | result?: string; 8 | time?: number; 9 | error?: Error; 10 | }; 11 | 12 | /** 13 | * An adapter for implementing custom storage of config specs. 14 | * Useful for backing up data in memory. 15 | * Can also be used to bootstrap Statsig server. 16 | */ 17 | export interface IDataAdapter { 18 | /** 19 | * Returns the data stored for a specific key 20 | * @param key - Key of stored item to fetch 21 | */ 22 | get(key: string): Promise; 23 | 24 | /** 25 | * Updates data stored for each key 26 | * @param key - Key of stored item to update 27 | * @param value - New value to store 28 | * @param time - Time of update 29 | */ 30 | set(key: string, value: string, time?: number): Promise; 31 | 32 | /** 33 | * Startup tasks to run before any fetch/update calls can be made 34 | */ 35 | initialize(): Promise; 36 | 37 | /** 38 | * Cleanup tasks to run when statsig is shutdown 39 | */ 40 | shutdown(): Promise; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['./'], 3 | setupFilesAfterEnv: ['/src/__tests__/jest.setup.js'], 4 | testMatch: ['**/__tests__/**/*test.(j|t)s', '**/?(*.)+test.(j|t)s'], 5 | testPathIgnorePatterns: [ 6 | '/node_modules/', 7 | '/src/__tests__/jest.setup.js', 8 | '/dist/', 9 | ], 10 | testEnvironment: 'node', 11 | transformIgnorePatterns: ['/node_modules/(?!uuid)'], 12 | }; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "statsig-node", 3 | "version": "6.4.2", 4 | "description": "Statsig Node.js SDK for usage in multi-user server environments.", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "prepare": "rm -rf dist/ && tsc", 8 | "test": "npm run prepare && jest", 9 | "docs": "jsdoc2md src/index.js src/typedefs.js src/DynamicConfig.js > docs/generated.md", 10 | "lint": "eslint '*/**/*.{ts,tsx}' --fix --max-warnings 0 --cache --cache-strategy content && git status", 11 | "lint:github": "eslint '*/**/*.{ts,tsx}' --max-warnings 100 --cache --cache-strategy content" 12 | }, 13 | "keywords": [ 14 | "feature gate", 15 | "feature flag", 16 | "continuous deployment", 17 | "ci", 18 | "ab test" 19 | ], 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/statsig-io/node-js-server-sdk.git" 23 | }, 24 | "author": "Statsig, Inc.", 25 | "license": "ISC", 26 | "bugs": { 27 | "url": "https://github.com/statsig-io/node-js-server-sdk/issues" 28 | }, 29 | "homepage": "https://www.statsig.com", 30 | "dependencies": { 31 | "ip3country": "^5.0.0", 32 | "node-fetch": "^2.6.13", 33 | "ua-parser-js": "^1.0.2", 34 | "uuid": "^8.3.2" 35 | }, 36 | "devDependencies": { 37 | "@babel/core": "^7.23.2", 38 | "@babel/preset-env": "^7.18.10", 39 | "@babel/preset-typescript": "^7.18.6", 40 | "@types/jest": "^26.0.24", 41 | "@types/node": "^14.18.26", 42 | "@types/node-fetch": "^2.6.2", 43 | "@types/sha.js": "^2.4.0", 44 | "@types/ua-parser-js": "^0.7.36", 45 | "@types/useragent": "^2.3.1", 46 | "@types/uuid": "^8.3.4", 47 | "@types/whatwg-fetch": "^0.0.33", 48 | "@typescript-eslint/eslint-plugin": "^5.59.7", 49 | "@typescript-eslint/parser": "^5.59.7", 50 | "babel-jest": "^29.7.0", 51 | "eslint": "^8.50.0", 52 | "eslint-config-prettier": "^9.1.0", 53 | "eslint-plugin-prettier": "^5.1.2", 54 | "eslint-plugin-simple-import-sort": "^10.0.0", 55 | "sha.js": "^2.4.11", 56 | "jest": "^29.7.0", 57 | "jest-environment-jsdom": "^29.7.0", 58 | "jsdoc-to-markdown": "^7.1.1", 59 | "prettier": "^3.1.1", 60 | "typescript": "^4.7.4" 61 | }, 62 | "importSort": { 63 | ".js, .jsx, .ts, .tsx": { 64 | "style": "module", 65 | "parser": "typescript" 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/ConfigEvaluation.ts: -------------------------------------------------------------------------------- 1 | import { EvaluationDetails } from './EvaluationDetails'; 2 | import type { StickyValues } from './interfaces/IUserPersistentStorage'; 3 | import { SecondaryExposure } from './LogEvent'; 4 | 5 | export default class ConfigEvaluation { 6 | public value: boolean; 7 | public rule_id: string; 8 | public secondary_exposures: SecondaryExposure[]; 9 | public json_value: Record; 10 | public explicit_parameters: string[] | null; 11 | public config_delegate: string | null; 12 | public unsupported: boolean; 13 | public undelegated_secondary_exposures: SecondaryExposure[]; 14 | public is_experiment_group: boolean; 15 | public group_name: string | null; 16 | public evaluation_details: EvaluationDetails | undefined; 17 | public id_type: string | null; 18 | public configVersion?: number | undefined; 19 | 20 | constructor( 21 | value: boolean, 22 | rule_id = '', 23 | group_name: string | null = null, 24 | id_type: string | null = null, 25 | secondary_exposures: SecondaryExposure[] = [], 26 | json_value: Record | boolean = {}, 27 | explicit_parameters: string[] | null = null, 28 | config_delegate: string | null = null, 29 | configVersion?: number, 30 | unsupported = false, 31 | ) { 32 | this.value = value; 33 | this.rule_id = rule_id; 34 | if (typeof json_value === 'boolean') { 35 | // handle legacy gate case 36 | this.json_value = {}; 37 | } else { 38 | this.json_value = json_value; 39 | } 40 | this.secondary_exposures = secondary_exposures; 41 | this.undelegated_secondary_exposures = secondary_exposures; 42 | this.config_delegate = config_delegate; 43 | this.unsupported = unsupported; 44 | this.explicit_parameters = explicit_parameters; 45 | this.is_experiment_group = false; 46 | this.group_name = group_name; 47 | this.id_type = id_type; 48 | this.configVersion = configVersion; 49 | } 50 | 51 | public withEvaluationDetails( 52 | evaulationDetails: EvaluationDetails, 53 | ): ConfigEvaluation { 54 | this.evaluation_details = evaulationDetails; 55 | return this; 56 | } 57 | 58 | public setIsExperimentGroup(isExperimentGroup = false) { 59 | this.is_experiment_group = isExperimentGroup; 60 | } 61 | 62 | public static unsupported( 63 | configSyncTime: number, 64 | initialUpdateTime: number, 65 | version?: number | undefined, 66 | ): ConfigEvaluation { 67 | return new ConfigEvaluation( 68 | false, 69 | '', 70 | null, 71 | null, 72 | [], 73 | {}, 74 | undefined, 75 | undefined, 76 | version, 77 | true, 78 | ).withEvaluationDetails( 79 | EvaluationDetails.unsupported(configSyncTime, initialUpdateTime), 80 | ); 81 | } 82 | 83 | public toStickyValues(): StickyValues { 84 | return { 85 | value: this.value, 86 | json_value: this.json_value, 87 | rule_id: this.rule_id, 88 | group_name: this.group_name, 89 | secondary_exposures: this.secondary_exposures, 90 | undelegated_secondary_exposures: this.undelegated_secondary_exposures, 91 | config_delegate: this.config_delegate, 92 | explicit_parameters: this.explicit_parameters, 93 | time: this.evaluation_details?.configSyncTime ?? Date.now(), 94 | configVersion: this.configVersion, 95 | }; 96 | } 97 | 98 | public static fromStickyValues( 99 | stickyValues: StickyValues, 100 | initialUpdateTime: number, 101 | ): ConfigEvaluation { 102 | const evaluation = new ConfigEvaluation( 103 | stickyValues.value, 104 | stickyValues.rule_id, 105 | stickyValues.group_name, 106 | null, 107 | stickyValues.secondary_exposures, 108 | stickyValues.json_value, 109 | stickyValues.explicit_parameters, 110 | stickyValues.config_delegate, 111 | stickyValues.configVersion, 112 | ); 113 | evaluation.evaluation_details = EvaluationDetails.persisted( 114 | stickyValues.time, 115 | initialUpdateTime, 116 | ); 117 | evaluation.undelegated_secondary_exposures = 118 | stickyValues.undelegated_secondary_exposures; 119 | evaluation.is_experiment_group = true; 120 | 121 | return evaluation; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/ConfigSpec.ts: -------------------------------------------------------------------------------- 1 | export class ConfigSpec { 2 | public name: string; 3 | public type: string; 4 | public salt: string; 5 | public defaultValue: unknown; 6 | public enabled: boolean; 7 | public idType: string; 8 | public rules: ConfigRule[]; 9 | public entity: string; 10 | public explicitParameters: string[] | null; 11 | public hasSharedParams: boolean; 12 | public isActive?: boolean; 13 | public targetAppIDs?: string[]; 14 | public version?: number; 15 | 16 | constructor(specJSON: Record) { 17 | this.name = specJSON.name as string; 18 | this.type = specJSON.type as string; 19 | this.salt = specJSON.salt as string; 20 | this.defaultValue = specJSON.defaultValue; 21 | this.enabled = specJSON.enabled as boolean; 22 | this.idType = specJSON.idType as string; 23 | this.rules = this.parseRules(specJSON.rules); 24 | this.entity = specJSON.entity as string; 25 | this.explicitParameters = specJSON.explicitParameters as string[]; 26 | if (specJSON.version != null) { 27 | this.version = specJSON.version as number; 28 | } 29 | if (specJSON.isActive !== null) { 30 | this.isActive = specJSON.isActive === true; 31 | } 32 | this.hasSharedParams = 33 | specJSON.hasSharedParams != null 34 | ? specJSON.hasSharedParams === true 35 | : false; 36 | if (specJSON.targetAppIDs != null) { 37 | this.targetAppIDs = specJSON.targetAppIDs as string[]; 38 | } 39 | } 40 | 41 | parseRules(rulesJSON: unknown) { 42 | const json = rulesJSON as Record[]; 43 | const rules = []; 44 | for (let i = 0; i < json.length; i++) { 45 | const rule = new ConfigRule(json[i]); 46 | rules.push(rule); 47 | } 48 | return rules; 49 | } 50 | } 51 | 52 | export class ConfigRule { 53 | public name: string; 54 | public passPercentage: number; 55 | public conditions: ConfigCondition[]; 56 | public returnValue: unknown; 57 | public id: string; 58 | public salt: string; 59 | public idType: string; 60 | public configDelegate: string | null; 61 | public isExperimentGroup?: boolean; 62 | public groupName: string | null; 63 | 64 | constructor(ruleJSON: Record) { 65 | this.name = ruleJSON.name as string; 66 | this.passPercentage = ruleJSON.passPercentage as number; 67 | this.conditions = this.parseConditions(ruleJSON.conditions); 68 | this.returnValue = ruleJSON.returnValue; 69 | this.id = ruleJSON.id as string; 70 | this.salt = ruleJSON.salt as string; 71 | this.idType = ruleJSON.idType as string; 72 | this.configDelegate = (ruleJSON.configDelegate as string) ?? null; 73 | this.groupName = (ruleJSON.groupName as string) ?? null; 74 | 75 | if (ruleJSON.isExperimentGroup !== null) { 76 | this.isExperimentGroup = ruleJSON.isExperimentGroup as boolean; 77 | } 78 | } 79 | 80 | parseConditions(conditionsJSON: unknown) { 81 | const json = conditionsJSON as Record[]; 82 | const conditions: ConfigCondition[] = []; 83 | json?.forEach((cJSON) => { 84 | const condition = new ConfigCondition(cJSON); 85 | conditions.push(condition); 86 | }); 87 | return conditions; 88 | } 89 | 90 | isTargetingRule(): boolean { 91 | return this.id === 'inlineTargetingRules' || this.id === 'targetingGate'; 92 | } 93 | } 94 | 95 | export class ConfigCondition { 96 | public type: string; 97 | public targetValue: unknown; 98 | public operator: string; 99 | public field: string; 100 | public additionalValues: Record; 101 | public idType: string; 102 | public targetValueSet?: Set; 103 | public constructor(conditionJSON: Record) { 104 | this.type = conditionJSON.type as string; 105 | this.targetValue = conditionJSON.targetValue; 106 | this.operator = conditionJSON.operator as string; 107 | this.field = conditionJSON.field as string; 108 | this.additionalValues = 109 | (conditionJSON.additionalValues as Record) ?? {}; 110 | this.idType = conditionJSON.idType as string; 111 | if (this.operator === 'any' || this.operator === 'none') { 112 | if (Array.isArray(this.targetValue)) { 113 | this.targetValueSet = new Set(); 114 | const values = this.targetValue as (string | number)[]; 115 | for (let i = 0; i < values.length; i++) { 116 | this.targetValueSet.add(String(values[i])); 117 | this.targetValueSet.add(String(values[i]).toLowerCase()); 118 | } 119 | } 120 | } 121 | if ( 122 | this.operator === 'any_case_sensitive' || 123 | this.operator === 'none_case_sensitive' 124 | ) { 125 | if (Array.isArray(this.targetValue)) { 126 | this.targetValueSet = new Set(); 127 | const values = this.targetValue as (string | number)[]; 128 | for (let i = 0; i < values.length; i++) { 129 | this.targetValueSet.add(String(values[i])); 130 | } 131 | } 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/DynamicConfig.ts: -------------------------------------------------------------------------------- 1 | import type { EvaluationDetails } from './EvaluationDetails'; 2 | import { SecondaryExposure } from './LogEvent'; 3 | import { clone, getTypeOf } from './utils/core'; 4 | 5 | export type OnDefaultValueFallback = ( 6 | config: DynamicConfig, 7 | parameter: string, 8 | defaultValueType: string, 9 | valueType: string, 10 | ) => void; 11 | 12 | /** 13 | * Returns the data for a DynamicConfig in the statsig console via typed get functions 14 | */ 15 | export default class DynamicConfig { 16 | public name: string; 17 | public value: Record; 18 | private _ruleID: string; 19 | private _groupName: string | null; 20 | private _idType: string | null; 21 | private _secondaryExposures: SecondaryExposure[]; 22 | private _onDefaultValueFallback: OnDefaultValueFallback | null = null; 23 | private _evaluationDetails: EvaluationDetails | null; 24 | 25 | public constructor( 26 | configName: string, 27 | value: Record = {}, 28 | ruleID = '', 29 | groupName: string | null = null, 30 | idType: string | null = null, 31 | secondaryExposures: SecondaryExposure[] = [], 32 | onDefaultValueFallback: OnDefaultValueFallback | null = null, 33 | evaluationDetails: EvaluationDetails | null = null, 34 | ) { 35 | if (typeof configName !== 'string' || configName.length === 0) { 36 | configName = ''; 37 | } 38 | if (value == null || typeof value !== 'object') { 39 | value = {}; 40 | } 41 | this.name = configName; 42 | this.value = clone(value) ?? {}; 43 | this._ruleID = ruleID; 44 | this._groupName = groupName; 45 | this._idType = idType; 46 | this._secondaryExposures = Array.isArray(secondaryExposures) 47 | ? secondaryExposures 48 | : []; 49 | this._onDefaultValueFallback = onDefaultValueFallback; 50 | this._evaluationDetails = evaluationDetails; 51 | } 52 | 53 | public get( 54 | key: string, 55 | defaultValue: T, 56 | typeGuard: ((value: unknown) => value is T | null) | null = null, 57 | ): T { 58 | // @ts-ignore 59 | defaultValue = defaultValue ?? null; 60 | 61 | // @ts-ignore 62 | const val = this.getValue(key, defaultValue); 63 | 64 | if (val == null) { 65 | return defaultValue; 66 | } 67 | 68 | const expectedType = getTypeOf(defaultValue); 69 | const actualType = getTypeOf(val); 70 | 71 | if (typeGuard != null) { 72 | if (typeGuard(val)) { 73 | return val as T; 74 | } 75 | 76 | this._onDefaultValueFallback?.(this, key, expectedType, actualType); 77 | return defaultValue; 78 | } 79 | 80 | if (defaultValue == null || expectedType === actualType) { 81 | return val as T; 82 | } 83 | 84 | this._onDefaultValueFallback?.(this, key, expectedType, actualType); 85 | return defaultValue; 86 | } 87 | 88 | getValue( 89 | key: string, 90 | defaultValue?: boolean | number | string | object | Array | null, 91 | ): unknown | null { 92 | if (key == null) { 93 | return this.value; 94 | } 95 | 96 | if (defaultValue === undefined) { 97 | defaultValue = null; 98 | } 99 | 100 | return this.value[key] ?? defaultValue; 101 | } 102 | 103 | getRuleID(): string { 104 | return this._ruleID; 105 | } 106 | 107 | getGroupName(): string | null { 108 | return this._groupName; 109 | } 110 | 111 | getIDType(): string | null { 112 | return this._idType; 113 | } 114 | 115 | getEvaluationDetails(): EvaluationDetails | null { 116 | return this._evaluationDetails; 117 | } 118 | 119 | _getSecondaryExposures(): SecondaryExposure[] { 120 | return this._secondaryExposures; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/ErrorBoundary.ts: -------------------------------------------------------------------------------- 1 | import Diagnostics from './Diagnostics'; 2 | import { 3 | StatsigInvalidArgumentError, 4 | StatsigLocalModeNetworkError, 5 | StatsigTooManyRequestsError, 6 | StatsigUninitializedError, 7 | } from './Errors'; 8 | import OutputLogger from './OutputLogger'; 9 | import { NetworkOverrideFunc, StatsigOptions } from './StatsigOptions'; 10 | import { getSDKType, getSDKVersion, getStatsigMetadata } from './utils/core'; 11 | import safeFetch from './utils/safeFetch'; 12 | import { StatsigContext } from './utils/StatsigContext'; 13 | 14 | export const ExceptionEndpoint = 'https://statsigapi.net/v1/sdk_exception'; 15 | 16 | export default class ErrorBoundary { 17 | private sdkKey: string; 18 | private optionsLoggingCopy: StatsigOptions; 19 | private statsigMetadata = getStatsigMetadata(); 20 | private seen = new Set(); 21 | private networkOverrideFunc: NetworkOverrideFunc | null; 22 | 23 | constructor( 24 | sdkKey: string, 25 | optionsLoggingCopy: StatsigOptions, 26 | sessionID: string, 27 | ) { 28 | this.sdkKey = sdkKey; 29 | this.optionsLoggingCopy = optionsLoggingCopy; 30 | this.statsigMetadata['sessionID'] = sessionID; 31 | this.networkOverrideFunc = optionsLoggingCopy.networkOverrideFunc ?? null; 32 | } 33 | 34 | swallow(task: (ctx: StatsigContext) => T, ctx: StatsigContext) { 35 | this.capture( 36 | task, 37 | () => { 38 | return undefined; 39 | }, 40 | ctx, 41 | ); 42 | } 43 | 44 | capture( 45 | task: (ctx: C) => T, 46 | recover: (ctx: C, e: unknown) => T, 47 | ctx: C, 48 | ): T { 49 | let markerID: string | null = null; 50 | try { 51 | markerID = this.beginMarker(ctx.caller, ctx.configName); 52 | const result = task(ctx); 53 | if (result instanceof Promise) { 54 | return (result as any).catch((e: unknown) => { 55 | return this.onCaught(e, recover, ctx); 56 | }); 57 | } 58 | this.endMarker(ctx.caller, true, markerID, ctx.configName); 59 | return result; 60 | } catch (error) { 61 | this.endMarker(ctx.caller, false, markerID, ctx.configName); 62 | return this.onCaught(error, recover, ctx); 63 | } 64 | } 65 | 66 | setup(sdkKey: string) { 67 | this.sdkKey = sdkKey; 68 | } 69 | 70 | private onCaught( 71 | error: unknown, 72 | recover: (ctx: C, e: unknown) => T, 73 | ctx: C, 74 | ): T { 75 | if ( 76 | error instanceof StatsigUninitializedError || 77 | error instanceof StatsigInvalidArgumentError || 78 | error instanceof StatsigTooManyRequestsError 79 | ) { 80 | throw error; // Don't catch these 81 | } 82 | if (error instanceof StatsigLocalModeNetworkError) { 83 | return recover(ctx, error); 84 | } 85 | 86 | OutputLogger.error( 87 | '[Statsig] An unexpected exception occurred.', 88 | error as Error, 89 | ); 90 | 91 | this.logError(error, ctx); 92 | 93 | return recover(ctx, error); 94 | } 95 | 96 | public logError(error: unknown, ctx: StatsigContext) { 97 | try { 98 | if (!this.sdkKey || this.optionsLoggingCopy.disableAllLogging) { 99 | return; 100 | } 101 | 102 | const unwrapped = error ?? Error('[Statsig] Error was empty'); 103 | const isError = unwrapped instanceof Error; 104 | const name = isError && unwrapped.name ? unwrapped.name : 'No Name'; 105 | const hasSeen = this.seen.has(name); 106 | if (ctx.bypassDedupe !== true && hasSeen) { 107 | return; 108 | } 109 | this.seen.add(name); 110 | const info = isError ? unwrapped.stack : this.getDescription(unwrapped); 111 | const body = JSON.stringify({ 112 | exception: name, 113 | info, 114 | statsigMetadata: this.statsigMetadata ?? {}, 115 | statsigOptions: this.optionsLoggingCopy, 116 | ...ctx.getContextForLogging(), 117 | }); 118 | 119 | const fetcher = this.networkOverrideFunc ?? safeFetch; 120 | fetcher(ExceptionEndpoint, { 121 | method: 'POST', 122 | headers: { 123 | 'STATSIG-API-KEY': this.sdkKey, 124 | 'STATSIG-SDK-TYPE': getSDKType(), 125 | 'STATSIG-SDK-VERSION': getSDKVersion(), 126 | 'Content-Type': 'application/json', 127 | }, 128 | body, 129 | // eslint-disable-next-line @typescript-eslint/no-empty-function 130 | }).catch(() => {}); 131 | } catch { 132 | /* noop */ 133 | } 134 | } 135 | 136 | private getDescription(obj: unknown): string { 137 | try { 138 | return JSON.stringify(obj); 139 | } catch { 140 | return '[Statsig] Failed to get string for error.'; 141 | } 142 | } 143 | 144 | private beginMarker( 145 | key: string | undefined, 146 | configName: string | undefined, 147 | ): string | null { 148 | if (key == null) { 149 | return null; 150 | } 151 | const diagnostics = Diagnostics.mark.api_call(key); 152 | if (!diagnostics) { 153 | return null; 154 | } 155 | const count = Diagnostics.getMarkerCount('api_call'); 156 | const markerID = `${key}_${count}`; 157 | diagnostics.start( 158 | { 159 | markerID, 160 | configName, 161 | }, 162 | 'api_call', 163 | ); 164 | return markerID; 165 | } 166 | 167 | private endMarker( 168 | key: string | undefined, 169 | wasSuccessful: boolean, 170 | markerID: string | null, 171 | configName?: string, 172 | ): void { 173 | if (key == null) { 174 | return; 175 | } 176 | const diagnostics = Diagnostics.mark.api_call(key); 177 | if (!markerID || !diagnostics) { 178 | return; 179 | } 180 | diagnostics.end( 181 | { 182 | markerID, 183 | success: wasSuccessful, 184 | configName, 185 | }, 186 | 'api_call', 187 | ); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/Errors.ts: -------------------------------------------------------------------------------- 1 | export class StatsigUninitializedError extends Error { 2 | constructor() { 3 | super('Call and wait for initialize() to finish first.'); 4 | 5 | Object.setPrototypeOf(this, StatsigUninitializedError.prototype); 6 | } 7 | } 8 | 9 | export class StatsigInvalidArgumentError extends Error { 10 | constructor(message: string) { 11 | super(message); 12 | 13 | Object.setPrototypeOf(this, StatsigInvalidArgumentError.prototype); 14 | } 15 | } 16 | 17 | export class StatsigTooManyRequestsError extends Error { 18 | constructor(message: string) { 19 | super(message); 20 | 21 | Object.setPrototypeOf(this, StatsigTooManyRequestsError.prototype); 22 | } 23 | } 24 | 25 | export class StatsigLocalModeNetworkError extends Error { 26 | constructor() { 27 | super('No network requests in localMode'); 28 | 29 | Object.setPrototypeOf(this, StatsigLocalModeNetworkError.prototype); 30 | } 31 | } 32 | 33 | export class StatsigInitializeFromNetworkError extends Error { 34 | constructor(error?: Error) { 35 | super( 36 | `statsigSDK::initialize> Failed to initialize from the network${ 37 | error ? `: ${error.message}` : '' 38 | }. See https://docs.statsig.com/messages/serverSDKConnection for more information`, 39 | ); 40 | 41 | Object.setPrototypeOf(this, StatsigInitializeFromNetworkError.prototype); 42 | } 43 | } 44 | 45 | export class StatsigInitializeIDListsError extends Error { 46 | constructor(error?: Error) { 47 | super( 48 | `statsigSDK::initialize> Failed to initialize id lists${ 49 | error ? `: ${error.message}` : '' 50 | }.`, 51 | ); 52 | 53 | Object.setPrototypeOf(this, StatsigInitializeFromNetworkError.prototype); 54 | } 55 | } 56 | 57 | export class StatsigInvalidBootstrapValuesError extends Error { 58 | constructor() { 59 | super( 60 | 'statsigSDK::initialize> the provided bootstrapValues is not a valid JSON string.', 61 | ); 62 | 63 | Object.setPrototypeOf(this, StatsigInvalidBootstrapValuesError.prototype); 64 | } 65 | } 66 | 67 | export class StatsigInvalidDataAdapterValuesError extends Error { 68 | constructor(key: string) { 69 | super( 70 | `statsigSDK::dataAdapter> Failed to retrieve valid values for ${key}) from the provided data adapter`, 71 | ); 72 | 73 | Object.setPrototypeOf(this, StatsigInvalidDataAdapterValuesError.prototype); 74 | } 75 | } 76 | 77 | export class StatsigInvalidIDListsResponseError extends Error { 78 | constructor() { 79 | super( 80 | 'statsigSDK::dataAdapter> Failed to retrieve a valid ID lists response from network', 81 | ); 82 | 83 | Object.setPrototypeOf(this, StatsigInvalidIDListsResponseError.prototype); 84 | } 85 | } 86 | 87 | export class StatsigInvalidConfigSpecsResponseError extends Error { 88 | constructor() { 89 | super( 90 | 'statsigSDK::dataAdapter> Failed to retrieve a valid config specs response from network', 91 | ); 92 | 93 | Object.setPrototypeOf( 94 | this, 95 | StatsigInvalidConfigSpecsResponseError.prototype, 96 | ); 97 | } 98 | } 99 | 100 | export class StatsigSDKKeyMismatchError extends Error { 101 | constructor() { 102 | super( 103 | 'statsigSDK::initialize> SDK key provided in initialize() does not match the one used to generate initialize reponse.', 104 | ); 105 | 106 | Object.setPrototypeOf(this, StatsigSDKKeyMismatchError.prototype); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/EvaluationDetails.ts: -------------------------------------------------------------------------------- 1 | import { EvaluationReason } from './EvaluationReason'; 2 | 3 | export class EvaluationDetails { 4 | readonly configSyncTime: number; 5 | readonly initTime: number; 6 | readonly serverTime: number; 7 | readonly reason: EvaluationReason; 8 | 9 | private constructor( 10 | configSyncTime: number, 11 | initTime: number, 12 | reason: EvaluationReason, 13 | ) { 14 | this.configSyncTime = configSyncTime; 15 | this.initTime = initTime; 16 | this.reason = reason; 17 | this.serverTime = Date.now(); 18 | } 19 | 20 | static uninitialized() { 21 | return new EvaluationDetails(0, 0, 'Uninitialized'); 22 | } 23 | 24 | static unsupported(configSyncTime: number, initialUpdateTime: number) { 25 | return new EvaluationDetails( 26 | configSyncTime, 27 | initialUpdateTime, 28 | 'Unsupported', 29 | ); 30 | } 31 | 32 | static persisted(configSyncTime: number, initialUpdateTime: number) { 33 | return new EvaluationDetails( 34 | configSyncTime, 35 | initialUpdateTime, 36 | 'Persisted', 37 | ); 38 | } 39 | 40 | static make( 41 | configSyncTime: number, 42 | initialUpdateTime: number, 43 | reason: EvaluationReason, 44 | ) { 45 | return new EvaluationDetails(configSyncTime, initialUpdateTime, reason); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/EvaluationReason.ts: -------------------------------------------------------------------------------- 1 | export type EvaluationReason = 2 | | 'Network' 3 | | 'LocalOverride' 4 | | 'Unrecognized' 5 | | 'Uninitialized' 6 | | 'Bootstrap' 7 | | 'DataAdapter' 8 | | 'Unsupported' 9 | | 'Persisted'; 10 | -------------------------------------------------------------------------------- /src/FeatureGate.ts: -------------------------------------------------------------------------------- 1 | import type { EvaluationDetails } from './EvaluationDetails'; 2 | 3 | export type FeatureGate = { 4 | readonly name: string; 5 | readonly ruleID: string; 6 | readonly groupName: string | null; 7 | readonly idType: string | null; 8 | readonly value: boolean; 9 | readonly evaluationDetails: EvaluationDetails | null; 10 | }; 11 | 12 | export function makeFeatureGate( 13 | name: string, 14 | ruleID: string, 15 | value: boolean, 16 | groupName: string | null, 17 | idType: string | null, 18 | evaluationDetails: EvaluationDetails | null, 19 | ): FeatureGate { 20 | return { name, ruleID, value, groupName, idType, evaluationDetails }; 21 | } 22 | 23 | export function makeEmptyFeatureGate(name: string): FeatureGate { 24 | return { 25 | name, 26 | ruleID: '', 27 | value: false, 28 | groupName: null, 29 | idType: null, 30 | evaluationDetails: null, 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/InitializationDetails.ts: -------------------------------------------------------------------------------- 1 | export type InitializationDetails = { 2 | duration: number; 3 | success: boolean; 4 | error?: Error; 5 | source?: InitializationSource; 6 | }; 7 | 8 | export type InitializationSource = 'Network' | 'Bootstrap' | 'DataAdapter'; 9 | -------------------------------------------------------------------------------- /src/Layer.ts: -------------------------------------------------------------------------------- 1 | import type { EvaluationDetails } from './EvaluationDetails'; 2 | import { clone } from './utils/core'; 3 | 4 | type ExposeLayer = (layer: Layer, key: string) => void; 5 | 6 | /** 7 | * Returns the data for a Layer in the statsig console via typed get functions 8 | */ 9 | export default class Layer { 10 | public name: string; 11 | private _value: Record; 12 | private _ruleID: string; 13 | private _groupName: string | null; 14 | private _allocatedExperimentName: string | null; 15 | private _logExposure: ExposeLayer | null; 16 | private _evaluationDetails: EvaluationDetails | null; 17 | private _idType: string | null; 18 | 19 | public constructor( 20 | layerName: string, 21 | value: Record = {}, 22 | ruleID = '', 23 | groupName: string | null = null, 24 | allocatedExperimentName: string | null = null, 25 | logExposure: ExposeLayer | null = null, 26 | evaluationDetails: EvaluationDetails | null = null, 27 | idType: string | null = null, 28 | ) { 29 | if (typeof layerName !== 'string' || layerName.length === 0) { 30 | layerName = ''; 31 | } 32 | if (value == null || typeof value !== 'object') { 33 | value = {}; 34 | } 35 | 36 | this.name = layerName; 37 | this._value = clone(value) ?? {}; 38 | this._ruleID = ruleID; 39 | this._groupName = groupName; 40 | this._allocatedExperimentName = allocatedExperimentName; 41 | this._logExposure = logExposure; 42 | this._evaluationDetails = evaluationDetails; 43 | this._idType = idType; 44 | } 45 | 46 | public get( 47 | key: string, 48 | defaultValue: T, 49 | typeGuard: ((value: unknown) => value is T) | null = null, 50 | ): T { 51 | // @ts-ignore 52 | defaultValue = defaultValue ?? null; 53 | 54 | const val = this._value[key]; 55 | 56 | if (val == null) { 57 | return defaultValue; 58 | } 59 | 60 | const logAndReturn = (): T => { 61 | this._logExposure?.(this, key); 62 | return val as T; 63 | }; 64 | 65 | if (typeGuard) { 66 | return typeGuard(val) ? logAndReturn() : defaultValue; 67 | } 68 | 69 | if (defaultValue == null) { 70 | return logAndReturn(); 71 | } 72 | 73 | if ( 74 | typeof val === typeof defaultValue && 75 | Array.isArray(defaultValue) === Array.isArray(val) 76 | ) { 77 | return logAndReturn(); 78 | } 79 | 80 | return defaultValue; 81 | } 82 | 83 | getValue( 84 | key: string, 85 | defaultValue?: boolean | number | string | object | Array | null, 86 | ): unknown | null { 87 | if (defaultValue === undefined) { 88 | defaultValue = null; 89 | } 90 | 91 | if (key == null) { 92 | return defaultValue; 93 | } 94 | 95 | if (this._value[key] != null) { 96 | this._logExposure?.(this, key); 97 | } 98 | 99 | return this._value[key] ?? defaultValue; 100 | } 101 | 102 | getRuleID(): string { 103 | return this._ruleID; 104 | } 105 | 106 | getIDType(): string | null { 107 | return this._idType; 108 | } 109 | 110 | getGroupName(): string | null { 111 | return this._groupName; 112 | } 113 | 114 | getAllocatedExperimentName(): string | null { 115 | return this._allocatedExperimentName; 116 | } 117 | 118 | getEvaluationDetails(): EvaluationDetails | null { 119 | return this._evaluationDetails; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/LogEvent.ts: -------------------------------------------------------------------------------- 1 | import { Marker } from './Diagnostics'; 2 | import { ExplicitStatsigOptions } from './StatsigOptions'; 3 | import { StatsigUser } from './StatsigUser'; 4 | import { clone } from './utils/core'; 5 | import LogEventValidator, { MAX_OBJ_SIZE } from './utils/LogEventValidator'; 6 | 7 | export type LogEventData = { 8 | time: number; 9 | eventName: string; 10 | user: StatsigUser | null; 11 | value: string | number | null; 12 | metadata: Record | null; 13 | secondaryExposures: SecondaryExposure[]; 14 | }; 15 | 16 | export type SecondaryExposure = { 17 | gate: string; 18 | gateValue: string; 19 | ruleID: string; 20 | }; 21 | 22 | export default class LogEvent { 23 | private time: number; 24 | private eventName: string; 25 | private user: StatsigUser | null = null; 26 | private value: string | number | null = null; 27 | private metadata: Record | null = null; 28 | private secondaryExposures: SecondaryExposure[] = []; 29 | 30 | public constructor(eventName: string) { 31 | this.time = Date.now(); 32 | this.eventName = 33 | LogEventValidator.validateEventName(eventName) ?? 'invalid_event'; 34 | } 35 | 36 | public setUser(user: StatsigUser) { 37 | const validatedUser = LogEventValidator.validateUserObject(user); 38 | if (validatedUser == null) { 39 | return; 40 | } 41 | this.user = clone(validatedUser); 42 | if (this.user != null) { 43 | this.user.privateAttributes = null; 44 | } 45 | } 46 | 47 | public setValue(value: string | number | null) { 48 | const validatedValue = LogEventValidator.validateEventValue(value); 49 | if (validatedValue == null) { 50 | return; 51 | } 52 | this.value = validatedValue; 53 | } 54 | 55 | public setMetadata(metadata: Record | null) { 56 | const validatedMetadata = LogEventValidator.validateEventMetadata(metadata); 57 | if (validatedMetadata == null) { 58 | return; 59 | } 60 | this.metadata = clone(validatedMetadata); 61 | } 62 | 63 | public setDiagnosticsMetadata(metadata: { 64 | context: string; 65 | markers: Marker[]; 66 | statsigOptions: object | undefined; 67 | }) { 68 | const metadataSize = LogEventValidator.approximateObjectSize(metadata); 69 | let optionSize = 0; 70 | const metadataCopy = clone(metadata) as { 71 | context: string; 72 | markers: unknown; 73 | statsigOptions: unknown; 74 | }; 75 | if (metadataSize > MAX_OBJ_SIZE) { 76 | if (metadata.statsigOptions) { 77 | optionSize = LogEventValidator.approximateObjectSize( 78 | metadata.statsigOptions, 79 | ); 80 | metadataCopy.statsigOptions = 'dropped'; 81 | } 82 | if (metadataSize - optionSize > MAX_OBJ_SIZE) { 83 | if (metadata.context === 'initialize') { 84 | metadataCopy.markers = metadata.markers.filter( 85 | (marker) => marker.key === 'overall', 86 | ); 87 | } else { 88 | metadataCopy.markers = 'dropped'; 89 | } 90 | } 91 | } 92 | this.metadata = metadataCopy; 93 | } 94 | 95 | public setTime(time: number) { 96 | const validatedTime = LogEventValidator.validateEventTime(time); 97 | if (validatedTime == null) { 98 | return; 99 | } 100 | this.time = validatedTime; 101 | } 102 | 103 | public setSecondaryExposures(exposures: SecondaryExposure[]) { 104 | this.secondaryExposures = Array.isArray(exposures) ? exposures : []; 105 | } 106 | 107 | public validate(): boolean { 108 | return typeof this.eventName === 'string' && this.eventName.length > 0; 109 | } 110 | 111 | public toObject(): LogEventData { 112 | return { 113 | eventName: this.eventName, 114 | metadata: this.metadata, 115 | time: this.time, 116 | user: this.user, 117 | value: this.value, 118 | secondaryExposures: this.secondaryExposures, 119 | }; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/OutputLogger.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { LoggerInterface } from './StatsigOptions'; 3 | 4 | let _logger: LoggerInterface = { ...console, logLevel: 'warn' }; 5 | let _sdkKey: string | null = null; 6 | 7 | export default abstract class OutputLogger { 8 | static getLogger(): LoggerInterface { 9 | return _logger; 10 | } 11 | 12 | static debug(message?: any, ...optionalParams: any[]) { 13 | if (_logger.logLevel === 'debug') { 14 | const sanitizedMessage = this.sanitizeError(message); 15 | _logger.debug && _logger.debug(sanitizedMessage, ...optionalParams); 16 | } 17 | } 18 | 19 | static info(message?: any, ...optionalParams: any[]) { 20 | if (_logger.logLevel === 'debug' || _logger.logLevel === 'info') { 21 | const sanitizedMessage = this.sanitizeError(message); 22 | _logger.info && _logger.info(sanitizedMessage, ...optionalParams); 23 | } 24 | } 25 | 26 | static warn(message?: any, ...optionalParams: any[]) { 27 | if ( 28 | _logger.logLevel === 'debug' || 29 | _logger.logLevel === 'info' || 30 | _logger.logLevel === 'warn' 31 | ) { 32 | const sanitizedMessage = this.sanitizeError(message); 33 | _logger.warn(sanitizedMessage, ...optionalParams); 34 | } 35 | } 36 | 37 | static error(message?: any, ...optionalParams: any[]) { 38 | if ( 39 | _logger.logLevel === 'debug' || 40 | _logger.logLevel === 'info' || 41 | _logger.logLevel === 'warn' || 42 | _logger.logLevel === 'error' 43 | ) { 44 | const sanitizedMessage = this.sanitizeError(message); 45 | _logger.error(sanitizedMessage, ...optionalParams); 46 | } 47 | } 48 | 49 | static setLogger(logger: LoggerInterface, sdkKey: string) { 50 | _logger = logger; 51 | _sdkKey = sdkKey; 52 | } 53 | 54 | static resetLogger() { 55 | _logger = { ...console, logLevel: 'warn' }; 56 | } 57 | 58 | static sanitizeError(message: any): any { 59 | if (_sdkKey === null) { 60 | return message; 61 | } 62 | try { 63 | if (typeof message === 'string') { 64 | return message.replace(new RegExp(_sdkKey, 'g'), '******'); 65 | } else if (message instanceof Error) { 66 | return message.toString().replace(new RegExp(_sdkKey, 'g'), '******'); 67 | } 68 | } catch (_e) { 69 | // ignore 70 | } 71 | return message; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/SDKFlags.ts: -------------------------------------------------------------------------------- 1 | export default abstract class SDKFlags { 2 | private static _flags: Record = {}; 3 | 4 | static setFlags(newFlags: unknown) { 5 | const typedFlags = newFlags && typeof newFlags === 'object' ? newFlags : {}; 6 | this._flags = typedFlags as Record; 7 | } 8 | 9 | static on(key: string): boolean { 10 | return this._flags[key] === true; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/StatsigInstanceUtils.ts: -------------------------------------------------------------------------------- 1 | import StatsigServer from './StatsigServer'; 2 | 3 | let _instance: StatsigServer | null = null; 4 | 5 | export default abstract class StatsigInstanceUtils { 6 | static getInstance(): StatsigServer | null { 7 | return _instance; 8 | } 9 | 10 | static setInstance(instance: StatsigServer | null) { 11 | _instance = instance; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/StatsigUser.ts: -------------------------------------------------------------------------------- 1 | import { StatsigEnvironment } from './StatsigOptions'; 2 | import { djb2HashForObject } from './utils/Hashing'; 3 | 4 | /** 5 | * An object of properties relating to the current user 6 | * Provide as many as possible to take advantage of advanced conditions in the statsig console 7 | * A dictionary of additional fields can be provided under the "custom" field 8 | */ 9 | export type StatsigUser = 10 | // at least one of userID or customIDs must be provided 11 | ({ userID: string } | { customIDs: Record }) & { 12 | userID?: string; 13 | customIDs?: Record; 14 | email?: string; 15 | ip?: string; 16 | userAgent?: string; 17 | country?: string; 18 | locale?: string; 19 | appVersion?: string; 20 | custom?: Record< 21 | string, 22 | string | number | boolean | Array | undefined 23 | >; 24 | privateAttributes?: Record< 25 | string, 26 | string | number | boolean | Array | undefined 27 | > | null; 28 | statsigEnvironment?: StatsigEnvironment; 29 | }; 30 | 31 | export function getUserHashWithoutStableID(user: StatsigUser): string { 32 | const { customIDs, ...rest } = user; 33 | const copyCustomIDs = { ...customIDs }; 34 | delete copyCustomIDs.stableID; 35 | return djb2HashForObject({ ...rest, customIDs: copyCustomIDs }); 36 | } 37 | -------------------------------------------------------------------------------- /src/UserPersistentStorageHandler.ts: -------------------------------------------------------------------------------- 1 | import type ConfigEvaluation from './ConfigEvaluation'; 2 | import { 3 | IUserPersistentStorage, 4 | UserPersistedValues, 5 | } from './interfaces/IUserPersistentStorage'; 6 | import OutputLogger from './OutputLogger'; 7 | import type { StatsigUser } from './StatsigUser'; 8 | import { getUnitID } from './utils/EvaluatorUtils'; 9 | 10 | export default class UserPersistentStorageHandler { 11 | constructor(private storage: IUserPersistentStorage | null) {} 12 | 13 | public load(user: StatsigUser, idType: string): UserPersistedValues | null { 14 | if (this.storage == null) { 15 | return null; 16 | } 17 | 18 | const key = UserPersistentStorageHandler.getStorageKey(user, idType); 19 | if (!key) { 20 | return null; 21 | } 22 | 23 | try { 24 | return this.storage.load(key); 25 | } catch (e) { 26 | OutputLogger.error( 27 | `statsigSDK> Failed to load persisted values for key ${key} (${(e as Error).message})`, 28 | ); 29 | return null; 30 | } 31 | } 32 | 33 | public save( 34 | user: StatsigUser, 35 | idType: string, 36 | configName: string, 37 | evaluation: ConfigEvaluation, 38 | ): void { 39 | if (this.storage == null) { 40 | return; 41 | } 42 | 43 | const key = UserPersistentStorageHandler.getStorageKey(user, idType); 44 | if (!key) { 45 | return; 46 | } 47 | 48 | try { 49 | this.storage.save(key, configName, evaluation.toStickyValues()); 50 | } catch (e) { 51 | OutputLogger.error( 52 | `statsigSDK> Failed to save persisted values for key ${key} (${(e as Error).message})`, 53 | ); 54 | } 55 | } 56 | 57 | public delete(user: StatsigUser, idType: string, configName: string): void { 58 | if (this.storage == null) { 59 | return; 60 | } 61 | 62 | const key = UserPersistentStorageHandler.getStorageKey(user, idType); 63 | if (!key) { 64 | return; 65 | } 66 | 67 | try { 68 | this.storage.delete(key, configName); 69 | } catch (e) { 70 | OutputLogger.error( 71 | `statsigSDK> Failed to delete persisted values for key ${key} (${(e as Error).message})`, 72 | ); 73 | } 74 | } 75 | 76 | private static getStorageKey( 77 | user: StatsigUser, 78 | idType: string, 79 | ): string | null { 80 | const unitID = getUnitID(user, idType); 81 | if (!unitID) { 82 | OutputLogger.warn(`statsigSDK> No unit ID found for ID type ${idType}`); 83 | } 84 | return `${unitID ?? ''}:${idType}`; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/__tests__/BootstrapWithDataAdapter.data.ts: -------------------------------------------------------------------------------- 1 | export const GateForConfigSpecTest = [ 2 | { 3 | name: 'test_public', 4 | type: 'feature_gate', 5 | salt: '64fa52a6-4195-4658-b124-aa0be3ff8860', 6 | enabled: true, 7 | defaultValue: false, 8 | rules: [ 9 | { 10 | name: '6X3qJgyfwA81IJ2dxI7lYp', 11 | groupName: 'public', 12 | passPercentage: 100, 13 | conditions: [ 14 | { 15 | type: 'public', 16 | targetValue: null, 17 | operator: null, 18 | field: null, 19 | additionalValues: {}, 20 | isDeviceBased: false, 21 | idType: 'userID', 22 | }, 23 | ], 24 | returnValue: true, 25 | id: '6X3qJgyfwA81IJ2dxI7lYp', 26 | salt: '6X3qJgyfwA81IJ2dxI7lYp', 27 | isDeviceBased: false, 28 | idType: 'userID', 29 | }, 30 | ], 31 | isDeviceBased: false, 32 | idType: 'userID', 33 | entity: 'feature_gate', 34 | }, 35 | ]; 36 | 37 | export const GatesForIdListTest = [ 38 | { 39 | name: 'test_id_list', 40 | type: 'feature_gate', 41 | salt: '7113c807-8236-477f-ac1c-bb8ac69bc9f7', 42 | enabled: true, 43 | defaultValue: false, 44 | rules: [ 45 | { 46 | name: '1WF7SXC60cUGiiLvutKKQO', 47 | groupName: 'id_list', 48 | passPercentage: 100, 49 | conditions: [ 50 | { 51 | type: 'pass_gate', 52 | targetValue: 'segment:user_id_list', 53 | operator: null, 54 | field: null, 55 | additionalValues: {}, 56 | isDeviceBased: false, 57 | idType: 'userID', 58 | }, 59 | ], 60 | returnValue: true, 61 | id: '1WF7SXC60cUGiiLvutKKQO', 62 | salt: '61ac4901-051f-4448-ae0e-f559cc55294e', 63 | isDeviceBased: false, 64 | idType: 'userID', 65 | }, 66 | ], 67 | isDeviceBased: false, 68 | idType: 'userID', 69 | entity: 'feature_gate', 70 | }, 71 | { 72 | name: 'segment:user_id_list', 73 | type: 'feature_gate', 74 | salt: '2b81f86d-abd5-444f-93f4-79edf1815cd2', 75 | enabled: true, 76 | defaultValue: false, 77 | rules: [ 78 | { 79 | name: 'id_list', 80 | groupName: 'id_list', 81 | passPercentage: 100, 82 | conditions: [ 83 | { 84 | type: 'unit_id', 85 | targetValue: 'user_id_list', 86 | operator: 'in_segment_list', 87 | additionalValues: {}, 88 | isDeviceBased: false, 89 | idType: 'userID', 90 | }, 91 | ], 92 | returnValue: true, 93 | id: 'id_list', 94 | salt: '', 95 | isDeviceBased: false, 96 | idType: 'userID', 97 | }, 98 | ], 99 | isDeviceBased: false, 100 | idType: 'userID', 101 | entity: 'segment', 102 | }, 103 | ]; 104 | -------------------------------------------------------------------------------- /src/__tests__/BootstrapWithDataAdapter.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AdapterResponse, 3 | DataAdapterKeyPath, 4 | IDataAdapter, 5 | } from '../interfaces/IDataAdapter'; 6 | import StatsigServer from '../StatsigServer'; 7 | import { 8 | GateForConfigSpecTest, 9 | GatesForIdListTest, 10 | } from './BootstrapWithDataAdapter.data'; 11 | let hitNetwork = false; 12 | jest.mock('node-fetch', () => 13 | jest.fn().mockImplementation(() => { 14 | hitNetwork = true; 15 | return Promise.resolve(); 16 | }), 17 | ); 18 | 19 | function makeDownloadConfigSpecsResponse(gates?: any[]) { 20 | return { 21 | time: Date.now(), 22 | feature_gates: gates ?? [], 23 | dynamic_configs: [], 24 | layer_configs: [], 25 | has_updates: true, 26 | }; 27 | } 28 | 29 | class BootstrapDataAdapter implements IDataAdapter { 30 | private specs: string; 31 | private idLists: Record; 32 | private idListLookup: string; 33 | 34 | constructor(specs: Record, idLists: Record) { 35 | this.specs = JSON.stringify(specs); 36 | this.idLists = idLists; 37 | this.idListLookup = JSON.stringify(Object.keys(idLists)); 38 | } 39 | 40 | get(key: string): Promise { 41 | if (key.includes(DataAdapterKeyPath.V1Rulesets)) { 42 | return Promise.resolve({ result: this.specs }); 43 | } else if (key.includes(DataAdapterKeyPath.V1IDLists)) { 44 | return Promise.resolve({ result: this.idListLookup }); 45 | } else { 46 | const second_part = key.split('|')[1] 47 | const idListName = second_part.split('::')[1]; 48 | return Promise.resolve({ result: this.idLists[idListName] }); 49 | } 50 | } 51 | 52 | set = () => Promise.resolve(); 53 | initialize = () => Promise.resolve(); 54 | shutdown = () => Promise.resolve(); 55 | } 56 | 57 | describe('Bootstrap with DataAdapter', () => { 58 | beforeEach(async () => { 59 | hitNetwork = false; 60 | }); 61 | 62 | it('makes no network calls when bootstrapping', async () => { 63 | const adapter = new BootstrapDataAdapter( 64 | makeDownloadConfigSpecsResponse(), 65 | {}, 66 | ); 67 | 68 | const statsig = new StatsigServer('secret-key', { 69 | dataAdapter: adapter, 70 | }); 71 | await statsig.initializeAsync(); 72 | 73 | expect(hitNetwork).toBe(false); 74 | }); 75 | 76 | it('bootstraps id lists', async () => { 77 | const adapter = new BootstrapDataAdapter( 78 | makeDownloadConfigSpecsResponse(GatesForIdListTest), 79 | { 80 | /* a-user + b-user */ 81 | user_id_list: ['+Z/hEKLio', '+M5m6a10x'].join('\n'), 82 | }, 83 | ); 84 | 85 | const statsig = new StatsigServer('secret-key', { 86 | dataAdapter: adapter, 87 | }); 88 | await statsig.initializeAsync(); 89 | 90 | await Promise.all( 91 | [ 92 | { 93 | user: { userID: 'a-user' }, 94 | expectedValue: true, 95 | }, 96 | { 97 | user: { userID: 'b-user' }, 98 | expectedValue: true, 99 | }, 100 | { 101 | user: { userID: 'c-user' }, 102 | expectedValue: false, 103 | }, 104 | ].map( 105 | async ({ user, expectedValue }) => { 106 | const gateResult = statsig.checkGate(user, 'test_id_list'); 107 | expect(gateResult).toBe(expectedValue) 108 | } 109 | ), 110 | ); 111 | }); 112 | 113 | it('bootstraps config specs', async () => { 114 | const adapter = new BootstrapDataAdapter( 115 | makeDownloadConfigSpecsResponse(GateForConfigSpecTest), 116 | {}, 117 | ); 118 | 119 | const statsig = new StatsigServer('secret-key', { 120 | dataAdapter: adapter, 121 | }); 122 | await statsig.initializeAsync(); 123 | const gateResult = statsig.checkGate({ userID: 'a-user' }, 'test_public'); 124 | expect(gateResult).toBeTruthy(); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /src/__tests__/ClientInitializeResponseConsistency.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock('node-fetch', () => jest.fn()); 2 | const fetch = require('node-fetch'); 3 | const fetchActual = jest.requireActual('node-fetch'); 4 | 5 | import * as statsigsdk from '../index'; 6 | import StatsigInstanceUtils from '../StatsigInstanceUtils'; 7 | 8 | // @ts-ignore 9 | const statsig = statsigsdk.default; 10 | 11 | const clientKey = 'client-wlH3WMkysINMhMU8VrNBkbjrEr2JQrqgxKwDPOUosJK'; 12 | const secret = process.env.test_api_key; 13 | if (!secret) { 14 | throw 'THIS TEST IS EXPECTED TO FAIL FOR NON-STATSIG EMPLOYEES! If this is the only test failing, please proceed to submit a pull request. If you are a Statsig employee, chat with jkw.'; 15 | } 16 | 17 | // Disabled until optimizations are complete 18 | xdescribe('Verify e2e behavior consistency /initialize vs getClientInitializeResponse', () => { 19 | beforeEach(() => { 20 | jest.restoreAllMocks(); 21 | jest.resetModules(); 22 | StatsigInstanceUtils.setInstance(null); 23 | }); 24 | 25 | [ 26 | { 27 | api: 'https://api.statsig.com/v1', 28 | environment: null, 29 | }, 30 | { 31 | api: 'https://api.statsig.com/v1', 32 | environment: { 33 | tier: 'development', 34 | }, 35 | }, 36 | ].map((config) => 37 | test(`server and SDK evaluates gates to the same results on ${config.api}`, async () => { 38 | await _validateInitializeConsistency(config.api, config.environment); 39 | }), 40 | ); 41 | }); 42 | 43 | async function _validateInitializeConsistency(api, environment) { 44 | expect.assertions(1); 45 | const user = { 46 | userID: '123', 47 | email: 'test@statsig.com', 48 | country: 'US', 49 | custom: { 50 | test: '123', 51 | }, 52 | customIDs: { 53 | stableID: '12345', 54 | }, 55 | }; 56 | 57 | const serverUser = JSON.parse(JSON.stringify(user)); 58 | if (environment != null) { 59 | serverUser['statsigEnvironment'] = environment; 60 | } 61 | 62 | const response = await fetch(api + '/initialize', { 63 | method: 'POST', 64 | body: JSON.stringify({ 65 | user: serverUser, 66 | statsigMetadata: { 67 | sdkType: 'consistency-test', 68 | sessionID: 'x123', 69 | }, 70 | }), 71 | headers: { 72 | 'Content-type': 'application/json; charset=UTF-8', 73 | 'STATSIG-API-KEY': clientKey, 74 | 'STATSIG-CLIENT-TIME': Date.now(), 75 | }, 76 | }); 77 | const testData = await response.json(); 78 | // for sake of comparison, normalize the initialize response 79 | // drop unused fields, set the time to 0 80 | testData.time = 0; 81 | 82 | for (const topLevel in testData) { 83 | for (const property in testData[topLevel]) { 84 | const item = testData[topLevel][property]; 85 | if (item.secondary_exposures) { 86 | item.secondary_exposures.map((item) => { 87 | delete item.gate; 88 | }); 89 | item.undelegated_secondary_exposures?.map((item) => { 90 | delete item.gate; 91 | }); 92 | } 93 | } 94 | } 95 | 96 | const options: statsigsdk.StatsigOptions = { 97 | api, 98 | }; 99 | if (environment != null) { 100 | options.environment = environment; 101 | } 102 | 103 | await statsig.initialize(secret ?? '', options); 104 | 105 | const sdkInitializeResponse = statsig.getClientInitializeResponse( 106 | user, 107 | ) as any; 108 | 109 | for (const topLevel in sdkInitializeResponse) { 110 | for (const property in sdkInitializeResponse[topLevel]) { 111 | const item = sdkInitializeResponse[topLevel][property]; 112 | // initialize has these hashed, we are putting them in plain text 113 | // exposure logging still works 114 | item.secondary_exposures?.map((item) => { 115 | delete item.gate; 116 | }); 117 | item.undelegated_secondary_exposures?.map((item) => { 118 | delete item.gate; 119 | }); 120 | } 121 | } 122 | delete testData.generator; 123 | delete sdkInitializeResponse.generator; 124 | expect(sdkInitializeResponse).toEqual(testData); 125 | } 126 | 127 | async function filterGatesWithNoRules(reponse: Response) { 128 | const body = await reponse.json(); 129 | body['feature_gates'] = body['feature_gates'].filter(({ rules }) => { 130 | return rules.length > 0; 131 | }); 132 | return new fetchActual.default.Response(JSON.stringify(body)); 133 | } 134 | 135 | fetch.mockImplementation(async (url: string, params) => { 136 | const res = await fetchActual(url, params); 137 | 138 | if (url.toString().includes('/v1/download_config_specs')) { 139 | return filterGatesWithNoRules(res); 140 | } 141 | 142 | return res; 143 | }); 144 | -------------------------------------------------------------------------------- /src/__tests__/ClientInitializeResponseOverride.test.ts: -------------------------------------------------------------------------------- 1 | import * as statsigsdk from '../index'; 2 | import Statsig, { StatsigUser } from '../index'; 3 | 4 | // @ts-ignore 5 | const statsig = statsigsdk.default; 6 | 7 | jest.mock('node-fetch', () => jest.fn()); 8 | 9 | const CONFIG_SPEC_RESPONSE = JSON.stringify( 10 | require('./data/download_config_spec.json'), 11 | ); 12 | 13 | const user: StatsigUser = { 14 | userID: 'a-user', 15 | }; 16 | const clientKey = 'client-key'; 17 | 18 | describe('ClientInitializeResponse overrides', () => { 19 | beforeAll(async () => { 20 | const fetch = require('node-fetch'); 21 | fetch.mockImplementation((url: string, params) => { 22 | if (url.includes('download_config_specs')) { 23 | return Promise.resolve({ 24 | ok: true, 25 | text: () => Promise.resolve(CONFIG_SPEC_RESPONSE), 26 | }); 27 | } 28 | 29 | if (url.includes('log_event')) { 30 | return Promise.resolve({ 31 | ok: true, 32 | }); 33 | } 34 | 35 | return Promise.resolve({ 36 | ok: true, 37 | text: () => Promise.resolve('{}'), 38 | }); 39 | }); 40 | 41 | await Statsig.initialize('secret-key', { 42 | disableDiagnostics: true, 43 | }); 44 | }); 45 | 46 | it('can override feature gates', async () => { 47 | const noOverrides = statsig.getClientInitializeResponse(user, clientKey, { 48 | hash: 'none', 49 | }); 50 | expect(noOverrides?.feature_gates['always_on_gate'].value).toBe(true); 51 | const overridden = statsig.getClientInitializeResponse(user, clientKey, { 52 | hash: 'none', 53 | overrides: { 54 | featureGates: { 55 | always_on_gate: false, 56 | }, 57 | }, 58 | }); 59 | expect(overridden?.feature_gates['always_on_gate'].value).toBe(false); 60 | }); 61 | 62 | it('can override dynamic configs', async () => { 63 | const noOverrides = statsig.getClientInitializeResponse(user, clientKey, { 64 | hash: 'none', 65 | }); 66 | expect( 67 | noOverrides?.dynamic_configs['sample_experiment'].value, 68 | ).toMatchObject({ 69 | sample_parameter: expect.any(Boolean), 70 | }); 71 | expect( 72 | noOverrides?.dynamic_configs['sample_experiment'].group_name, 73 | ).toMatch(/^(Test|Control)$/); 74 | 75 | const overriddenControl = statsig.getClientInitializeResponse( 76 | user, 77 | clientKey, 78 | { 79 | hash: 'none', 80 | overrides: { 81 | dynamicConfigs: { 82 | sample_experiment: { 83 | groupName: 'Control', 84 | }, 85 | }, 86 | }, 87 | }, 88 | ); 89 | expect( 90 | overriddenControl?.dynamic_configs['sample_experiment'].group_name, 91 | ).toBe('Control'); 92 | expect( 93 | overriddenControl?.dynamic_configs['sample_experiment'].value, 94 | ).toMatchObject({ 95 | sample_parameter: false, 96 | }); 97 | 98 | const overriddenTest = statsig.getClientInitializeResponse( 99 | user, 100 | clientKey, 101 | { 102 | hash: 'none', 103 | overrides: { 104 | dynamicConfigs: { 105 | sample_experiment: { 106 | groupName: 'Test', 107 | }, 108 | }, 109 | }, 110 | }, 111 | ); 112 | expect( 113 | overriddenTest?.dynamic_configs['sample_experiment'].group_name, 114 | ).toBe('Test'); 115 | expect( 116 | overriddenTest?.dynamic_configs['sample_experiment'].value, 117 | ).toMatchObject({ 118 | sample_parameter: true, 119 | }); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /src/__tests__/ConfigGroupName.test.ts: -------------------------------------------------------------------------------- 1 | import Statsig from '../index'; 2 | import StatsigInstanceUtils from '../StatsigInstanceUtils'; 3 | 4 | jest.mock('node-fetch', () => jest.fn()); 5 | 6 | const CONFIG_SPEC_RESPONSE = JSON.stringify( 7 | require('./data/download_config_specs_group_name_test.json'), 8 | ); 9 | 10 | describe('ConfigGroupName', () => { 11 | beforeEach(async () => { 12 | const fetch = require('node-fetch'); 13 | fetch.mockImplementation(() => { 14 | return Promise.resolve({ 15 | ok: true, 16 | text: () => Promise.resolve(CONFIG_SPEC_RESPONSE), 17 | }); 18 | }); 19 | 20 | StatsigInstanceUtils.setInstance(null); 21 | await Statsig.initialize('secret-key', { disableDiagnostics: true }); 22 | }); 23 | 24 | describe('getFeatureGate', () => { 25 | it.each([ 26 | ['Has Custom', { userID: 'a-user', custom: { Foo: 'Bar' } }], 27 | ['Has IP From NZ', { userID: 'b-user', ip: '101.100.159.200' }], 28 | ['Is Beta Release', { userID: 'c-user', appVersion: '1.1.1-beta' }], 29 | ])('has group name `%s`', async (expected, user) => { 30 | const gate = await Statsig.getFeatureGate(user, 'test_many_rules'); 31 | expect(gate.groupName).toBeNull(); 32 | }); 33 | 34 | it('returns null when no gate is found', async () => { 35 | const user = { userID: 'a-user' }; 36 | const gate = await Statsig.getFeatureGate(user, 'not_a_valid_gate'); 37 | expect(gate.groupName).toBeNull(); 38 | }); 39 | 40 | it('returns null when the user is locally overriden', async () => { 41 | const user = { userID: 'override-user', ip: '101.100.159.200' }; 42 | 43 | Statsig.overrideGate('test_many_rules', true, 'override-user'); 44 | const gate = await Statsig.getFeatureGate(user, 'test_many_rules'); 45 | 46 | expect(gate.groupName).toBeNull(); 47 | }); 48 | 49 | it('returns null when the gate is disabled', async () => { 50 | const user = { userID: 'b-user' }; 51 | 52 | const gate = await Statsig.getFeatureGate(user, 'test_disabled_gate'); 53 | expect(gate.groupName).toBeNull(); 54 | }); 55 | }); 56 | 57 | describe('getExperiment', () => { 58 | it.each([ 59 | ['user-not-allocated-to-experiment-1', null], 60 | ['user-allocated-to-test-6', 'Test #2'], 61 | ['user-allocated-to-control-3', 'Control'], 62 | ])('%s has group name %s', async (userID, expected) => { 63 | const user = { userID: userID }; 64 | const experiment = await Statsig.getExperiment( 65 | user, 66 | 'experiment_with_many_params', 67 | ); 68 | 69 | expect(experiment.getGroupName()).toEqual(expected); 70 | }); 71 | 72 | it('returns null when no experiment is found', async () => { 73 | const user = { userID: 'a-user' }; 74 | const experiment = await Statsig.getExperiment( 75 | user, 76 | 'not_a_valid_experiment', 77 | ); 78 | 79 | expect(experiment.getGroupName()).toBeNull(); 80 | }); 81 | 82 | it('returns null when the user is locally overriden', async () => { 83 | const user = { userID: 'a-user' }; 84 | 85 | Statsig.overrideConfig( 86 | 'experiment_with_many_params', 87 | { foo: 'bar' }, 88 | user.userID, 89 | ); 90 | 91 | const experiment = await Statsig.getExperiment( 92 | user, 93 | 'experiment_with_many_params', 94 | ); 95 | 96 | expect(experiment.getGroupName()).toBeNull(); 97 | }); 98 | 99 | it('returns null when the config is disabled', async () => { 100 | const user = { userID: 'b-user' }; 101 | 102 | const experiment = await Statsig.getExperiment(user, 'disabled_config'); 103 | 104 | expect(experiment.getGroupName()).toBeNull(); 105 | }); 106 | }); 107 | 108 | describe('getClientInitializeResponse', () => { 109 | describe('GroupName in FeatureGates', () => { 110 | let gate: any; 111 | 112 | beforeEach(() => { 113 | const user = { userID: 'user-a' }; 114 | const response = Statsig.getClientInitializeResponse(user, undefined, { 115 | hash: 'none', 116 | })!; 117 | gate = response.feature_gates['test_many_rules']; 118 | }); 119 | 120 | it('group name is not included in gate results', () => { 121 | expect(gate.group_name).toBeUndefined(); 122 | }); 123 | }); 124 | 125 | describe('GroupName in DynamicConfigs', () => { 126 | let config: any; 127 | 128 | beforeEach(() => { 129 | const user = { userID: 'user-a' }; 130 | const response = Statsig.getClientInitializeResponse(user, undefined, { 131 | hash: 'none', 132 | })!; 133 | config = response.dynamic_configs['disabled_config']; 134 | }); 135 | 136 | it('group name is not included in dynamic config results', () => { 137 | expect(config.group_name).toBeUndefined(); 138 | }); 139 | }); 140 | 141 | describe('User not in Experiment', () => { 142 | let experiment: any; 143 | let layer: any; 144 | 145 | beforeEach(() => { 146 | const user = { userID: 'user-a' }; 147 | const response = Statsig.getClientInitializeResponse(user, undefined, { 148 | hash: 'none', 149 | })!; 150 | 151 | experiment = response.dynamic_configs['experiment_with_many_params']; 152 | layer = response.layer_configs['layer_with_many_params']; 153 | }); 154 | 155 | it('returns null when group name is not an experiment group', () => { 156 | expect(experiment.group_name).toBeUndefined(); 157 | expect(layer.group_name).toBeUndefined(); 158 | }); 159 | }); 160 | 161 | describe('User is in Experiment', () => { 162 | let experiment: any; 163 | let layer: any; 164 | 165 | beforeEach(() => { 166 | const user = { userID: 'user-b' }; 167 | const response = Statsig.getClientInitializeResponse(user, undefined, { 168 | hash: 'none', 169 | })!; 170 | 171 | experiment = response.dynamic_configs['experiment_with_many_params']; 172 | layer = response.layer_configs['layer_with_many_params']; 173 | }); 174 | 175 | it('includes group name in experiments', () => { 176 | expect(experiment.group_name).toBe('Control'); 177 | }); 178 | 179 | it('includes group name in layers', () => { 180 | expect(layer.group_name).toBe('Control'); 181 | }); 182 | }); 183 | }); 184 | }); 185 | -------------------------------------------------------------------------------- /src/__tests__/ConfigSpec.test.ts: -------------------------------------------------------------------------------- 1 | const { ConfigSpec } = require('../ConfigSpec'); 2 | const exampleConfigSpecs = require('./jest.setup'); 3 | 4 | describe('Verify behavior of ConfigSpec', () => { 5 | const gateSpec = new ConfigSpec(exampleConfigSpecs.gate); 6 | const dynamicConfigSpec = new ConfigSpec(exampleConfigSpecs.config); 7 | 8 | beforeEach(() => {}); 9 | 10 | test('Test constructor works for feature gates', () => { 11 | expect(gateSpec).toBeTruthy(); 12 | expect(gateSpec.type).toEqual('feature_gate'); 13 | expect(gateSpec.name).toEqual('nfl_gate'); 14 | expect(gateSpec.salt).toEqual('na'); 15 | expect(gateSpec.enabled).toEqual(true); 16 | expect(gateSpec.defaultValue).toEqual(false); 17 | 18 | const rules = gateSpec.rules; 19 | expect(Array.isArray(rules)).toEqual(true); 20 | expect(rules.length).toEqual(1); 21 | 22 | const rule = rules[0]; 23 | expect(rule.name).toEqual('employees'); 24 | expect(rule.id).toEqual('rule_id_gate'); 25 | expect(rule.passPercentage).toEqual(100); 26 | expect(rule.returnValue).toEqual(true); 27 | 28 | const conds = rule.conditions; 29 | expect(Array.isArray(conds)).toEqual(true); 30 | expect(conds.length).toEqual(1); 31 | 32 | const cond = conds[0]; 33 | expect(cond.type).toEqual('user_field'); 34 | expect(cond.targetValue).toEqual(['packers.com', 'nfl.com']); 35 | expect(cond.operator).toEqual('str_contains_any'); 36 | expect(cond.field).toEqual('email'); 37 | }); 38 | 39 | test('Test constructor works for dynamic configs', () => { 40 | expect(dynamicConfigSpec).toBeTruthy(); 41 | expect(dynamicConfigSpec.type).toEqual('dynamic_config'); 42 | expect(dynamicConfigSpec.name).toEqual('teams'); 43 | expect(dynamicConfigSpec.salt).toEqual('sodium'); 44 | expect(dynamicConfigSpec.enabled).toEqual(true); 45 | expect(dynamicConfigSpec.defaultValue).toEqual({ 46 | test: 'default', 47 | }); 48 | 49 | const rules = dynamicConfigSpec.rules; 50 | expect(Array.isArray(rules)).toEqual(true); 51 | expect(rules.length).toEqual(2); 52 | 53 | const rule = rules[0]; 54 | expect(rule.name).toEqual('can see teams'); 55 | expect(rule.id).toEqual('rule_id_config'); 56 | expect(rule.passPercentage).toEqual(100); 57 | expect(rule.returnValue).toEqual({ 58 | packers: { 59 | name: 'Green Bay Packers', 60 | yearFounded: 1919, 61 | }, 62 | seahawks: { 63 | name: 'Seattle Seahawks', 64 | yearFounded: 1974, 65 | }, 66 | }); 67 | 68 | const conds = rule.conditions; 69 | expect(Array.isArray(conds)).toEqual(true); 70 | expect(conds.length).toEqual(1); 71 | 72 | const cond = conds[0]; 73 | expect(cond.type).toEqual('user_field'); 74 | expect(cond.targetValue).toEqual(9); 75 | expect(cond.operator).toEqual('gte'); 76 | expect(cond.field).toEqual('level'); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/__tests__/CustomDcsUrl.test.ts: -------------------------------------------------------------------------------- 1 | import Diagnostics from '../Diagnostics'; 2 | import ErrorBoundary from '../ErrorBoundary'; 3 | import LogEvent from '../LogEvent'; 4 | import LogEventProcessor from '../LogEventProcessor'; 5 | import SpecStore from '../SpecStore'; 6 | import { OptionsWithDefaults } from '../StatsigOptions'; 7 | import { InitializeContext } from '../utils/StatsigContext'; 8 | import StatsigFetcher from '../utils/StatsigFetcher'; 9 | 10 | const jsonResponse = { 11 | time: Date.now(), 12 | feature_gates: [], 13 | dynamic_configs: [], 14 | layer_configs: [], 15 | has_updates: true, 16 | }; 17 | const dcsPath = '/download_config_specs'; 18 | const customUrl = 'custom_download_config_specs_url'; 19 | 20 | describe('Check custom DCS url', () => { 21 | const options = OptionsWithDefaults({ 22 | apiForDownloadConfigSpecs: customUrl, 23 | disableDiagnostics: true, 24 | }); 25 | const secretKey = 'secret-123'; 26 | const errorBoundary = new ErrorBoundary(secretKey, options, 'sessionid-1'); 27 | const fetcher = new StatsigFetcher(secretKey, options); 28 | const logger = new LogEventProcessor(fetcher, errorBoundary, options); 29 | const store = new SpecStore(secretKey, fetcher, options); 30 | Diagnostics.initialize({ logger }); 31 | 32 | const spy = jest.spyOn(fetcher, 'request').mockImplementation(async () => { 33 | return new Response(JSON.stringify(jsonResponse), { status: 200 }); 34 | }); 35 | 36 | it('works', async () => { 37 | await store.init(InitializeContext.new({ sdkKey: 'secret-key' })); 38 | logger.log(new LogEvent('test')); 39 | await logger.flush(); 40 | 41 | expect(spy).toHaveBeenCalledWith( 42 | 'GET', 43 | customUrl + dcsPath + `/${secretKey}.json?sinceTime=0`, 44 | undefined, 45 | undefined, 46 | ); 47 | expect(spy).not.toHaveBeenCalledWith( 48 | 'POST', 49 | customUrl + '/get_id_lists', 50 | expect.anything(), 51 | ); 52 | expect(spy).not.toHaveBeenCalledWith( 53 | 'POST', 54 | customUrl + '/log_event', 55 | expect.anything(), 56 | ); 57 | 58 | spy.mock.calls.forEach((u) => { 59 | if (u[0].endsWith(dcsPath) && u[0] != customUrl + dcsPath) { 60 | fail('download_config_spec should not be called on another base url'); 61 | } 62 | }); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/__tests__/DefaultValueFallbackLogging.test.ts: -------------------------------------------------------------------------------- 1 | import Statsig, { DynamicConfig, StatsigUser } from '../index'; 2 | import StatsigInstanceUtils from '../StatsigInstanceUtils'; 3 | import { parseLogEvents } from './StatsigTestUtils'; 4 | 5 | jest.mock('node-fetch', () => jest.fn()); 6 | 7 | const CONFIG_SPEC_RESPONSE = JSON.stringify( 8 | require('./data/download_config_spec.json'), 9 | ); 10 | 11 | const user: StatsigUser = { 12 | userID: 'a-user', 13 | }; 14 | 15 | describe('On Default Value Fallback', () => { 16 | let events: { 17 | eventName: string; 18 | time: number; 19 | metadata: { gate?: string; config?: string; isManualExposure?: string }; 20 | }[] = []; 21 | let config: DynamicConfig; 22 | 23 | beforeAll(async () => { 24 | const fetch = require('node-fetch'); 25 | fetch.mockImplementation((url: string, params) => { 26 | if (url.includes('download_config_specs')) { 27 | return Promise.resolve({ 28 | ok: true, 29 | text: () => Promise.resolve(CONFIG_SPEC_RESPONSE), 30 | }); 31 | } 32 | 33 | if (url.includes('log_event')) { 34 | events = events.concat(parseLogEvents(params)['events']); 35 | return Promise.resolve({ 36 | ok: true, 37 | }); 38 | } 39 | 40 | return Promise.resolve({ 41 | ok: true, 42 | text: () => Promise.resolve('{}'), 43 | }); 44 | }); 45 | 46 | StatsigInstanceUtils.setInstance(null); 47 | await Statsig.initialize('secret-key', {disableDiagnostics: true}); 48 | }); 49 | 50 | beforeEach(async () => { 51 | config = await Statsig.getConfig(user, 'test_config'); 52 | await Statsig.flush() 53 | events = []; 54 | }); 55 | 56 | it('logs an event when falling back to default value', async () => { 57 | config.get('number', 'a_string'); 58 | await Statsig.flush(); 59 | expect(events.length).toBe(1); 60 | 61 | const event = events[0]; 62 | expect(event).toMatchObject({ 63 | eventName: 'statsig::default_value_type_mismatch', 64 | metadata: { 65 | defaultValueType: 'string', 66 | name: 'test_config', 67 | parameter: 'number', 68 | ruleID: 'default', 69 | valueType: 'number', 70 | }, 71 | }); 72 | }); 73 | 74 | it('logs an event when the typeguard fails', async () => { 75 | config.get('boolean', 'a_string', (_v): _v is string => false); 76 | await Statsig.flush(); 77 | expect(events.length).toBe(1); 78 | 79 | const event = events[0]; 80 | expect(event).toMatchObject({ 81 | eventName: 'statsig::default_value_type_mismatch', 82 | metadata: { 83 | defaultValueType: 'string', 84 | name: 'test_config', 85 | parameter: 'boolean', 86 | ruleID: 'default', 87 | valueType: 'boolean', 88 | }, 89 | }); 90 | }); 91 | 92 | it('does not log when returning the correct value', async () => { 93 | config.get('number', 0); 94 | await Statsig.flush(); 95 | expect(events.length).toBe(0); 96 | }); 97 | 98 | it('does not log when type guard succeeds', async () => { 99 | config.get('number', 0, (_v): _v is number => true); 100 | await Statsig.flush(); 101 | expect(events.length).toBe(0); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /src/__tests__/Diagnostics.test.ts: -------------------------------------------------------------------------------- 1 | import Diagnostics, { 2 | ContextType, 3 | DiagnosticsImpl, 4 | KeyType, 5 | } from '../Diagnostics'; 6 | import ErrorBoundary from '../ErrorBoundary'; 7 | import LogEventProcessor from '../LogEventProcessor'; 8 | import { OptionsLoggingCopy, OptionsWithDefaults } from '../StatsigOptions'; 9 | import StatsigFetcher from '../utils/StatsigFetcher'; 10 | import { getDecodedBody } from './StatsigTestUtils'; 11 | 12 | jest.mock('node-fetch', () => jest.fn()); 13 | 14 | function getMarkers(): DiagnosticsImpl['markers'] { 15 | return (Diagnostics as any).instance.markers; 16 | } 17 | 18 | describe('Diagnostics', () => { 19 | const options = { loggingMaxBufferSize: 1 } 20 | const logger = new LogEventProcessor( 21 | new StatsigFetcher('secret-asdf1234', options), 22 | new ErrorBoundary('secret-asdf1234', options, 'sessionID-a'), 23 | OptionsWithDefaults(options), 24 | OptionsLoggingCopy(options), 25 | 'sessionID' 26 | ); 27 | 28 | let events: { 29 | eventName: string; 30 | metadata: { gate?: string; config?: string; isManualExposure?: string }; 31 | }[] = []; 32 | 33 | beforeEach(async () => { 34 | const fetch = require('node-fetch'); 35 | fetch.mockImplementation((url: string, params) => { 36 | if (url.includes('log_event')) { 37 | events = events.concat(getDecodedBody(params)['events']); 38 | return Promise.resolve({ 39 | ok: true, 40 | }); 41 | } 42 | return Promise.resolve({ 43 | ok: true, 44 | text: () => Promise.resolve('{}'), 45 | }); 46 | }); 47 | 48 | events = []; 49 | Diagnostics.initialize({ logger }); 50 | Diagnostics.instance.setSamplingRate({ 51 | dcs: 5000, 52 | log: 5000, 53 | idlist: 5000, 54 | initialize: 5000, 55 | api_call: 5000 56 | }) 57 | }); 58 | 59 | it.each(['initialize', 'config_sync', 'event_logging'] as ContextType[])( 60 | 'test .mark() %s', 61 | async (context: ContextType) => { 62 | assertMarkersEmpty(); 63 | Diagnostics.setContext(context); 64 | Diagnostics.mark.downloadConfigSpecs.process.start(); 65 | expect(getMarkers().initialize).toHaveLength( 66 | context === 'initialize' ? 1 : 0, 67 | ); 68 | expect(getMarkers().config_sync).toHaveLength( 69 | context === 'config_sync' ? 1 : 0, 70 | ); 71 | expect(getMarkers().event_logging).toHaveLength( 72 | context === 'event_logging' ? 1 : 0, 73 | ); 74 | }, 75 | ); 76 | 77 | it('test .logDiagnostics()', async () => { 78 | assertMarkersEmpty(); 79 | 80 | let time = 1; 81 | jest.spyOn(Date, 'now').mockImplementation(() => { 82 | return time++; 83 | }); 84 | 85 | Diagnostics.setContext('initialize'); 86 | Diagnostics.mark.downloadConfigSpecs.process.start({}); 87 | Diagnostics.setContext('config_sync'); 88 | Diagnostics.mark.downloadConfigSpecs.process.start({}); 89 | 90 | const assertLogDiagnostics = async ( 91 | context: ContextType, 92 | expectedTime: number, 93 | ) => { 94 | Diagnostics.logDiagnostics(context); 95 | await logger.flush() 96 | expect(events).toHaveLength(1); 97 | expect(events[0].eventName).toBe('statsig::diagnostics'); 98 | expect(events[0].metadata['context']).toEqual(context); 99 | expect(events[0].metadata['markers'][0]['timestamp']).toEqual( 100 | expectedTime, 101 | ); 102 | if(context == "initialize") { 103 | expect(events[0].metadata['statsigOptions']).toEqual({ 104 | loggingMaxBufferSize: 1 105 | }) 106 | } else { 107 | expect(events[0].metadata['statsigOptions']).toBeUndefined() 108 | } 109 | events = []; 110 | }; 111 | 112 | await assertLogDiagnostics('initialize', 1); 113 | await assertLogDiagnostics('config_sync', 2); 114 | }); 115 | 116 | const types = ['initialize', 'id_list', 'config_spec', 'api_call'] as const; 117 | it.each(types)('test sampling rate for %s', async (type) => { 118 | const context: ContextType = 119 | type ==='api_call'? 'api_call': (type === 'initialize' ? 'initialize' : 'config_sync'); 120 | for (let i = 0; i < 1000; i++) { 121 | Diagnostics.mark.downloadConfigSpecs.networkRequest.start({}, context); 122 | Diagnostics.logDiagnostics(context, { 123 | type: type, 124 | }); 125 | } 126 | await logger.flush() 127 | expect(events.length).toBeGreaterThan(400); 128 | expect(events.length).toBeLessThan(600); 129 | }); 130 | }); 131 | 132 | function assertMarkersEmpty() { 133 | expect(getMarkers().initialize).toHaveLength(0); 134 | expect(getMarkers().config_sync).toHaveLength(0); 135 | expect(getMarkers().event_logging).toHaveLength(0); 136 | } 137 | -------------------------------------------------------------------------------- /src/__tests__/DiagnosticsCoreAPI.test.ts: -------------------------------------------------------------------------------- 1 | import { MAX_MARKER_COUNT, MAX_SAMPLING_RATE } from '../Diagnostics'; 2 | import Statsig from '../index'; 3 | import StatsigInstanceUtils from '../StatsigInstanceUtils'; 4 | import exampleConfigSpecs from './jest.setup'; 5 | import { assertMarkerEqual, getDecodedBody } from './StatsigTestUtils'; 6 | 7 | jest.mock('node-fetch', () => jest.fn()); 8 | 9 | const CONFIG_SPEC_RESPONSE = { 10 | time: Date.now(), 11 | feature_gates: [exampleConfigSpecs.gate, exampleConfigSpecs.disabled_gate], 12 | dynamic_configs: [exampleConfigSpecs.config], 13 | layer_configs: [exampleConfigSpecs.allocated_layer], 14 | has_updates: true, 15 | diagnostics: { 16 | dcs: MAX_SAMPLING_RATE, 17 | log: MAX_SAMPLING_RATE, 18 | idlist: MAX_SAMPLING_RATE, 19 | initialize: MAX_SAMPLING_RATE, 20 | api_call: MAX_SAMPLING_RATE, 21 | }, 22 | }; 23 | 24 | describe('CoreAPIDiagnostics', () => { 25 | let events: { 26 | eventName: string; 27 | metadata: { gate?: string; config?: string; isManualExposure?: string }; 28 | }[] = []; 29 | 30 | let getIDListJSON; 31 | let downloadConfigSpecsResponse; 32 | 33 | beforeEach(async () => { 34 | getIDListJSON = {}; 35 | downloadConfigSpecsResponse = Promise.resolve({ 36 | ok: true, 37 | json: () => Promise.resolve(CONFIG_SPEC_RESPONSE), 38 | text: () => Promise.resolve(JSON.stringify(CONFIG_SPEC_RESPONSE)), 39 | status: 200, 40 | }); 41 | const fetch = require('node-fetch'); 42 | 43 | fetch.mockImplementation((url: string, params) => { 44 | if (url.includes('download_config_specs')) { 45 | return Promise.resolve(downloadConfigSpecsResponse); 46 | } 47 | 48 | if (url.includes('get_id_lists')) { 49 | return Promise.resolve({ 50 | ok: true, 51 | status: 200, 52 | json: () => Promise.resolve(getIDListJSON), 53 | }); 54 | } 55 | 56 | if (url.includes('log_event')) { 57 | events = events.concat(getDecodedBody(params)['events']); 58 | return Promise.resolve({ 59 | ok: true, 60 | }); 61 | } 62 | 63 | if (url.includes('id_list_content')) { 64 | let wholeList = ''; 65 | for (let i = 1; i <= 5; i++) { 66 | wholeList += `+${i}\n`; 67 | } 68 | const startingIndex = parseInt( 69 | // @ts-ignore 70 | /=(.*)-/.exec(params['headers']['Range'])[1], 71 | ); 72 | return Promise.resolve({ 73 | ok: true, 74 | status: 200, 75 | text: () => Promise.resolve(wholeList.slice(startingIndex)), 76 | headers: { 77 | get: jest.fn((v) => { 78 | if (v.toLowerCase() === 'content-length') { 79 | return 15 - startingIndex; 80 | } 81 | }), 82 | }, 83 | }); 84 | } 85 | 86 | return Promise.resolve({ 87 | ok: true, 88 | text: () => Promise.resolve('{}'), 89 | }); 90 | }); 91 | 92 | events = []; 93 | // @ts-ignore 94 | StatsigInstanceUtils.setInstance(null); 95 | }); 96 | 97 | // Always initialization data even when diagnostics disabled 98 | it.each([true, false])('test core api', async (disableDiagnostics) => { 99 | await Statsig.initialize('secret-key', { 100 | disableDiagnostics, 101 | }); 102 | const user = { 103 | userID: 'testUser', 104 | }; 105 | Statsig.checkGate(user, 'nfl_gate'); 106 | Statsig.getExperiment(user, 'teams'); 107 | Statsig.getConfig(user, 'teams'); 108 | Statsig.getLayer(user, 'unallocated_layer'); 109 | await Statsig.shutdownAsync(); 110 | events = events.filter( 111 | (event) => event.eventName === 'statsig::diagnostics', 112 | ); 113 | if (disableDiagnostics) { 114 | expect(events.length).toBe(1); 115 | expect(events[0].metadata['context']).toBe('initialize'); 116 | return; 117 | } 118 | expect(events.length).toBe(2); 119 | expect(events[1].metadata['context']).toBe('api_call'); 120 | const markers = events[1].metadata.markers; 121 | expect(markers.length).toBe(8); 122 | assertMarkerEqual(markers[0], { 123 | key: 'check_gate', 124 | action: 'start', 125 | markerID: 'checkGate_0', 126 | configName: 'nfl_gate', 127 | }); 128 | assertMarkerEqual(markers[1], { 129 | key: 'check_gate', 130 | action: 'end', 131 | markerID: 'checkGate_0', 132 | configName: 'nfl_gate', 133 | success: true, 134 | }); 135 | assertMarkerEqual(markers[2], { 136 | key: 'get_experiment', 137 | action: 'start', 138 | markerID: 'getExperiment_2', 139 | configName: 'teams', 140 | }); 141 | assertMarkerEqual(markers[3], { 142 | key: 'get_experiment', 143 | action: 'end', 144 | markerID: 'getExperiment_2', 145 | configName: 'teams', 146 | success: true, 147 | }); 148 | assertMarkerEqual(markers[4], { 149 | key: 'get_config', 150 | action: 'start', 151 | markerID: 'getConfig_4', 152 | configName: 'teams', 153 | }); 154 | assertMarkerEqual(markers[5], { 155 | key: 'get_config', 156 | action: 'end', 157 | markerID: 'getConfig_4', 158 | configName: 'teams', 159 | success: true, 160 | }); 161 | assertMarkerEqual(markers[6], { 162 | key: 'get_layer', 163 | action: 'start', 164 | markerID: 'getLayer_6', 165 | configName: 'unallocated_layer', 166 | }); 167 | assertMarkerEqual(markers[7], { 168 | key: 'get_layer', 169 | action: 'end', 170 | markerID: 'getLayer_6', 171 | success: true, 172 | configName: 'unallocated_layer', 173 | }); 174 | }); 175 | 176 | it('test max_markers', async () => { 177 | await Statsig.initialize('secret-key', { 178 | loggingMaxBufferSize: 1000, 179 | rulesetsSyncIntervalMs: 1000, 180 | disableDiagnostics: false, 181 | }); 182 | const user = { userID: 'test_user' }; 183 | for (let i = 0; i < MAX_MARKER_COUNT * 4; i++) { 184 | Statsig.checkGate(user, 'a_gate'); 185 | } 186 | await Statsig.shutdownAsync(); 187 | events = events.filter((e) => e['metadata']['context'] === 'api_call'); 188 | 189 | expect(events.length).toBe(1); 190 | const event = events[0]; 191 | expect(event['eventName']).toBe('statsig::diagnostics'); 192 | 193 | const markers = event['metadata']['markers']; 194 | expect(markers.length).toBe(MAX_MARKER_COUNT); 195 | }); 196 | }); 197 | -------------------------------------------------------------------------------- /src/__tests__/ErrorBoundary.test.ts: -------------------------------------------------------------------------------- 1 | import { AdapterResponse, IDataAdapter } from 'statsig-node'; 2 | import ErrorBoundary, { ExceptionEndpoint } from '../ErrorBoundary'; 3 | import { 4 | StatsigInvalidArgumentError, 5 | StatsigTooManyRequestsError, 6 | StatsigUninitializedError, 7 | } from '../Errors'; 8 | import { InitStrategy, OptionsLoggingCopy } from '../StatsigOptions'; 9 | import { getStatsigMetadata } from '../utils/core'; 10 | import { StatsigContext } from '../utils/StatsigContext'; 11 | import { getDecodedBody } from './StatsigTestUtils'; 12 | jest.mock('node-fetch', () => jest.fn()); 13 | const TestDataAdapter: IDataAdapter = { 14 | get(key: string): Promise { 15 | return new Promise(() => {}); 16 | }, 17 | set(key: string, value: string, time?: number): Promise { 18 | return new Promise(() => {}); 19 | }, 20 | shutdown(): Promise { 21 | return new Promise(() => {}); 22 | }, 23 | initialize(): Promise { 24 | return new Promise(() => {}); 25 | }, 26 | }; 27 | describe('ErrorBoundary', () => { 28 | let boundary: ErrorBoundary; 29 | let requests: { url: RequestInfo; params: RequestInit }[] = []; 30 | 31 | beforeEach(() => { 32 | const options = { 33 | environment: { tier: 'staging' }, 34 | initStrategyForIP3Country: 'await' as InitStrategy, 35 | rulesetsSyncIntervalMs: 30000, 36 | dataAdapter: TestDataAdapter, 37 | api: 'www.google.com', 38 | disableDiagnostics: true, 39 | }; 40 | boundary = new ErrorBoundary( 41 | 'secret-key', 42 | OptionsLoggingCopy(options), 43 | 'sessionID', 44 | ); 45 | requests = []; 46 | 47 | const fetch = require('node-fetch'); 48 | fetch.mockImplementation((url: RequestInfo, params: RequestInit) => { 49 | requests.push({ url: url.toString(), params }); 50 | return Promise.resolve(); 51 | }); 52 | }); 53 | 54 | it('recovers from error and returns result', () => { 55 | let called = false; 56 | const result = boundary.capture( 57 | () => { 58 | throw new URIError(); 59 | }, 60 | () => { 61 | called = true; 62 | return 'called'; 63 | }, 64 | StatsigContext.new({}), 65 | ); 66 | 67 | expect(called).toBe(true); 68 | expect(result).toEqual('called'); 69 | }); 70 | 71 | it('recovers from error and returns result', async () => { 72 | const result = await boundary.capture( 73 | () => Promise.reject(Error('bad')), 74 | () => Promise.resolve('good'), 75 | StatsigContext.new({}), 76 | ); 77 | 78 | expect(result).toEqual('good'); 79 | }); 80 | 81 | it('returns successful results when there is no crash', async () => { 82 | const result = await boundary.capture( 83 | () => Promise.resolve('success'), 84 | () => Promise.resolve('failure'), 85 | StatsigContext.new({}), 86 | ); 87 | 88 | expect(result).toEqual('success'); 89 | }); 90 | 91 | it('logs errors correctly', () => { 92 | const err = new URIError(); 93 | boundary.swallow(() => { 94 | throw err; 95 | }, StatsigContext.new({})); 96 | 97 | expect(requests[0].url).toEqual(ExceptionEndpoint); 98 | 99 | expect(getDecodedBody(requests[0].params)).toEqual( 100 | expect.objectContaining({ 101 | exception: 'URIError', 102 | info: err.stack, 103 | }), 104 | ); 105 | }); 106 | 107 | it('logs error-ish correctly', () => { 108 | const err = { 'sort-of-an-error': 'but-not-really' }; 109 | boundary.swallow(() => { 110 | throw err; 111 | }, StatsigContext.new({})); 112 | 113 | expect(requests[0].url).toEqual(ExceptionEndpoint); 114 | expect(getDecodedBody(requests[0].params)).toEqual( 115 | expect.objectContaining({ 116 | exception: 'No Name', 117 | info: JSON.stringify(err), 118 | }), 119 | ); 120 | }); 121 | 122 | it('logs the correct headers', () => { 123 | boundary.swallow(() => { 124 | throw new Error(); 125 | }, StatsigContext.new({})); 126 | 127 | const metadata = getStatsigMetadata(); 128 | expect(requests[0].params['headers']).toEqual( 129 | expect.objectContaining({ 130 | 'STATSIG-API-KEY': 'secret-key', 131 | 'STATSIG-SDK-TYPE': metadata.sdkType, 132 | 'STATSIG-SDK-VERSION': metadata.sdkVersion, 133 | 'Content-Type': 'application/json', 134 | }), 135 | ); 136 | }); 137 | 138 | it('logs statsig metadata and options', () => { 139 | boundary.swallow(() => { 140 | throw new Error(); 141 | }, StatsigContext.new({})); 142 | 143 | expect(getDecodedBody(requests[0].params)).toEqual( 144 | expect.objectContaining({ 145 | statsigMetadata: { ...getStatsigMetadata(), sessionID: 'sessionID' }, 146 | }), 147 | ); 148 | 149 | expect(getDecodedBody(requests[0].params)).toEqual( 150 | expect.objectContaining({ 151 | statsigOptions: { 152 | environment: { tier: 'staging' }, 153 | initStrategyForIP3Country: 'await', 154 | rulesetsSyncIntervalMs: 30000, 155 | dataAdapter: 'set', 156 | api: 'www.google.com', 157 | disableDiagnostics: true, 158 | }, 159 | }), 160 | ); 161 | }); 162 | 163 | it('logs the same error only once', () => { 164 | boundary.swallow(() => { 165 | throw new Error(); 166 | }, StatsigContext.new({})); 167 | 168 | expect(requests.length).toEqual(1); 169 | 170 | boundary.swallow(() => { 171 | throw new Error(); 172 | }, StatsigContext.new({})); 173 | 174 | expect(requests.length).toEqual(1); 175 | }); 176 | 177 | it('does not catch intended errors', () => { 178 | expect(() => { 179 | boundary.swallow(() => { 180 | throw new StatsigUninitializedError(); 181 | }, StatsigContext.new({})); 182 | }).toThrow('Call and wait for initialize() to finish first.'); 183 | 184 | expect(() => { 185 | boundary.swallow(() => { 186 | throw new StatsigInvalidArgumentError('bad arg'); 187 | }, StatsigContext.new({})); 188 | }).toThrow('bad arg'); 189 | 190 | expect(() => { 191 | boundary.swallow(() => { 192 | throw new StatsigTooManyRequestsError('slow down'); 193 | }, StatsigContext.new({})); 194 | }).toThrow('slow down'); 195 | }); 196 | }); 197 | -------------------------------------------------------------------------------- /src/__tests__/EvalCallback.test.ts: -------------------------------------------------------------------------------- 1 | import Statsig, { DynamicConfig, FeatureGate, Layer } from '../index'; 2 | import StatsigInstanceUtils from '../StatsigInstanceUtils'; 3 | 4 | jest.mock('node-fetch', () => jest.fn()); 5 | 6 | const CONFIG_SPEC_RESPONSE = JSON.stringify( 7 | require('./data/download_config_specs_group_name_test.json'), 8 | ); 9 | 10 | describe('Eval Callback', () => { 11 | let gateCount = 0; 12 | let configCount = 0; 13 | let layerCount = 0; 14 | beforeEach(async () => { 15 | const fetch = require('node-fetch'); 16 | fetch.mockImplementation(() => { 17 | return Promise.resolve({ 18 | ok: true, 19 | text: () => Promise.resolve(CONFIG_SPEC_RESPONSE), 20 | }); 21 | }); 22 | gateCount = 0; 23 | configCount = 0; 24 | layerCount = 0; 25 | StatsigInstanceUtils.setInstance(null); 26 | await Statsig.initialize('secret-key', { 27 | disableDiagnostics: true, 28 | evaluationCallback: (config) => { 29 | if (config instanceof DynamicConfig) { 30 | configCount++; 31 | } else if (config instanceof Layer) { 32 | layerCount++; 33 | } else { 34 | gateCount++; 35 | } 36 | }, 37 | }); 38 | }); 39 | 40 | describe('getFeatureGate', () => { 41 | it('Calls callback when gate found', async () => { 42 | const user = { userID: 'a-user' }; 43 | Statsig.getFeatureGate(user, 'test_many_rules'); 44 | expect(gateCount).toBe(1); 45 | }); 46 | 47 | it('Calls callback when gate not found', async () => { 48 | const user = { userID: 'a-user' }; 49 | Statsig.getFeatureGate(user, 'not_a_valid_gate'); 50 | expect(gateCount).toBe(1); 51 | }); 52 | 53 | it('Calls callback for checkGate', async () => { 54 | const user = { userID: 'a-user' }; 55 | Statsig.checkGate(user, 'test_many_rules'); 56 | expect(gateCount).toBe(1); 57 | }); 58 | }); 59 | 60 | describe('getConfig', () => { 61 | it('Calls callback when config found', async () => { 62 | const user = { userID: 'a-user' }; 63 | Statsig.getConfig(user, 'disabled_config'); 64 | expect(configCount).toBe(1); 65 | }); 66 | 67 | it('Calls callback when config not found', async () => { 68 | const user = { userID: 'a-user' }; 69 | Statsig.getConfig(user, 'fake_config'); 70 | expect(configCount).toBe(1); 71 | }); 72 | }); 73 | 74 | describe('getExperiment', () => { 75 | it('Calls callback when experiment found', async () => { 76 | const user = { userID: 'a-user' }; 77 | Statsig.getExperiment(user, 'experiment_with_many_params'); 78 | expect(configCount).toBe(1); 79 | }); 80 | 81 | it('Calls callback when experiment not found', async () => { 82 | const user = { userID: 'a-user' }; 83 | Statsig.getExperiment(user, 'fake_exp'); 84 | expect(configCount).toBe(1); 85 | }); 86 | }); 87 | 88 | describe('getLayer', () => { 89 | it('Calls callback when layer found', async () => { 90 | const user = { userID: 'a-user' }; 91 | Statsig.getLayer(user, 'layer_with_many_params'); 92 | expect(layerCount).toBe(1); 93 | }); 94 | 95 | it('Calls callback when layer not found', async () => { 96 | const user = { userID: 'a-user' }; 97 | Statsig.getLayer(user, 'fake_layer'); 98 | expect(layerCount).toBe(1); 99 | }); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /src/__tests__/EvalCallbacks.test.ts: -------------------------------------------------------------------------------- 1 | import Statsig, { DynamicConfig, FeatureGate, Layer } from '../index'; 2 | import StatsigInstanceUtils from '../StatsigInstanceUtils'; 3 | 4 | jest.mock('node-fetch', () => jest.fn()); 5 | 6 | const CONFIG_SPEC_RESPONSE = JSON.stringify( 7 | require('./data/download_config_specs_group_name_test.json'), 8 | ); 9 | 10 | describe('Eval Callbacks', () => { 11 | let gateCount = 0; 12 | let configCount = 0; 13 | let experimentCount = 0; 14 | let layerCount = 0; 15 | let layerParamCount = 0; 16 | beforeEach(async () => { 17 | const fetch = require('node-fetch'); 18 | fetch.mockImplementation(() => { 19 | return Promise.resolve({ 20 | ok: true, 21 | text: () => Promise.resolve(CONFIG_SPEC_RESPONSE), 22 | }); 23 | }); 24 | gateCount = 0; 25 | configCount = 0; 26 | experimentCount = 0; 27 | layerCount = 0; 28 | layerParamCount = 0; 29 | StatsigInstanceUtils.setInstance(null); 30 | await Statsig.initialize('secret-key', { 31 | disableDiagnostics: true, 32 | evaluationCallbacks: { 33 | gateCallback: (gate, user, event) => { 34 | gateCount++; 35 | }, 36 | dynamicConfigCallback: (config, user, event) => { 37 | configCount++; 38 | }, 39 | experimentCallback: (config, user, event) => { 40 | experimentCount++; 41 | }, 42 | layerCallback: (layer, user) => { 43 | layerCount++; 44 | }, 45 | layerParamCallback(layer, paramName, user, event) { 46 | layerParamCount++; 47 | }, 48 | }, 49 | }); 50 | }); 51 | 52 | describe('getFeatureGate', () => { 53 | it('Calls callback when gate found', async () => { 54 | const user = { userID: 'a-user' }; 55 | Statsig.getFeatureGate(user, 'test_many_rules'); 56 | expect(gateCount).toBe(1); 57 | }); 58 | 59 | it('Calls callback when gate not found', async () => { 60 | const user = { userID: 'a-user' }; 61 | Statsig.getFeatureGate(user, 'not_a_valid_gate'); 62 | expect(gateCount).toBe(1); 63 | }); 64 | 65 | it('Calls callback for checkGate', async () => { 66 | const user = { userID: 'a-user' }; 67 | Statsig.checkGate(user, 'test_many_rules'); 68 | expect(gateCount).toBe(1); 69 | }); 70 | }); 71 | 72 | describe('getConfig', () => { 73 | it('Calls callback when config found', async () => { 74 | const user = { userID: 'a-user' }; 75 | Statsig.getConfig(user, 'disabled_config'); 76 | expect(configCount).toBe(1); 77 | }); 78 | 79 | it('Calls callback when config not found', async () => { 80 | const user = { userID: 'a-user' }; 81 | Statsig.getConfig(user, 'fake_config'); 82 | expect(configCount).toBe(1); 83 | }); 84 | }); 85 | 86 | describe('getExperiment', () => { 87 | it('Calls callback when experiment found', async () => { 88 | const user = { userID: 'a-user' }; 89 | Statsig.getExperiment(user, 'experiment_with_many_params'); 90 | expect(experimentCount).toBe(1); 91 | }); 92 | 93 | it('Calls callback when experiment not found', async () => { 94 | const user = { userID: 'a-user' }; 95 | Statsig.getExperiment(user, 'fake_exp'); 96 | expect(experimentCount).toBe(1); 97 | }); 98 | }); 99 | 100 | describe('getLayer', () => { 101 | it('Calls callback when layer found', async () => { 102 | const user = { userID: 'a-user' }; 103 | const layer = Statsig.getLayer(user, 'layer_with_many_params'); 104 | expect(layerCount).toBe(1); 105 | layer.get('a_string', ''); 106 | expect(layerParamCount).toBe(1); 107 | }); 108 | 109 | it('Calls callback when layer not found', async () => { 110 | const user = { userID: 'a-user' }; 111 | Statsig.getLayer(user, 'fake_layer'); 112 | expect(layerCount).toBe(1); 113 | }); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /src/__tests__/Exports.test.ts: -------------------------------------------------------------------------------- 1 | import * as Mod from 'statsig-node'; 2 | 3 | describe('Module Exports', () => { 4 | test.each([ 5 | 'StatsigUninitializedError', 6 | 'StatsigInvalidArgumentError', 7 | 'StatsigTooManyRequestsError', 8 | 'StatsigLocalModeNetworkError', 9 | 'DynamicConfig', 10 | 'Layer', 11 | ])('%p', (key) => { 12 | expect(Mod.default[key]).toBeDefined(); 13 | expect(Mod[key]).toBeDefined(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/__tests__/FlushTimer.test.ts: -------------------------------------------------------------------------------- 1 | import * as statsigsdk from '../index'; 2 | import StatsigInstanceUtils from '../StatsigInstanceUtils'; 3 | import StatsigTestUtils, { getDecodedBody } from './StatsigTestUtils'; 4 | // @ts-ignore 5 | const statsig = statsigsdk.default; 6 | 7 | const CONFIG_SPEC_RESPONSE = JSON.stringify( 8 | require('./data/download_config_spec.json'), 9 | ); 10 | 11 | const INIT_RESPONSE = require('./data/initialize_response.json'); 12 | let postedLogs = { 13 | events: [], 14 | }; 15 | 16 | jest.mock('node-fetch', () => jest.fn()); 17 | // @ts-ignore 18 | const fetch = require('node-fetch'); 19 | // @ts-ignore 20 | fetch.mockImplementation((url, params) => { 21 | if (url.includes('download_config_specs')) { 22 | return Promise.resolve({ 23 | ok: true, 24 | text: () => Promise.resolve(CONFIG_SPEC_RESPONSE), 25 | }); 26 | } 27 | if (url.includes('log_event')) { 28 | postedLogs = JSON.parse(getDecodedBody(params)); 29 | return Promise.resolve({ 30 | ok: true, 31 | }); 32 | } 33 | if (url.includes('get_id_lists')) { 34 | return Promise.resolve({ 35 | ok: true, 36 | json: () => Promise.resolve({}), 37 | }); 38 | } 39 | return Promise.reject(); 40 | }); 41 | 42 | describe('Verify e2e behavior of the SDK with mocked network', () => { 43 | jest.mock('node-fetch', () => jest.fn()); 44 | const statsigUser = { 45 | userID: '123', 46 | email: 'testuser@statsig.com', 47 | }; 48 | 49 | beforeEach(() => { 50 | jest.restoreAllMocks(); 51 | jest.resetModules(); 52 | jest.useFakeTimers(); 53 | 54 | StatsigInstanceUtils.setInstance(null); 55 | postedLogs = { 56 | events: [], 57 | }; 58 | }); 59 | 60 | test('Verify checkGate and exposure logs', async () => { 61 | expect.assertions(2); 62 | await statsig.initialize('secret-123'); 63 | 64 | const logger = StatsigTestUtils.getLogger(); 65 | const spy = jest.spyOn(logger['fetcher'], 'post'); 66 | 67 | const on1 = await statsig.checkGate(statsigUser, 'always_on_gate'); 68 | expect(on1).toEqual(true); 69 | // trigger the flush 70 | jest.runOnlyPendingTimers(); 71 | expect(spy).toHaveBeenCalled(); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/__tests__/FlushWithTimeout.test.ts: -------------------------------------------------------------------------------- 1 | import Statsig from '../index'; 2 | import { StatsigUser } from '../index'; 3 | import { parseLogEvents } from './StatsigTestUtils'; 4 | 5 | jest.mock('node-fetch', () => jest.fn()); 6 | 7 | const clearStatsig = () => { 8 | const instance = require('../StatsigInstanceUtils').default.getInstance(); 9 | if (instance) { 10 | instance.shutdown(); 11 | require('../StatsigInstanceUtils').default.setInstance(null); 12 | } 13 | }; 14 | 15 | describe('Statsig flush() method with timeout', () => { 16 | let logs: { eventName: string; metadata?: { gate?: string; config?: string } }[] = []; 17 | const user: StatsigUser = { userID: 'a-user' }; 18 | 19 | beforeEach(() => { 20 | logs = []; 21 | const fetch = require('node-fetch'); 22 | fetch.mockImplementation((url: string, params) => { 23 | if (url.includes('log_event')) { 24 | logs.push(...parseLogEvents(params)['events']); 25 | return Promise.resolve({ 26 | ok: false, 27 | status: 500, 28 | }); 29 | } 30 | return Promise.resolve({ 31 | ok: true, 32 | text: () => Promise.resolve('{}'), 33 | }); 34 | }); 35 | clearStatsig(); 36 | }); 37 | 38 | it('does not retry on failure', async () => { 39 | await Statsig.initialize('secret-key', { disableDiagnostics: true }); 40 | Statsig.logEvent(user, 'test-event'); 41 | await Statsig.flush(3000); 42 | expect(logs).toHaveLength(1); 43 | }); 44 | 45 | it('cancels if exceeds timeout', async () => { 46 | const fetch = require('node-fetch'); 47 | fetch.mockImplementation((url: string, params) => { 48 | if (url.includes('log_event')) { 49 | return new Promise((resolve) => setTimeout(() => resolve({ ok: true }), 2000)); 50 | } 51 | return Promise.resolve({ 52 | ok: true, 53 | text: () => Promise.resolve('{}'), 54 | }); 55 | }); 56 | 57 | await Statsig.initialize('secret-key', { disableDiagnostics: true }); 58 | Statsig.logEvent(user, 'test-event'); 59 | const flushPromise = Statsig.flush(1000); 60 | await expect(flushPromise).resolves.toBeUndefined(); 61 | expect(logs).toHaveLength(0); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/__tests__/InitDetails.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | StatsigInitializeFromNetworkError, 3 | StatsigInvalidBootstrapValuesError, 4 | StatsigInvalidDataAdapterValuesError, 5 | } from '../Errors'; 6 | import { DataAdapterKeyPath, getDataAdapterKey } from '../interfaces/IDataAdapter'; 7 | import Statsig from '../index'; 8 | import StatsigInstanceUtils from '../StatsigInstanceUtils'; 9 | import TestDataAdapter from './TestDataAdapter'; 10 | import SpecStore from '../SpecStore'; 11 | import { sha256HashBase64 } from '../utils/Hashing'; 12 | 13 | jest.mock('node-fetch', () => jest.fn()); 14 | 15 | const CONFIG_SPEC_RESPONSE = JSON.stringify( 16 | require('./data/eval_details_download_config_specs.json'), 17 | ); 18 | 19 | describe('InitDetails', () => { 20 | const network500Error = new Error( 21 | 'Request to https://api.statsigcdn.com/v1/download_config_specs/secret-key.json?sinceTime=0 failed with status 500', 22 | ); 23 | let dcsStatus: number = 200; 24 | let sdkKey = "secret-key" 25 | let dcsDataAdapterKey = getDataAdapterKey(sha256HashBase64(sdkKey), DataAdapterKeyPath.V1Rulesets) 26 | 27 | beforeEach(async () => { 28 | const fetch = require('node-fetch'); 29 | 30 | fetch.mockImplementation((url: string) => { 31 | if (url.includes('download_config_specs')) { 32 | return new Promise((res) => { 33 | setTimeout( 34 | () => 35 | res({ 36 | ok: dcsStatus >= 200 && dcsStatus < 300, 37 | text: () => Promise.resolve(CONFIG_SPEC_RESPONSE), 38 | status: dcsStatus, 39 | }), 40 | 100, 41 | ); 42 | }); 43 | } 44 | 45 | return Promise.resolve({ 46 | ok: true, 47 | text: () => Promise.resolve('{}'), 48 | }); 49 | }); 50 | }); 51 | 52 | afterEach(() => { 53 | StatsigInstanceUtils.setInstance(null); 54 | dcsStatus = 200; 55 | }); 56 | 57 | it('Network - success', async () => { 58 | const res = await Statsig.initialize(sdkKey); 59 | 60 | expect(res.success).toEqual(true); 61 | expect(res.error).toBeUndefined(); 62 | expect(res.source).toEqual('Network'); 63 | expect(res.duration).toEqual(expect.any(Number)); 64 | }); 65 | 66 | it('Network - failure', async () => { 67 | dcsStatus = 500; 68 | const res = await Statsig.initialize(sdkKey); 69 | 70 | expect(res.success).toEqual(false); 71 | expect(res.error).toEqual( 72 | new StatsigInitializeFromNetworkError(network500Error), 73 | ); 74 | expect(res.source).toBeUndefined(); 75 | expect(res.duration).toEqual(expect.any(Number)); 76 | }); 77 | 78 | it('Network - timeout', async () => { 79 | const res = await Statsig.initialize(sdkKey, { initTimeoutMs: 1 }); 80 | 81 | expect(res.success).toEqual(false); 82 | expect(res.error).toEqual(new Error('Timed out waiting for initialize')); 83 | expect(res.source).toBeUndefined(); 84 | expect(res.duration).toEqual(expect.any(Number)); 85 | }); 86 | 87 | it('Bootstrap - success', async () => { 88 | const res = await Statsig.initialize(sdkKey, { 89 | bootstrapValues: CONFIG_SPEC_RESPONSE, 90 | }); 91 | 92 | expect(res.success).toEqual(true); 93 | expect(res.error).toBeUndefined(); 94 | expect(res.source).toEqual('Bootstrap'); 95 | expect(res.duration).toEqual(expect.any(Number)); 96 | }); 97 | 98 | it('Bootstrap - failure (fallback to network success)', async () => { 99 | const res = await Statsig.initialize(sdkKey, { 100 | bootstrapValues: '', 101 | }); 102 | 103 | expect(res.success).toEqual(true); 104 | expect(res.error).toEqual(new StatsigInvalidBootstrapValuesError()); 105 | expect(res.source).toEqual('Network'); 106 | expect(res.duration).toEqual(expect.any(Number)); 107 | }); 108 | 109 | it('Bootstrap - failure (fallback to network failure)', async () => { 110 | dcsStatus = 500; 111 | const res = await Statsig.initialize(sdkKey, { 112 | bootstrapValues: '', 113 | }); 114 | 115 | expect(res.success).toEqual(false); 116 | expect(res.error).toEqual( 117 | new StatsigInitializeFromNetworkError(network500Error), 118 | ); 119 | expect(res.source).toBeUndefined(); 120 | expect(res.duration).toEqual(expect.any(Number)); 121 | }); 122 | 123 | it('Data Adapter - success', async () => { 124 | const dataAdapter = new TestDataAdapter(); 125 | const hashedSDKkey = sha256HashBase64("secret-key") 126 | await dataAdapter.set(getDataAdapterKey(hashedSDKkey, DataAdapterKeyPath.V1Rulesets), CONFIG_SPEC_RESPONSE); 127 | const res = await Statsig.initialize(sdkKey, { 128 | dataAdapter: dataAdapter, 129 | }); 130 | 131 | expect(res.success).toEqual(true); 132 | expect(res.error).toBeUndefined(); 133 | expect(res.source).toEqual('DataAdapter'); 134 | expect(res.duration).toEqual(expect.any(Number)); 135 | }); 136 | 137 | it('Data Adapter - failure (fallback to network success)', async () => { 138 | const dataAdapter = new TestDataAdapter(); 139 | await dataAdapter.set(dcsDataAdapterKey, ''); 140 | const res = await Statsig.initialize(sdkKey, { 141 | dataAdapter: dataAdapter, 142 | }); 143 | 144 | expect(res.success).toEqual(true); 145 | expect(res.error).toEqual( 146 | new StatsigInvalidDataAdapterValuesError(DataAdapterKeyPath.V1Rulesets), 147 | ); 148 | expect(res.source).toEqual('Network'); 149 | expect(res.duration).toEqual(expect.any(Number)); 150 | }); 151 | 152 | it('Data Adapter - failure (fallback to network failure)', async () => { 153 | dcsStatus = 500; 154 | const dataAdapter = new TestDataAdapter(); 155 | await dataAdapter.set(dcsDataAdapterKey, ''); 156 | const res = await Statsig.initialize(sdkKey, { 157 | dataAdapter: dataAdapter, 158 | }); 159 | 160 | expect(res.success).toEqual(false); 161 | expect(res.error).toEqual( 162 | new StatsigInitializeFromNetworkError(network500Error), 163 | ); 164 | expect(res.source).toBeUndefined(); 165 | expect(res.duration).toEqual(expect.any(Number)); 166 | }); 167 | 168 | it('Internal Error', async () => { 169 | jest 170 | .spyOn(SpecStore.prototype, 'init') 171 | .mockImplementation(async () => 172 | Promise.reject(new Error('Something bad happened..')), 173 | ); 174 | const res = await Statsig.initialize(sdkKey); 175 | 176 | expect(res.success).toEqual(false); 177 | expect(res.error).toEqual(new Error('Something bad happened..')); 178 | expect(res.source).toBeUndefined(); 179 | expect(res.duration).toEqual(expect.any(Number)); 180 | }); 181 | }); 182 | -------------------------------------------------------------------------------- /src/__tests__/InitTimeout.test.ts: -------------------------------------------------------------------------------- 1 | import * as statsigsdk from '../index'; 2 | import StatsigInstanceUtils from '../StatsigInstanceUtils'; 3 | import { getDecodedBody } from './StatsigTestUtils'; 4 | // @ts-ignore 5 | const statsig = statsigsdk.default; 6 | 7 | const exampleConfigSpecs = require('./jest.setup'); 8 | 9 | jest.mock('node-fetch', () => jest.fn()); 10 | // @ts-ignore 11 | const fetch = require('node-fetch'); 12 | 13 | const jsonResponse = { 14 | time: Date.now(), 15 | feature_gates: [exampleConfigSpecs.gate, exampleConfigSpecs.disabled_gate], 16 | dynamic_configs: [exampleConfigSpecs.config], 17 | layer_configs: [], 18 | id_lists: {}, 19 | has_updates: true, 20 | }; 21 | 22 | let events: { 23 | eventName: string; 24 | metadata: { gate?: string; config?: string; isManualExposure?: string }; 25 | }[] = []; 26 | 27 | // @ts-ignore 28 | fetch.mockImplementation((url, params) => { 29 | if (url.includes('log_event')) { 30 | events = events.concat(getDecodedBody(params)['events']); 31 | return Promise.resolve({ 32 | ok: true, 33 | }); 34 | } 35 | if (url.includes('download_config_specs')) { 36 | return new Promise((res) => { 37 | // Simulate a 1s delay 38 | jest.advanceTimersByTime(1000); 39 | res({ 40 | ok: true, 41 | json: () => Promise.resolve(jsonResponse), 42 | text: () => Promise.resolve(JSON.stringify(jsonResponse)), 43 | }); 44 | }); 45 | } 46 | return Promise.reject(); 47 | }); 48 | 49 | describe('Test local mode with overrides', () => { 50 | jest.setTimeout(3000); 51 | 52 | jest.useFakeTimers(); 53 | 54 | beforeEach(() => { 55 | events = []; 56 | 57 | const now = Date.now(); 58 | jest.spyOn(global.Date, 'now').mockImplementation(() => now); 59 | jest.resetModules(); 60 | jest.restoreAllMocks(); 61 | 62 | StatsigInstanceUtils.setInstance(null); 63 | }); 64 | 65 | test('Verify initialize() returns early when the network request takes too long', async () => { 66 | const prom = statsig.initialize('secret-abcdefg1234567890', { 67 | initTimeoutMs: 250, 68 | }); 69 | jest.advanceTimersByTime(400); 70 | 71 | await prom; 72 | // @ts-ignore 73 | expect(StatsigInstanceUtils.getInstance()['_ready']).toBe(true); 74 | expect(prom).resolves; 75 | expect( 76 | statsig.checkGate( 77 | { userID: 'test_user_id', email: 'test@nfl.com' }, 78 | 'nfl_gate', 79 | ), 80 | ).toBe(false); 81 | 82 | await statsig.shutdownAsync(); 83 | expect(events).toHaveLength(2); // 1 for init and 1 for gate check 84 | const event = events.find((e) => e.eventName === 'statsig::diagnostics'); 85 | expect(event?.metadata['statsigOptions']['initTimeoutMs']).toBe(250); 86 | 87 | const endMarker = event?.metadata['markers'].find( 88 | (marker) => marker.action === 'end', 89 | ); 90 | expect(endMarker['action']).toBe('end'); 91 | expect(endMarker['success']).toBe(false); 92 | expect(endMarker['reason']).toStrictEqual('timeout'); 93 | }); 94 | 95 | test('Verify initialize() can resolve before the specified timeout and serve requests', async () => { 96 | const prom = statsig.initialize('secret-abcdefg1234567890', { 97 | initTimeoutMs: 3000, 98 | disableDiagnostics: true, 99 | }); 100 | await prom; 101 | // @ts-ignore 102 | expect(StatsigInstanceUtils.getInstance()['_ready']).toBe(true); 103 | expect( 104 | statsig.checkGate( 105 | { userID: 'test_user_id', email: 'test@nfl.com' }, 106 | 'nfl_gate', 107 | ), 108 | ).toBe(true); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /src/__tests__/Layer.test.ts: -------------------------------------------------------------------------------- 1 | import Layer from '../Layer'; 2 | 3 | describe('Verify behavior of Layer', () => { 4 | const testLayer = new Layer( 5 | 'test_layer', 6 | { 7 | bool: true, 8 | number: 2, 9 | string: 'string', 10 | object: { 11 | key: 'value', 12 | key2: 123, 13 | }, 14 | boolStr1: 'true', 15 | boolStr2: 'FALSE', 16 | numberStr1: '3', 17 | numberStr2: '3.3', 18 | numberStr3: '3.3.3', 19 | arr: [1, 2, 'three'], 20 | }, 21 | 'default', 22 | ); 23 | 24 | beforeEach(() => { 25 | expect.hasAssertions(); 26 | }); 27 | 28 | test('Test constructor', () => { 29 | // @ts-ignore intentional mistyping test 30 | const layer = new Layer('name', 123); 31 | // @ts-ignore intentional mistyping test 32 | expect(layer.get()).toStrictEqual(null); 33 | }); 34 | 35 | test('Test getValue key not found', () => { 36 | expect(testLayer.getValue('key_not_found')).toBeNull(); 37 | expect(testLayer.getValue('key_not_found', null)).toBeNull(); 38 | expect(testLayer.getValue('key_not_found', true)).toStrictEqual(true); 39 | expect(testLayer.getValue('key_not_found', 12)).toStrictEqual(12); 40 | expect(testLayer.getValue('key_not_found', '123')).toStrictEqual('123'); 41 | expect(testLayer.getValue('key_not_found', ['1', '2'])).toStrictEqual([ 42 | '1', 43 | '2', 44 | ]); 45 | expect(testLayer.getValue('key_not_found', { test: 123 })).toStrictEqual({ 46 | test: 123, 47 | }); 48 | expect(testLayer.getRuleID()).toStrictEqual('default'); 49 | expect(testLayer.getGroupName()).toBeNull(); 50 | expect(testLayer.getAllocatedExperimentName()).toBeNull(); 51 | }); 52 | 53 | test('Test get key not found', () => { 54 | // @ts-ignore intentional mistyping test 55 | expect(testLayer.get('key_not_found')).toBeNull(); 56 | expect(testLayer.get('key_not_found', null)).toBeNull(); 57 | expect(testLayer.get('key_not_found', true)).toStrictEqual(true); 58 | expect(testLayer.get('key_not_found', 12)).toStrictEqual(12); 59 | expect(testLayer.get('key_not_found', '123')).toStrictEqual('123'); 60 | expect(testLayer.get('key_not_found', ['1', '2'])).toStrictEqual([ 61 | '1', 62 | '2', 63 | ]); 64 | expect(testLayer.get('key_not_found', { test: 123 })).toStrictEqual({ 65 | test: 123, 66 | }); 67 | }); 68 | 69 | test('Test all types types', () => { 70 | expect(testLayer.getValue('boolStr1', '123')).toStrictEqual('true'); 71 | expect(testLayer.getValue('boolStr1', null)).toStrictEqual('true'); 72 | expect(testLayer.getValue('boolStr1')).toStrictEqual('true'); 73 | 74 | expect(testLayer.getValue('number', '123')).toStrictEqual(2); 75 | expect(testLayer.getValue('number', null)).toStrictEqual(2); 76 | expect(testLayer.getValue('number')).toStrictEqual(2); 77 | 78 | expect(testLayer.getValue('bool', '123')).toStrictEqual(true); 79 | expect(testLayer.getValue('bool', null)).toStrictEqual(true); 80 | expect(testLayer.getValue('bool')).toStrictEqual(true); 81 | 82 | expect(testLayer.getValue('object', '123')).toStrictEqual({ 83 | key: 'value', 84 | key2: 123, 85 | }); 86 | expect(testLayer.getValue('object', null)).toStrictEqual({ 87 | key: 'value', 88 | key2: 123, 89 | }); 90 | expect(testLayer.getValue('object')).toStrictEqual({ 91 | key: 'value', 92 | key2: 123, 93 | }); 94 | 95 | expect(testLayer.getValue('arr', '123')).toStrictEqual([1, 2, 'three']); 96 | expect(testLayer.getValue('arr', null)).toStrictEqual([1, 2, 'three']); 97 | expect(testLayer.getValue('arr')).toStrictEqual([1, 2, 'three']); 98 | }); 99 | 100 | test('Test typed getting with matching types', () => { 101 | expect(testLayer.get('boolStr1', '123')).toStrictEqual('true'); 102 | expect(testLayer.get('boolStr1', null)).toStrictEqual('true'); 103 | // @ts-ignore intentional mistyping test 104 | expect(testLayer.get('boolStr1')).toStrictEqual('true'); 105 | 106 | expect(testLayer.get('number', 123)).toStrictEqual(2); 107 | expect(testLayer.get('number', null)).toStrictEqual(2); 108 | // @ts-ignore intentional mistyping test 109 | expect(testLayer.get('number')).toStrictEqual(2); 110 | 111 | expect(testLayer.get('bool', false)).toStrictEqual(true); 112 | expect(testLayer.get('bool', null)).toStrictEqual(true); 113 | // @ts-ignore intentional mistyping test 114 | expect(testLayer.get('bool')).toStrictEqual(true); 115 | 116 | expect(testLayer.get('object', {})).toStrictEqual({ 117 | key: 'value', 118 | key2: 123, 119 | }); 120 | expect(testLayer.get('object', null)).toStrictEqual({ 121 | key: 'value', 122 | key2: 123, 123 | }); 124 | // @ts-ignore intentional mistyping test 125 | expect(testLayer.get('object')).toStrictEqual({ 126 | key: 'value', 127 | key2: 123, 128 | }); 129 | 130 | expect(testLayer.get('arr', [])).toStrictEqual([1, 2, 'three']); 131 | expect(testLayer.get('arr', null)).toStrictEqual([1, 2, 'three']); 132 | // @ts-ignore intentional mistyping test 133 | expect(testLayer.get('arr')).toStrictEqual([1, 2, 'three']); 134 | }); 135 | 136 | test('Test typed getter mismatches', () => { 137 | expect(testLayer.get('boolStr1', 123)).toStrictEqual(123); 138 | expect(testLayer.get('number', '123')).toStrictEqual('123'); 139 | expect(testLayer.get('bool', '123')).toStrictEqual('123'); 140 | expect(testLayer.get('object', '123')).toStrictEqual('123'); 141 | expect(testLayer.get('object', ['123'])).toStrictEqual(['123']); 142 | expect(testLayer.get('arr', {})).toStrictEqual({}); 143 | }); 144 | 145 | test('Behavior of dummy layers', () => { 146 | const dummyLayer = new Layer('layerName'); 147 | // @ts-ignore intentional mistyping test 148 | expect(dummyLayer.get()).toBeNull(); 149 | // @ts-ignore intentional mistyping test 150 | expect(dummyLayer.get('test_field')).toBeNull(); 151 | expect(dummyLayer.get('str', 'default_value')).toEqual('default_value'); 152 | expect(dummyLayer.get('bool', true)).toEqual(true); 153 | expect(dummyLayer.get('number', 1.234)).toEqual(1.234); 154 | expect(dummyLayer.get('arr', [1, 2, 3])).toEqual([1, 2, 3]); 155 | expect(dummyLayer.get('obj', { key: 'value' })).toEqual({ key: 'value' }); 156 | 157 | // @ts-ignore intentional mistyping test 158 | expect(dummyLayer.getValue()).toEqual(null); 159 | expect(dummyLayer.getValue('test_field')).toEqual(null); 160 | expect(dummyLayer.getValue('str', 'default_value')).toEqual( 161 | 'default_value', 162 | ); 163 | expect(dummyLayer.getValue('bool', true)).toEqual(true); 164 | expect(dummyLayer.getValue('number', 1.234)).toEqual(1.234); 165 | expect(dummyLayer.getValue('arr', [1, 2, 3])).toEqual([1, 2, 3]); 166 | expect(dummyLayer.getValue('obj', { key: 'value' })).toEqual({ 167 | key: 'value', 168 | }); 169 | expect(dummyLayer.getIDType()).toBeNull(); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /src/__tests__/NetworkOverrideFunc.test.ts: -------------------------------------------------------------------------------- 1 | import * as statsigsdk from '../index'; 2 | import StatsigInstanceUtils from '../StatsigInstanceUtils'; 3 | 4 | const statsig = statsigsdk.default; 5 | 6 | jest.mock('node-fetch', () => jest.fn()); 7 | 8 | function verifyDcsFetchCall(call: any[]) { 9 | expect(call[0]).toEqual( 10 | 'https://api.statsigcdn.com/v1/download_config_specs/secret-key.json?sinceTime=0', 11 | ); 12 | expect(call[1].method).toBe('GET'); 13 | } 14 | 15 | function verifyGetIdListsFetchCall(call: any[]) { 16 | expect(call[0]).toEqual('https://statsigapi.net/v1/get_id_lists'); 17 | expect(call[1].method).toBe('POST'); 18 | } 19 | 20 | describe('NetworkOverrideFunc', () => { 21 | const fetchSpy = require('node-fetch'); 22 | const networkOverrideSpy = jest.fn(); 23 | 24 | beforeEach(() => { 25 | StatsigInstanceUtils.setInstance(null); 26 | 27 | fetchSpy.mockClear(); 28 | networkOverrideSpy.mockClear(); 29 | }); 30 | 31 | it('calls the networkOverrideFunc', async () => { 32 | await statsig.initialize('secret-key', { 33 | networkOverrideFunc: networkOverrideSpy, 34 | }); 35 | 36 | expect(fetchSpy).not.toHaveBeenCalled(); 37 | 38 | verifyDcsFetchCall(networkOverrideSpy.mock.calls[0]); 39 | verifyGetIdListsFetchCall(networkOverrideSpy.mock.calls[1]); 40 | }); 41 | 42 | it('calls fetch when no override is given', async () => { 43 | await statsig.initialize('secret-key'); 44 | expect(networkOverrideSpy).not.toHaveBeenCalled(); 45 | 46 | verifyDcsFetchCall(fetchSpy.mock.calls[0]); 47 | verifyGetIdListsFetchCall(fetchSpy.mock.calls[1]); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/__tests__/OutputLogger.test.ts: -------------------------------------------------------------------------------- 1 | import Statsig from '..'; 2 | import { StatsigInitializeFromNetworkError, StatsigInitializeIDListsError } from '../Errors'; 3 | import LogEvent from '../LogEvent'; 4 | import { LoggerInterface } from '../StatsigOptions'; 5 | 6 | const logLevels = ['none', 'error'] as const 7 | describe('Output Logger Interface', () => { 8 | it.each(logLevels)('verify calls to logger with log level %s', async (level) => { 9 | const warnings: unknown[] = []; 10 | const errors: unknown[] = []; 11 | const customLogger: LoggerInterface = { 12 | warn: (message?: any, ...optionalParams: any[]) => { 13 | warnings.push(message); 14 | }, 15 | error: (message?: any, ...optionalParams: any[]) => { 16 | errors.push(message); 17 | }, 18 | logLevel: level, 19 | }; 20 | const secretKey = 'secret-key'; 21 | await Statsig.initialize(secretKey, { logger: customLogger }); 22 | // @ts-ignore 23 | Statsig.logEvent({ userID: '123' }, null); 24 | expect(errors.length).toEqual(level === 'error' ? 3 : 0); 25 | if (level === 'error') { 26 | expect(errors).toContainEqual('statsigSDK> EventName needs to be a string of non-zero length.'); 27 | expect(errors).toContainEqual((new StatsigInitializeFromNetworkError(new Error(`Request to https://api.statsigcdn.com/v1/download_config_specs/******.json?sinceTime=0 failed with status 401`))).toString()); 28 | expect(errors).toContainEqual((new StatsigInitializeIDListsError(new Error('Request to https://statsigapi.net/v1/get_id_lists failed with status 401'))).toString()); 29 | } 30 | // @ts-ignore 31 | let event = new LogEvent(null); 32 | expect(errors.length).toEqual(level === 'error' ? 4 : 0); 33 | await Statsig.shutdownAsync(); 34 | // @ts-ignore 35 | event = new LogEvent(null); 36 | expect(errors.length).toEqual(level === 'error' ? 4 : 0); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/__tests__/ResetSync.test.ts: -------------------------------------------------------------------------------- 1 | import * as statsigsdk from '../index'; 2 | import StatsigInstanceUtils from '../StatsigInstanceUtils'; 3 | import StatsigTestUtils from './StatsigTestUtils'; 4 | // @ts-ignore 5 | const statsig = statsigsdk.default; 6 | 7 | const exampleConfigSpecs = require('./jest.setup'); 8 | 9 | const jsonResponse = { 10 | time: Date.now(), 11 | feature_gates: [exampleConfigSpecs.gate, exampleConfigSpecs.disabled_gate], 12 | dynamic_configs: [exampleConfigSpecs.config], 13 | layer_configs: [exampleConfigSpecs.allocated_layer], 14 | has_updates: true, 15 | }; 16 | 17 | jest.mock('node-fetch', () => jest.fn()); 18 | // @ts-ignore 19 | const fetch = require('node-fetch'); 20 | // @ts-ignore 21 | fetch.mockImplementation((url, params) => { 22 | if (url.includes('download_config_specs')) { 23 | return Promise.resolve({ 24 | ok: true, 25 | json: () => Promise.resolve(jsonResponse), 26 | text: () => Promise.resolve(JSON.stringify(jsonResponse)), 27 | }); 28 | } 29 | if (url.includes('get_id_lists')) { 30 | return Promise.resolve({ 31 | ok: true, 32 | json: () => 33 | Promise.resolve({ 34 | list_1: { 35 | name: 'list_1', 36 | size: 15, 37 | url: 'https://id_list_content/list_1', 38 | fileID: 'file_id_1', 39 | creationTime: 1, 40 | }, 41 | }), 42 | }); 43 | } 44 | return Promise.reject(); 45 | }); 46 | 47 | describe('Verify sync intervals reset', () => { 48 | const secretKey = 'secret-key'; 49 | beforeEach(() => { 50 | jest.restoreAllMocks(); 51 | jest.resetModules(); 52 | jest.useFakeTimers(); 53 | 54 | StatsigInstanceUtils.setInstance(null); 55 | }); 56 | 57 | test('Verify timers reset if rulesets stale', async () => { 58 | expect.assertions(6); 59 | await statsig.initialize(secretKey); 60 | const now = Date.now(); 61 | 62 | const evaluator = StatsigTestUtils.getEvaluator(); 63 | const spy = jest.spyOn(evaluator['store'], 'pollForUpdates'); 64 | 65 | let gate = await statsig.checkGate( 66 | { userID: '123', email: 'tore@packers.com' }, 67 | 'nfl_gate', 68 | ); 69 | expect(gate).toBe(true); 70 | expect(spy).toHaveBeenCalledTimes(0); 71 | 72 | jest 73 | .spyOn(global.Date, 'now') 74 | .mockImplementation(() => now + (2 * 60 * 1000 - 100)); 75 | gate = await statsig.checkGate( 76 | { userID: '123', email: 'tore@packers.com' }, 77 | 'nfl_gate', 78 | ); 79 | expect(gate).toBe(true); 80 | expect(spy).toHaveBeenCalledTimes(0); 81 | 82 | jest 83 | .spyOn(global.Date, 'now') 84 | .mockImplementation(() => now + (2 * 60 * 1000 + 1)); 85 | gate = await statsig.checkGate( 86 | { userID: '123', email: 'tore@packers.com' }, 87 | 'nfl_gate', 88 | ); 89 | gate = await statsig.checkGate( 90 | { userID: '123', email: 'tore@packers.com' }, 91 | 'nfl_gate', 92 | ); 93 | gate = await statsig.checkGate( 94 | { userID: '123', email: 'tore@packers.com' }, 95 | 'nfl_gate', 96 | ); 97 | gate = await statsig.checkGate( 98 | { userID: '123', email: 'tore@packers.com' }, 99 | 'nfl_gate', 100 | ); 101 | expect(gate).toBe(true); 102 | expect(spy).toHaveBeenCalledTimes(1); 103 | }); 104 | 105 | test('Verify timers dont reset if syncing is disabled', async () => { 106 | await statsig.initialize(secretKey, { 107 | disableRulesetsSync: true, 108 | disableIdListsSync: true, 109 | }); 110 | const now = Date.now(); 111 | 112 | const evaluator = StatsigTestUtils.getEvaluator(); 113 | const spyRulesetsSync = jest.spyOn(evaluator['store'], 'syncConfigSpecs'); 114 | const spyIdListsSync = jest.spyOn(evaluator['store'], 'syncIdLists'); 115 | 116 | jest 117 | .spyOn(global.Date, 'now') 118 | .mockImplementation(() => now + (2 * 60 * 1000 + 1)); 119 | const gate = await statsig.checkGate( 120 | { userID: '123', email: 'tore@packers.com' }, 121 | 'nfl_gate', 122 | ); 123 | expect(gate).toBe(true); 124 | expect(spyRulesetsSync).toHaveBeenCalledTimes(0); 125 | expect(spyIdListsSync).toHaveBeenCalledTimes(0); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /src/__tests__/RulesetsEvalConsistency.test.ts: -------------------------------------------------------------------------------- 1 | import Evaluator, { ClientInitializeResponse } from '../Evaluator'; 2 | import Statsig from '../index'; 3 | import StatsigInstanceUtils from '../StatsigInstanceUtils'; 4 | import safeFetch from '../utils/safeFetch'; 5 | import { StatsigContext } from '../utils/StatsigContext'; 6 | import StatsigTestUtils from './StatsigTestUtils'; 7 | 8 | const secret: string = process.env.test_api_key ?? ''; 9 | const shouldSkip = typeof secret !== 'string' || secret.length == 0; 10 | 11 | if (shouldSkip) { 12 | fail( 13 | 'THIS TEST IS EXPECTED TO FAIL FOR NON-STATSIG EMPLOYEES! If this is the only test failing, please proceed to submit a pull request. If you are a Statsig employee, chat with jkw.', 14 | ); 15 | } 16 | 17 | describe('RulesetsEvalConsistency', () => { 18 | beforeEach(() => { 19 | jest.restoreAllMocks(); 20 | jest.resetModules(); 21 | }); 22 | 23 | afterEach(async () => { 24 | await Statsig.shutdownAsync(); 25 | }); 26 | 27 | test.each([['https://statsigapi.net/v1']])('Verify [%s]', async (api) => { 28 | const response = await safeFetch(api + '/rulesets_e2e_test', { 29 | method: 'POST', 30 | body: JSON.stringify({}), 31 | headers: { 32 | 'Content-type': 'application/json; charset=UTF-8', 33 | 'STATSIG-API-KEY': secret, 34 | 'STATSIG-CLIENT-TIME': Date.now() + '', 35 | }, 36 | }); 37 | const testData = (await response.json()).data; 38 | 39 | const { 40 | feature_gates_v2: gates, 41 | dynamic_configs: configs, 42 | layer_configs: layers, 43 | } = testData[0]; 44 | 45 | const totalChecks = 46 | testData.length * 47 | (Object.keys(gates).length + 48 | Object.keys(configs).length + 49 | Object.keys(layers).length); 50 | 51 | expect.assertions(totalChecks); 52 | 53 | StatsigInstanceUtils.setInstance(null); 54 | await Statsig.initialize(secret, { api: api }); 55 | const evaluator: Evaluator = StatsigTestUtils.getEvaluator(); 56 | 57 | for (const data of testData) { 58 | const user = data.user; 59 | const gates = data.feature_gates_v2; 60 | const configs = data.dynamic_configs; 61 | const layers = data.layer_configs; 62 | const sdkResults = evaluator.getClientInitializeResponse( 63 | user, 64 | StatsigContext.new({ 65 | caller: 'getClientInitializeResponse', 66 | clientKey: undefined, 67 | hash: 'none', 68 | }), 69 | undefined, 70 | { hash: 'none' }, 71 | ); 72 | if (sdkResults == null) { 73 | throw new Error('Store has not been set up for test'); 74 | } 75 | 76 | for (const name in gates) { 77 | const sdkResult = sdkResults['feature_gates'][name]; 78 | const serverResult = gates[name]; 79 | 80 | expect([ 81 | name, 82 | sdkResult.value, 83 | sdkResult.rule_id, 84 | sdkResult.secondary_exposures, 85 | ]).toEqual([ 86 | name, 87 | serverResult.value, 88 | serverResult.rule_id, 89 | serverResult.secondary_exposures, 90 | ]); 91 | } 92 | 93 | for (const name in configs) { 94 | const sdkResult = sdkResults['dynamic_configs'][name]; 95 | const serverResult = configs[name]; 96 | 97 | expect([ 98 | name, 99 | sdkResult.value, 100 | sdkResult.rule_id, 101 | sdkResult.secondary_exposures, 102 | ]).toEqual([ 103 | name, 104 | serverResult.value, 105 | serverResult.rule_id, 106 | serverResult.secondary_exposures, 107 | ]); 108 | } 109 | 110 | for (const name in layers) { 111 | const sdkResult = sdkResults['layer_configs'][name]; 112 | const serverResult = layers[name]; 113 | 114 | expect([ 115 | name, 116 | sdkResult.value, 117 | sdkResult.rule_id, 118 | sdkResult.secondary_exposures, 119 | sdkResult.undelegated_secondary_exposures, 120 | ]).toEqual([ 121 | name, 122 | serverResult.value, 123 | serverResult.rule_id, 124 | serverResult.secondary_exposures, 125 | serverResult.undelegated_secondary_exposures, 126 | ]); 127 | } 128 | } 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /src/__tests__/SDKFlags.test.ts: -------------------------------------------------------------------------------- 1 | import SDKFlags from '../SDKFlags'; 2 | 3 | describe('TestSDKFlags', () => { 4 | beforeEach(() => { 5 | (SDKFlags as any)._flags = {}; 6 | }); 7 | 8 | it('handles empty', () => { 9 | expect(SDKFlags.on('not_a_flag')).toBe(false); 10 | }); 11 | 12 | it('handles malformed', () => { 13 | SDKFlags.setFlags({ bad_flag: 1 }); 14 | expect(SDKFlags.on('bad_flag')).toBe(false); 15 | }); 16 | 17 | it('handles unknown', () => { 18 | SDKFlags.setFlags('1'); 19 | expect(SDKFlags.on('a_flag')).toBe(false); 20 | }); 21 | 22 | it('handles valid flags', () => { 23 | SDKFlags.setFlags({ a_flag: true }); 24 | expect(SDKFlags.on('a_flag')).toBe(true); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/__tests__/SafeShutdown.test.ts: -------------------------------------------------------------------------------- 1 | import Diagnostics from '../Diagnostics'; 2 | import LogEvent from '../LogEvent'; 3 | import LogEventProcessor from '../LogEventProcessor'; 4 | import SpecStore from '../SpecStore'; 5 | import { ExplicitStatsigOptions, OptionsWithDefaults } from '../StatsigOptions'; 6 | import StatsigServer from '../StatsigServer'; 7 | import { InitializeContext } from '../utils/StatsigContext'; 8 | import StatsigFetcher from '../utils/StatsigFetcher'; 9 | import { getDecodedBody } from './StatsigTestUtils'; 10 | 11 | jest.mock('node-fetch', () => jest.fn()); 12 | 13 | const CONFIG_SPEC_RESPONSE = JSON.stringify( 14 | require('./data/exposure_logging_dcs.json'), 15 | ); 16 | 17 | describe('Verifies safe shutdown of Statsig SDK', () => { 18 | const options: ExplicitStatsigOptions = { 19 | ...OptionsWithDefaults({}), 20 | ...{ 21 | rulesetsSyncIntervalMs: 100, 22 | idListsSyncIntervalMs: 100, 23 | disableDiagnostics: true, 24 | }, 25 | }; 26 | let fetcher: StatsigFetcher; 27 | let logger: LogEventProcessor; 28 | let store: SpecStore; 29 | let server: StatsigServer; 30 | let events: { eventName: string }[] = []; 31 | let isInit: boolean; 32 | 33 | beforeAll(() => { 34 | Diagnostics.initialize({ logger }); 35 | const fetch = require('node-fetch'); 36 | fetch.mockImplementation(async (url: string, params) => { 37 | if (url.includes('download_config_specs')) { 38 | if (isInit) { 39 | isInit = false; 40 | return Promise.resolve({ ok: true, text: () => Promise.resolve() }); 41 | } else { 42 | await new Promise((r) => setTimeout(r, 500)); 43 | return Promise.resolve({ 44 | ok: true, 45 | text: () => Promise.resolve(CONFIG_SPEC_RESPONSE), 46 | }); 47 | } 48 | } 49 | 50 | if (url.includes('log_event')) { 51 | await new Promise((r) => setTimeout(r, 500)); 52 | events = events.concat(getDecodedBody(params)['events']); 53 | return Promise.resolve({ 54 | ok: true, 55 | }); 56 | } 57 | 58 | return Promise.resolve({ 59 | ok: true, 60 | text: () => Promise.resolve('{}'), 61 | }); 62 | }); 63 | }); 64 | 65 | afterAll(() => { 66 | jest.restoreAllMocks(); 67 | jest.resetModules(); 68 | }); 69 | 70 | beforeEach(() => { 71 | server = new StatsigServer('secret-key', options); 72 | // @ts-ignore 73 | fetcher = server._fetcher; 74 | // @ts-ignore 75 | logger = server._logger; 76 | // Need to manually create store to bypass OptionsWithDefaults 77 | store = new SpecStore('secret-key', fetcher, options); 78 | 79 | isInit = true; 80 | }); 81 | 82 | afterEach(() => { 83 | events = []; 84 | }); 85 | 86 | test('LogEventProcessor shutdown', async () => { 87 | logger.log(new LogEvent('LogEventProcessor shutdown test event')); 88 | const shutdownPromise = logger.shutdown(5000); 89 | expect(events).toHaveLength(0); 90 | // Wait for pending flush 91 | await shutdownPromise; 92 | // See that events are logged after shutdown 93 | expect(events).toHaveLength(1); 94 | }); 95 | 96 | test('LogEventProcessor shutdown async', async () => { 97 | logger.log(new LogEvent('LogEventProcessor shutdown async test event')); 98 | const start = Date.now(); 99 | await logger.shutdown(5000); 100 | const end = Date.now(); 101 | expect(events).toHaveLength(1); 102 | expect(end - start).toBeGreaterThanOrEqual(499); 103 | }); 104 | 105 | test('StatsigServer shutdown async', async () => { 106 | logger.log(new LogEvent('StatsigServer shutdown async test event')); 107 | const start = Date.now(); 108 | await server.shutdownAsync(); 109 | const end = Date.now(); 110 | expect(events).toHaveLength(1); 111 | expect(end - start).toBeGreaterThanOrEqual(500); 112 | }); 113 | 114 | test('SpecStore shutdown', async () => { 115 | await store.init(InitializeContext.new({ sdkKey: 'secret-key' })); 116 | expect(store.getInitReason()).toEqual('Uninitialized'); 117 | // Wait for next sync to start 118 | await new Promise((r) => setTimeout(r, 100)); 119 | store.shutdown(); 120 | expect(store.getInitReason()).toEqual('Uninitialized'); 121 | // Wait for next sync to finish 122 | await new Promise((r) => setTimeout(r, 500)); 123 | // See that it continued after shutdown 124 | expect(store.getInitReason()).toEqual('Network'); 125 | }); 126 | 127 | test('SpecStore shutdown async', async () => { 128 | await store.init(InitializeContext.new({ sdkKey: 'secret-key' })); 129 | expect(store.getInitReason()).toEqual('Uninitialized'); 130 | const start = Date.now(); 131 | // Wait for next sync to start 132 | await new Promise((r) => setTimeout(r, 100)); 133 | await store.shutdownAsync(); 134 | const end = Date.now(); 135 | expect(store.getInitReason()).toEqual('Network'); 136 | expect(end - start).toBeGreaterThanOrEqual(500); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /src/__tests__/StatsigErrorBoundaryUsage.test.ts: -------------------------------------------------------------------------------- 1 | import DynamicConfig from '../DynamicConfig'; 2 | import { ExceptionEndpoint } from '../ErrorBoundary'; 3 | import Layer from '../Layer'; 4 | import StatsigServer from '../StatsigServer'; 5 | jest.mock('node-fetch', () => jest.fn()); 6 | 7 | const user = { userID: 'dloomb' }; 8 | 9 | const oneLoggedError = (functionName: string) => { 10 | return [ 11 | expect.objectContaining({ 12 | url: ExceptionEndpoint, 13 | params: expect.objectContaining({ 14 | body: expect.stringContaining( 15 | `exception":"TypeError","info":"TypeError: this.${functionName} is not a function`, 16 | ), 17 | }), 18 | }), 19 | ]; 20 | }; 21 | 22 | describe('Statsig ErrorBoundary Usage', () => { 23 | let requests: { url: RequestInfo; params: RequestInit }[] = []; 24 | let statsig: StatsigServer; 25 | 26 | beforeEach(async () => { 27 | const fetch = require('node-fetch'); 28 | fetch.mockImplementation((url: RequestInfo, params: RequestInit) => { 29 | requests.push({ url, params }); 30 | return Promise.resolve(); 31 | }); 32 | 33 | statsig = new StatsigServer('secret-key'); 34 | await statsig.initializeAsync(); 35 | 36 | requests = []; 37 | 38 | // 1 Causes not a function errors 39 | // @ts-ignore 40 | statsig._evaluator = { 41 | resetSyncTimerIfExited: () => { 42 | return null; 43 | }, 44 | }; 45 | // @ts-ignore 46 | statsig._logger = 1; 47 | }); 48 | 49 | it('recovers from error and returns default gate value', async () => { 50 | const result = await statsig.checkGate(user, 'a_gate'); 51 | expect(result).toBe(false); 52 | expect(requests).toEqual(oneLoggedError('_evaluator.checkGate')); 53 | }); 54 | 55 | it('recovers from error and returns default config value', async () => { 56 | const result = await statsig.getConfig(user, 'a_config'); 57 | expect(result instanceof DynamicConfig).toBe(true); 58 | expect(result.value).toEqual({}); 59 | expect(requests).toEqual(oneLoggedError('_evaluator.getConfig')); 60 | }); 61 | 62 | it('recovers from error and returns default experiment value', async () => { 63 | const result = await statsig.getExperiment(user, 'an_experiment'); 64 | expect(result instanceof DynamicConfig).toBe(true); 65 | expect(result.value).toEqual({}); 66 | expect(requests).toEqual(oneLoggedError('_evaluator.getConfig')); 67 | }); 68 | 69 | it('recovers from error and returns default layer value', async () => { 70 | const result = await statsig.getLayer(user, 'a_layer'); 71 | expect(result instanceof Layer).toBe(true); 72 | // @ts-ignore 73 | expect(result._value).toEqual({}); 74 | expect(requests).toEqual(oneLoggedError('_evaluator.getLayer')); 75 | }); 76 | 77 | it('recovers from error with getClientInitializeResponse', () => { 78 | const result = statsig.getClientInitializeResponse(user); 79 | expect(result).toBeNull(); 80 | expect(requests).toEqual( 81 | oneLoggedError('_evaluator.getClientInitializeResponse'), 82 | ); 83 | }); 84 | 85 | it('recovers from error with logEvent', () => { 86 | statsig.logEvent(user, 'an_event'); 87 | expect(requests).toEqual(oneLoggedError('_logger.log')); 88 | }); 89 | 90 | it('recovers from error with logEventObject', () => { 91 | statsig.logEventObject({ user, eventName: 'an_event' }); 92 | expect(requests).toEqual(oneLoggedError('_logger.log')); 93 | }); 94 | 95 | it('recovers from error with shutdown', () => { 96 | statsig.shutdown(); 97 | expect(requests).toEqual(oneLoggedError('_logger.shutdown')); 98 | }); 99 | 100 | it('recovers from error with overrideGate', () => { 101 | statsig.overrideGate('a_gate', true); 102 | expect(requests).toEqual(oneLoggedError('_evaluator.overrideGate')); 103 | }); 104 | 105 | it('recovers from error with overrideConfig', () => { 106 | statsig.overrideConfig('a_config', {}); 107 | expect(requests).toEqual(oneLoggedError('_evaluator.overrideConfig')); 108 | }); 109 | 110 | it('recovers from error with overrideConfig', async () => { 111 | await statsig.flush(); 112 | expect(requests).toEqual(oneLoggedError('_logger.flush')); 113 | }); 114 | 115 | it('recovers from error with initialize', async () => { 116 | // @ts-ignore 117 | statsig._ready = false; 118 | await statsig.initializeAsync(); 119 | expect(requests).toEqual(oneLoggedError('_evaluator.init')); 120 | // @ts-ignore 121 | expect(statsig._ready).toBeTruthy(); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /src/__tests__/StatsigTestUtils.ts: -------------------------------------------------------------------------------- 1 | import StatsigInstanceUtils from '../StatsigInstanceUtils'; 2 | 3 | export default abstract class StatsigTestUtils { 4 | static getEvaluator(): any { 5 | // @ts-ignore 6 | return StatsigInstanceUtils.getInstance()?._evaluator ?? null; 7 | } 8 | 9 | static getLogger(): any { 10 | // @ts-ignore 11 | return StatsigInstanceUtils.getInstance()?._logger ?? null; 12 | } 13 | } 14 | 15 | export function assertMarkerEqual(marker: any, expected: any) { 16 | expect(marker).toStrictEqual({ 17 | ...expected, 18 | timestamp: expect.any(Number), 19 | }); 20 | } 21 | 22 | export function parseLogEvents(request: any, filterDiagnosticsEvent: boolean = true) { 23 | const logs = getDecodedBody(request) 24 | if(filterDiagnosticsEvent) { 25 | return {events:logs.events.filter(log => log.eventName !== 'statsig::diagnostics')} 26 | } 27 | return logs 28 | } 29 | 30 | export function getDecodedBody(request: any) { 31 | const body = request.body; 32 | if (typeof body === 'string') { 33 | return JSON.parse(body); 34 | } 35 | 36 | const headers = request.headers; 37 | if ( 38 | headers && 39 | headers['Content-Encoding'] === 'gzip' && 40 | Buffer.isBuffer(body) 41 | ) { 42 | return JSON.parse(require('zlib').gunzipSync(body).toString('utf8')); 43 | } 44 | 45 | return body; 46 | } 47 | -------------------------------------------------------------------------------- /src/__tests__/StatsigUserDeleteUndefinedFields.test.ts: -------------------------------------------------------------------------------- 1 | import { StatsigUser } from '../StatsigUser'; 2 | import Evaluator from '../Evaluator'; 3 | import StatsigFetcher from '../utils/StatsigFetcher'; 4 | import { OptionsWithDefaults } from '../StatsigOptions'; 5 | import ErrorBoundary from '../ErrorBoundary'; 6 | 7 | describe('Evaluator - deleteUndefinedFields', () => { 8 | let mockedEvaluator: Evaluator; 9 | 10 | beforeEach(() => { 11 | const serverKey = "secret-test"; 12 | const options = OptionsWithDefaults({}); 13 | const eb = new ErrorBoundary(serverKey, options, "sessionid-a"); 14 | const fetcher = new StatsigFetcher(serverKey, options, eb, "sessionid-a"); 15 | 16 | mockedEvaluator = new Evaluator("secret-key", fetcher, options); 17 | }); 18 | 19 | it('should delete undefined fields from a StatsigUser object', () => { 20 | const user: StatsigUser = { 21 | userID: undefined, 22 | email: 'example@example.com', 23 | ip: undefined, 24 | customIDs: { 25 | attr1: 'value', 26 | attr2: 'value', 27 | }, 28 | userAgent: undefined, 29 | locale: undefined, 30 | appVersion: undefined, 31 | custom: { key1: undefined, key2: undefined }, 32 | privateAttributes: { 33 | privateAttr1: undefined, 34 | privateAttr2: 'privateValue', 35 | }, 36 | statsigEnvironment: { 37 | tier: undefined, 38 | }, 39 | }; 40 | 41 | // TypeScript assertion to access private method 42 | (mockedEvaluator as any).deleteUndefinedFields(user); 43 | 44 | expect(user).toEqual({ 45 | email: 'example@example.com', 46 | customIDs: { 47 | attr1: 'value', 48 | attr2: 'value', 49 | }, 50 | custom: {}, 51 | privateAttributes: { 52 | privateAttr2: 'privateValue', 53 | }, 54 | statsigEnvironment: {} 55 | }); 56 | }); 57 | }); -------------------------------------------------------------------------------- /src/__tests__/TestDataAdapter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AdapterResponse, 3 | DataAdapterKeyPath, 4 | IDataAdapter, 5 | } from '../interfaces/IDataAdapter'; 6 | 7 | export default class TestDataAdapter implements IDataAdapter { 8 | private store: Record = {}; 9 | 10 | get(key: string): Promise { 11 | return Promise.resolve({ result: this.store[key], time: Date.now() }); 12 | } 13 | set(key: string, value: string, time?: number | undefined): Promise { 14 | this.store[key] = value; 15 | return Promise.resolve(); 16 | } 17 | initialize(): Promise { 18 | return Promise.resolve(); 19 | } 20 | shutdown(): Promise { 21 | this.store = {}; 22 | return Promise.resolve(); 23 | } 24 | } 25 | 26 | export class TestSyncingDataAdapter extends TestDataAdapter { 27 | private keysToSync: DataAdapterKeyPath[] | undefined; 28 | 29 | constructor(keysToSync?: DataAdapterKeyPath[]) { 30 | super(); 31 | this.keysToSync = keysToSync; 32 | } 33 | 34 | supportsPollingUpdatesFor(key): boolean { 35 | if (!this.keysToSync) { 36 | return false; 37 | } 38 | return this.keysToSync.includes(key); 39 | } 40 | } 41 | 42 | export class TestObjectDataAdapter { 43 | public store: Record = {}; 44 | 45 | get(key: string): Promise { 46 | return Promise.resolve({ result: this.store[key], time: Date.now() }); 47 | } 48 | set(key: string, value: string, time?: number | undefined): Promise { 49 | if (key.includes(DataAdapterKeyPath.V1Rulesets) || (key.includes(DataAdapterKeyPath.IDLists))) { 50 | this.store[key] = JSON.parse(value); 51 | } else { 52 | this.store[key] = value; 53 | } 54 | return Promise.resolve(); 55 | } 56 | initialize(): Promise { 57 | return Promise.resolve(); 58 | } 59 | shutdown(): Promise { 60 | this.store = {}; 61 | return Promise.resolve(); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/__tests__/TypedDynamicConfig.test.ts: -------------------------------------------------------------------------------- 1 | import DynamicConfig from '../DynamicConfig'; 2 | 3 | describe('Verify behavior of DynamicConfig', () => { 4 | const testConfig = new DynamicConfig( 5 | 'test_config', 6 | { 7 | bool: true, 8 | number: 2, 9 | string: 'string', 10 | object: { 11 | key: 'value', 12 | key2: 123, 13 | }, 14 | boolStr1: 'true', 15 | boolStr2: 'FALSE', 16 | numberStr1: '3', 17 | numberStr2: '3.3', 18 | arr: [1, 2, 'three'], 19 | }, 20 | 'default', 21 | ); 22 | 23 | type TestObject = { 24 | key: string; 25 | key2: number; 26 | }; 27 | 28 | type OtherTestObject = { 29 | someProp: string; 30 | otherProp: number; 31 | }; 32 | 33 | const isTestObject = (obj: any): obj is TestObject => { 34 | return typeof obj?.key === 'string' && typeof obj?.key2 === 'number'; 35 | }; 36 | 37 | const isOtherTestObject = (obj: any): obj is OtherTestObject => { 38 | return ( 39 | typeof obj?.someProp === 'string' && typeof obj?.otherProp === 'number' 40 | ); 41 | }; 42 | 43 | beforeEach(() => { 44 | expect.hasAssertions(); 45 | }); 46 | 47 | test('Test typed get', () => { 48 | expect(testConfig.get('bool', 3)).toStrictEqual(3); 49 | expect(testConfig.getValue('111', 222)).toStrictEqual(222); 50 | expect(testConfig.get('numberStr2', 'test')).toStrictEqual('3.3'); 51 | expect(testConfig.get('boolStr1', 'test')).toStrictEqual('true'); 52 | expect(testConfig.get('numberStr2', 17)).toStrictEqual(17); 53 | expect(testConfig.get('arr', ['test'])).toStrictEqual([1, 2, 'three']); 54 | expect(testConfig.get('object', ['test'])).toStrictEqual(['test']); 55 | expect(testConfig.get('object', {})).toStrictEqual({ 56 | key: 'value', 57 | key2: 123, 58 | }); 59 | }); 60 | 61 | test('Test optional type guard when runtime check succeeds', () => { 62 | const defaultTestObject: TestObject = { 63 | key: 'default', 64 | key2: 0, 65 | }; 66 | expect( 67 | testConfig.get('object', defaultTestObject, isTestObject), 68 | ).toStrictEqual({ 69 | key: 'value', 70 | key2: 123, 71 | }); 72 | }); 73 | 74 | test('Test optional type guard default', () => { 75 | const defaultOtherTestObject: OtherTestObject = { 76 | someProp: 'other', 77 | otherProp: 0, 78 | }; 79 | expect( 80 | testConfig.get('object', defaultOtherTestObject, isOtherTestObject), 81 | ).toStrictEqual(defaultOtherTestObject); 82 | }); 83 | 84 | test('Test optional type guard default when given a narrower type', () => { 85 | const narrowerOtherTestObject = { 86 | someProp: 'specificallyThisString', 87 | otherProp: 0, 88 | } as const; 89 | expect( 90 | testConfig.get('object', narrowerOtherTestObject, isOtherTestObject), 91 | ).toStrictEqual(narrowerOtherTestObject); 92 | }); 93 | 94 | test('Test optional type guard default when given a wider type', () => { 95 | const widerOtherTestObject = { 96 | someProp: 'Wider type than OtherTestObject', 97 | }; 98 | expect( 99 | testConfig.get('object', widerOtherTestObject, isOtherTestObject), 100 | ).toStrictEqual(widerOtherTestObject); 101 | }); 102 | 103 | test('Test optional type guard default when given null', () => { 104 | expect(testConfig.get('object', null, isOtherTestObject)).toBeNull(); 105 | }); 106 | 107 | test('Test optional type guard default given undefined', () => { 108 | expect(testConfig.get('object', undefined, isOtherTestObject)).toBeNull(); 109 | expect(testConfig.get('object', null, isOtherTestObject)).toBeNull(); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /src/__tests__/TypedLayer.test.ts: -------------------------------------------------------------------------------- 1 | import Layer from '../Layer'; 2 | 3 | describe('Verify behavior of Layer', () => { 4 | const testLayer = new Layer( 5 | 'test_layer', 6 | { 7 | bool: true, 8 | number: 2, 9 | string: 'string', 10 | object: { 11 | key: 'value', 12 | key2: 123, 13 | }, 14 | boolStr1: 'true', 15 | boolStr2: 'FALSE', 16 | numberStr1: '3', 17 | numberStr2: '3.3', 18 | arr: [1, 2, 'three'], 19 | }, 20 | 'default', 21 | ); 22 | 23 | type TestObject = { 24 | key: string; 25 | key2: number; 26 | }; 27 | 28 | type OtherTestObject = { 29 | someProp: string; 30 | otherProp: number; 31 | }; 32 | 33 | const isTestObject = (obj: any): obj is TestObject => { 34 | return typeof obj?.key === 'string' && typeof obj?.key2 === 'number'; 35 | }; 36 | 37 | const isOtherTestObject = (obj: any): obj is OtherTestObject => { 38 | return ( 39 | typeof obj?.someProp === 'string' && typeof obj?.otherProp === 'number' 40 | ); 41 | }; 42 | 43 | beforeEach(() => { 44 | expect.hasAssertions(); 45 | }); 46 | 47 | test('Test typed get', () => { 48 | expect(testLayer.get('bool', 3)).toStrictEqual(3); 49 | expect(testLayer.getValue('111', 222)).toStrictEqual(222); 50 | expect(testLayer.get('numberStr2', 'test')).toStrictEqual('3.3'); 51 | expect(testLayer.get('boolStr1', 'test')).toStrictEqual('true'); 52 | expect(testLayer.get('numberStr2', 17)).toStrictEqual(17); 53 | expect(testLayer.get('arr', ['test'])).toStrictEqual([1, 2, 'three']); 54 | expect(testLayer.get('object', ['test'])).toStrictEqual(['test']); 55 | expect(testLayer.get('object', {})).toStrictEqual({ 56 | key: 'value', 57 | key2: 123, 58 | }); 59 | }); 60 | 61 | test('Test optional type guard when runtime check succeeds', () => { 62 | const defaultTestObject: TestObject = { 63 | key: 'default', 64 | key2: 0, 65 | }; 66 | expect( 67 | testLayer.get('object', defaultTestObject, isTestObject), 68 | ).toStrictEqual({ 69 | key: 'value', 70 | key2: 123, 71 | }); 72 | }); 73 | 74 | test('Test optional type guard default', () => { 75 | const defaultOtherTestObject: OtherTestObject = { 76 | someProp: 'other', 77 | otherProp: 0, 78 | }; 79 | expect( 80 | testLayer.get('object', defaultOtherTestObject, isOtherTestObject), 81 | ).toStrictEqual(defaultOtherTestObject); 82 | }); 83 | 84 | test('Test optional type guard default when given a narrower type', () => { 85 | const narrowerOtherTestObject = { 86 | someProp: 'specificallyThisString', 87 | otherProp: 0, 88 | } as const; 89 | expect( 90 | testLayer.get('object', narrowerOtherTestObject, isOtherTestObject), 91 | ).toStrictEqual(narrowerOtherTestObject); 92 | }); 93 | 94 | test('Test optional type guard default when given a wider type', () => { 95 | const widerOtherTestObject = { 96 | someProp: 'Wider type than OtherTestObject', 97 | }; 98 | expect( 99 | testLayer.get('object', widerOtherTestObject, isOtherTestObject), 100 | ).toStrictEqual(widerOtherTestObject); 101 | }); 102 | 103 | test('Test optional type guard default when given null', () => { 104 | expect(testLayer.get('object', null, isOtherTestObject)).toBeNull(); 105 | }); 106 | 107 | test('Test optional type guard default given undefined', () => { 108 | expect(testLayer.get('object', undefined, isOtherTestObject)).toBeNull(); 109 | expect(testLayer.get('object', null, isOtherTestObject)).toBeNull(); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /src/__tests__/data/exposure_logging_dcs.json: -------------------------------------------------------------------------------- 1 | { 2 | "dynamic_configs": [], 3 | "feature_gates": [], 4 | "layer_configs": [ 5 | { 6 | "name": "a_layer", 7 | "type": "dynamic_config", 8 | "salt": "f8aeba58-18fb-4f36-9bbd-4c611447a912", 9 | "enabled": true, 10 | "defaultValue": { 11 | "a_bool": true 12 | }, 13 | "rules": [ 14 | { 15 | "name": "experimentAssignment", 16 | "passPercentage": 100, 17 | "conditions": [], 18 | "returnValue": { 19 | "a_bool": true 20 | }, 21 | "id": "experimentAssignment", 22 | "salt": "", 23 | "isDeviceBased": false, 24 | "idType": "userID", 25 | "configDelegate": "sample_experiment" 26 | } 27 | ], 28 | "isDeviceBased": false, 29 | "idType": "userID", 30 | "entity": "layer" 31 | } 32 | ], 33 | "has_updates": true, 34 | "diagnostics": {}, 35 | "time": 0, 36 | "layers": { 37 | "a_layer": ["sample_experiment"] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/__tests__/data/initialize_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "feature_gates": { 3 | "1389118615": { 4 | "name": "1389118615", 5 | "value": true, 6 | "rule_id": "2DWuOvXQZWKvoaNm27dqcs", 7 | "secondary_exposures": [], 8 | "id_type": "userID" 9 | }, 10 | "1747439400": { 11 | "name": "1747439400", 12 | "value": false, 13 | "rule_id": "", 14 | "secondary_exposures": [], 15 | "id_type": "userID" 16 | }, 17 | "1801014148": { 18 | "name": "1801014148", 19 | "value": true, 20 | "rule_id": "3jdTW54SQWbbxFFZJe7wYZ", 21 | "secondary_exposures": [], 22 | "id_type": "userID" 23 | }, 24 | "2172880123": { 25 | "name": "2172880123", 26 | "value": true, 27 | "rule_id": "2DWuOvXQZWKvoaNm27dqcs", 28 | "secondary_exposures": [], 29 | "id_type": "userID" 30 | } 31 | }, 32 | "dynamic_configs": { 33 | "3556498": { 34 | "name": "3556498", 35 | "value": {}, 36 | "group": "prestart", 37 | "rule_id": "prestart", 38 | "is_device_based": false, 39 | "secondary_exposures": [], 40 | "id_type": "userID", 41 | "is_user_in_experiment": false, 42 | "is_experiment_active": false 43 | }, 44 | "195944244": { 45 | "name": "195944244", 46 | "value": {}, 47 | "group": "prestart", 48 | "rule_id": "prestart", 49 | "is_device_based": false, 50 | "secondary_exposures": [], 51 | "id_type": "userID" 52 | }, 53 | "511441633": { 54 | "name": "511441633", 55 | "value": {}, 56 | "group": "", 57 | "rule_id": "", 58 | "is_device_based": false, 59 | "secondary_exposures": [], 60 | "id_type": null, 61 | "is_user_in_experiment": false, 62 | "is_experiment_active": false 63 | }, 64 | "2888658354": { 65 | "name": "2888658354", 66 | "value": { 67 | "sample_parameter": false 68 | }, 69 | "group": "5yQbPMfmKQdiRV35hS3B2l", 70 | "rule_id": "5yQbPMfmKQdiRV35hS3B2l", 71 | "is_device_based": false, 72 | "secondary_exposures": [], 73 | "group_name": "Control", 74 | "id_type": "userID", 75 | "is_user_in_experiment": true, 76 | "is_experiment_active": true 77 | }, 78 | "3591394191": { 79 | "name": "3591394191", 80 | "value": { 81 | "number": 7, 82 | "string": "statsig", 83 | "boolean": false 84 | }, 85 | "passed": true, 86 | "group": "4lInPNRUnjUzaWNkEWLFA9", 87 | "rule_id": "4lInPNRUnjUzaWNkEWLFA9", 88 | "is_device_based": false, 89 | "secondary_exposures": [], 90 | "id_type": "userID" 91 | } 92 | }, 93 | "layer_configs": { 94 | "694636833": { 95 | "name": "694636833", 96 | "value": {}, 97 | "group": "default", 98 | "rule_id": "default", 99 | "is_device_based": false, 100 | "secondary_exposures": [], 101 | "explicit_parameters": [], 102 | "undelegated_secondary_exposures": [] 103 | }, 104 | "1015384917": { 105 | "name": "1015384917", 106 | "value": {}, 107 | "group": "", 108 | "rule_id": "", 109 | "is_device_based": false, 110 | "secondary_exposures": [], 111 | "explicit_parameters": [], 112 | "allocated_experiment_name": "511441633", 113 | "is_experiment_active": false, 114 | "is_user_in_experiment": false, 115 | "undelegated_secondary_exposures": [] 116 | }, 117 | "2829289959": { 118 | "name": "2829289959", 119 | "value": { 120 | "sample_parameter": false 121 | }, 122 | "group": "5yQbPMfmKQdiRV35hS3B2l", 123 | "rule_id": "5yQbPMfmKQdiRV35hS3B2l", 124 | "is_device_based": false, 125 | "secondary_exposures": [], 126 | "group_name": "Control", 127 | "explicit_parameters": [], 128 | "allocated_experiment_name": "2888658354", 129 | "is_experiment_active": true, 130 | "is_user_in_experiment": true, 131 | "undelegated_secondary_exposures": [] 132 | } 133 | }, 134 | "sdkParams": {}, 135 | "has_updates": true, 136 | "generator": "statsig-node-sdk", 137 | "sdkInfo": { 138 | "sdkType": "statsig-node" 139 | }, 140 | "evaluated_keys": { 141 | "userID": "123" 142 | }, 143 | "hash_used": "djb2", 144 | "user": { 145 | "userID": "123", 146 | "email": "testuser@statsig.com" 147 | } 148 | } -------------------------------------------------------------------------------- /src/__tests__/data/layer_exposure_download_config_specs.json: -------------------------------------------------------------------------------- 1 | { 2 | "has_updates": true, 3 | "feature_gates": [], 4 | "dynamic_configs": [ 5 | { 6 | "__________________________USED_BY_TESTS": [ 7 | "test_explicit_vs_implicit_parameter_logging" 8 | ], 9 | "name": "experiment", 10 | "type": "dynamic_config", 11 | "salt": "58d0f242-4533-4601-abf7-126aa8f43868", 12 | "enabled": true, 13 | "defaultValue": { 14 | "an_int": 0, 15 | "a_string": "layer_default" 16 | }, 17 | "rules": [ 18 | { 19 | "name": "alwaysPass", 20 | "passPercentage": 100, 21 | "conditions": [ 22 | { 23 | "type": "public", 24 | "targetValue": null, 25 | "operator": null, 26 | "field": null, 27 | "additionalValues": {}, 28 | "isDeviceBased": false, 29 | "idType": "userID" 30 | } 31 | ], 32 | "returnValue": { 33 | "an_int": 99, 34 | "a_string": "exp_value" 35 | }, 36 | "id": "alwaysPass", 37 | "salt": "", 38 | "isDeviceBased": false, 39 | "idType": "userID" 40 | } 41 | ], 42 | "isDeviceBased": false, 43 | "idType": "userID", 44 | "entity": "experiment", 45 | "explicitParameters": ["an_int"] 46 | } 47 | ], 48 | "layer_configs": [ 49 | { 50 | "__________________________USED_BY_TESTS": [ 51 | "test_does_not_log_on_get_layer", 52 | "test_does_not_log_on_invalid_type", 53 | "test_does_not_log_non_existent_keys", 54 | "test_unallocated_layer_logging", 55 | "test_logs_user_and_event_name" 56 | ], 57 | "name": "unallocated_layer", 58 | "type": "dynamic_config", 59 | "salt": "3e361046-bc69-4dfd-bbb1-538afe609157", 60 | "enabled": true, 61 | "defaultValue": { 62 | "an_int": 99 63 | }, 64 | "rules": [], 65 | "isDeviceBased": false, 66 | "idType": "userID", 67 | "entity": "layer" 68 | }, 69 | { 70 | "__________________________USED_BY_TESTS": [ 71 | "test_explicit_vs_implicit_parameter_logging" 72 | ], 73 | "name": "explicit_vs_implicit_parameter_layer", 74 | "type": "dynamic_config", 75 | "salt": "3e361046-bc69-4dfd-bbb1-538afe609157", 76 | "enabled": true, 77 | "defaultValue": { 78 | "an_int": 0, 79 | "a_string": "layer_default" 80 | }, 81 | "rules": [ 82 | { 83 | "name": "experimentAssignment", 84 | "passPercentage": 100, 85 | "conditions": [ 86 | { 87 | "type": "public", 88 | "targetValue": null, 89 | "operator": null, 90 | "field": null, 91 | "additionalValues": {}, 92 | "isDeviceBased": false, 93 | "idType": "userID" 94 | } 95 | ], 96 | "returnValue": { 97 | "an_int": 0, 98 | "a_string": "layer_default" 99 | }, 100 | "id": "experimentAssignment", 101 | "salt": "", 102 | "isDeviceBased": false, 103 | "idType": "userID", 104 | "configDelegate": "experiment" 105 | } 106 | ], 107 | "isDeviceBased": false, 108 | "idType": "userID", 109 | "entity": "layer" 110 | }, 111 | { 112 | "__________________________USED_BY_TESTS": [ 113 | "test_different_object_type_logging" 114 | ], 115 | "name": "different_object_type_logging_layer", 116 | "type": "dynamic_config", 117 | "salt": "3e361046-bc69-4dfd-bbb1-538afe609157", 118 | "enabled": true, 119 | "defaultValue": { 120 | "a_bool": true, 121 | "an_int": 99, 122 | "a_double": 1.23, 123 | "a_long": 9223372036854776000, 124 | "a_string": "value", 125 | "an_array": ["a", "b"], 126 | "an_object": { 127 | "key": "value" 128 | } 129 | }, 130 | "rules": [], 131 | "isDeviceBased": false, 132 | "idType": "userID", 133 | "entity": "layer" 134 | }, 135 | { 136 | "__________________________USED_BY_TESTS": ["test_custom_id_layer"], 137 | "name": "test_custom_id_layer", 138 | "type": "dynamic_config", 139 | "salt": "3e361046-bc69-4dfd-bbb1-538afe609157", 140 | "enabled": true, 141 | "defaultValue": { 142 | "a_bool": true, 143 | "an_int": 99, 144 | "a_double": 1.23, 145 | "a_long": 9223372036854776000, 146 | "a_string": "value", 147 | "an_array": ["a", "b"], 148 | "an_object": { 149 | "key": "value" 150 | } 151 | }, 152 | "rules": [], 153 | "isDeviceBased": false, 154 | "idType": "companyID", 155 | "entity": "layer" 156 | } 157 | ] 158 | } 159 | -------------------------------------------------------------------------------- /src/interfaces/IDataAdapter.ts: -------------------------------------------------------------------------------- 1 | export type AdapterResponse = { 2 | result?: string | object; 3 | time?: number; 4 | error?: Error; 5 | }; 6 | 7 | const STATSIG_PREFIX = 'statsig'; 8 | 9 | export enum DataAdapterKeyPath { 10 | V1Rulesets = '/v1/download_config_specs', 11 | V2Rulesets = '/v2/download_config_specs', 12 | V1IDLists = '/v1/get_id_lists', 13 | IDList = 'id_list', 14 | } 15 | 16 | export enum CompressFormat { 17 | PlainText = 'plain_text', 18 | Gzip = 'gzip', // We don't support this yet, for future usage 19 | } 20 | 21 | export function getDataAdapterKey( 22 | hashedSDKKey: string, 23 | path: DataAdapterKeyPath, 24 | format: CompressFormat = CompressFormat.PlainText, 25 | idListName: string | undefined = undefined, 26 | ): string { 27 | if (path == DataAdapterKeyPath.IDList) { 28 | return `${STATSIG_PREFIX}|${path}::${String(idListName)}|${format}|${hashedSDKKey}`; 29 | } else { 30 | return `${STATSIG_PREFIX}|${path}|${format}|${hashedSDKKey}`; 31 | } 32 | } 33 | 34 | /** 35 | * An adapter for implementing custom storage of config specs. 36 | * Useful for backing up data in memory. 37 | * Can also be used to bootstrap Statsig server. 38 | */ 39 | export interface IDataAdapter { 40 | /** 41 | * Returns the data stored for a specific key 42 | * @param key - Key of stored item to fetch 43 | */ 44 | get(key: string): Promise; 45 | 46 | /** 47 | * Updates data stored for each key 48 | * @param key - Key of stored item to update 49 | * @param value - New value to store 50 | * @param time - Time of update 51 | */ 52 | set(key: string, value: string, time?: number): Promise; 53 | 54 | /** 55 | * Startup tasks to run before any fetch/update calls can be made 56 | */ 57 | initialize(): Promise; 58 | 59 | /** 60 | * Cleanup tasks to run when statsig is shutdown 61 | */ 62 | shutdown(): Promise; 63 | 64 | /** 65 | * Determines whether the SDK should poll for updates from 66 | * the data adapter for the given key 67 | * @param key - Key of stored item to poll from data adapter 68 | */ 69 | supportsPollingUpdatesFor?(key: DataAdapterKeyPath): boolean; 70 | } 71 | -------------------------------------------------------------------------------- /src/interfaces/IUserPersistentStorage.ts: -------------------------------------------------------------------------------- 1 | import { SecondaryExposure } from '../LogEvent'; 2 | 3 | // The properties of this struct must fit a universal schema that 4 | // when JSON-ified, can be parsed by every SDK supporting user persistent evaluation. 5 | export type StickyValues = { 6 | value: boolean; 7 | json_value: Record; 8 | rule_id: string; 9 | group_name: string | null; 10 | secondary_exposures: SecondaryExposure[]; 11 | undelegated_secondary_exposures: SecondaryExposure[]; 12 | config_delegate: string | null; 13 | explicit_parameters: string[] | null; 14 | time: number; 15 | configVersion?: number | undefined; 16 | }; 17 | 18 | export type UserPersistedValues = Record; 19 | 20 | /** 21 | * A storage adapter for persisted values. Can be used for sticky bucketing users in experiments. 22 | */ 23 | export interface IUserPersistentStorage { 24 | /** 25 | * Returns the full map of persisted values for a specific user key 26 | * @param key user key 27 | */ 28 | load(key: string): UserPersistedValues; 29 | 30 | /** 31 | * Save the persisted values of a config given a specific user key 32 | * @param key user key 33 | * @param configName Name of the config/experiment 34 | * @param data Object representing the persistent assignment to store for the given user-config 35 | */ 36 | save(key: string, configName: string, data: StickyValues): void; 37 | 38 | /** 39 | * Delete the persisted values of a config given a specific user key 40 | * @param key user key 41 | * @param configName Name of the config/experiment 42 | */ 43 | delete(key: string, configName: string): void; 44 | } 45 | -------------------------------------------------------------------------------- /src/test_utils/CheckGateTestUtils.ts: -------------------------------------------------------------------------------- 1 | import { StatsigUser } from '../StatsigUser'; 2 | 3 | const checkGateAssertion = ( 4 | statsig: { 5 | checkGate: (user: StatsigUser, gateName: string) => Promise; 6 | }, 7 | user: StatsigUser, 8 | gateName: string, 9 | expectedValue: boolean, 10 | ) => { 11 | const gateResult = statsig.checkGate(user, gateName); 12 | expect(gateResult).toBe(expectedValue); 13 | }; 14 | 15 | export { checkGateAssertion }; 16 | -------------------------------------------------------------------------------- /src/utils/Base64.ts: -------------------------------------------------------------------------------- 1 | // Encoding logic from https://stackoverflow.com/a/246813/1524355, with slight modifications to make it work for binary strings 2 | const KEY_STR = 3 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; 4 | 5 | export abstract class Base64 { 6 | static encodeArrayBuffer(buffer: ArrayBuffer): string { 7 | let binary = ''; 8 | const bytes = new Uint8Array(buffer); 9 | const len = bytes.byteLength; 10 | for (let i = 0; i < len; i++) { 11 | binary += String.fromCharCode(bytes[i]); 12 | } 13 | return this._encodeBinary(binary); 14 | } 15 | 16 | static _encodeBinary(value: string): string { 17 | let output = ''; 18 | let chr1: number; 19 | let chr2: number; 20 | let chr3: number; 21 | let enc1: number; 22 | let enc2: number; 23 | let enc3: number; 24 | let enc4: number; 25 | let i = 0; 26 | 27 | while (i < value.length) { 28 | chr1 = value.charCodeAt(i++); 29 | chr2 = value.charCodeAt(i++); 30 | chr3 = value.charCodeAt(i++); 31 | 32 | enc1 = chr1 >> 2; 33 | enc2 = ((chr1 & 3) << 4) | (chr2 >> 4); 34 | enc3 = ((chr2 & 15) << 2) | (chr3 >> 6); 35 | enc4 = chr3 & 63; 36 | 37 | if (isNaN(chr2)) { 38 | enc3 = enc4 = 64; 39 | } else if (isNaN(chr3)) { 40 | enc4 = 64; 41 | } 42 | 43 | output = 44 | output + 45 | KEY_STR.charAt(enc1) + 46 | KEY_STR.charAt(enc2) + 47 | KEY_STR.charAt(enc3) + 48 | KEY_STR.charAt(enc4); 49 | } 50 | 51 | return output; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/utils/Dispatcher.ts: -------------------------------------------------------------------------------- 1 | type Entry = { 2 | expiry: number; 3 | promise: Promise; 4 | taskCompleted: boolean; 5 | resolver?: ((value: Response | PromiseLike) => void) | null; 6 | rejector?: ((error?: any) => void) | null; 7 | }; 8 | 9 | export default class Dispatcher { 10 | private queue: Entry[]; 11 | private drainInterval: number; 12 | private drainTimer: NodeJS.Timer; 13 | 14 | constructor(drainIntervalms = 200) { 15 | this.queue = []; 16 | this.drainInterval = drainIntervalms; 17 | this.drainTimer = this._scheduleDrain(); 18 | } 19 | 20 | public enqueue( 21 | promise: Promise, 22 | timeoutms: number, 23 | ): Promise { 24 | const entry: Entry = { 25 | expiry: Date.now() + timeoutms, 26 | promise: promise, 27 | taskCompleted: false, 28 | resolver: null, 29 | rejector: null, 30 | }; 31 | 32 | const dispatcherPromise = new Promise((res, rej) => { 33 | entry.resolver = res; 34 | entry.rejector = rej; 35 | }); 36 | 37 | this.queue.push(entry); 38 | 39 | const markCompleted = ((e: Entry) => { 40 | e.taskCompleted = true; 41 | }).bind(this); 42 | 43 | promise.then( 44 | (result) => { 45 | markCompleted(entry); 46 | if (entry.resolver != null) { 47 | entry.resolver(result); 48 | } 49 | return result; 50 | }, 51 | (err) => { 52 | markCompleted(entry); 53 | if (entry.rejector != null) { 54 | entry.rejector(); 55 | } 56 | return err; 57 | }, 58 | ); 59 | 60 | return dispatcherPromise; 61 | } 62 | 63 | private _scheduleDrain(): NodeJS.Timer { 64 | const drain = setTimeout(this._drainQueue.bind(this), this.drainInterval); 65 | if (drain.unref) { 66 | drain.unref(); 67 | } 68 | return drain; 69 | } 70 | 71 | private _drainQueue() { 72 | const oldQueue = this.queue; 73 | this.queue = []; 74 | const now = Date.now(); 75 | oldQueue.forEach((entry) => { 76 | if (!entry.taskCompleted) { 77 | if (entry.expiry > now) { 78 | this.queue.push(entry); 79 | } else { 80 | if (entry.rejector != null) { 81 | entry.rejector('time_out'); 82 | } 83 | } 84 | } 85 | }, this); 86 | 87 | this.drainTimer = this._scheduleDrain(); 88 | } 89 | 90 | public shutdown() { 91 | if (this.drainTimer != null) { 92 | clearTimeout(this.drainTimer); 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/utils/Hashing.ts: -------------------------------------------------------------------------------- 1 | import { Base64 } from './Base64'; 2 | import { SHA256 } from './Sha256'; 3 | 4 | export type HashingAlgorithm = 'sha256' | 'djb2' | 'none'; 5 | 6 | function fasthash(value: string): number { 7 | let hash = 0; 8 | for (let i = 0; i < value.length; i++) { 9 | const character = value.charCodeAt(i); 10 | hash = (hash << 5) - hash + character; 11 | hash = hash & hash; // Convert to 32bit integer 12 | } 13 | return hash; 14 | } 15 | 16 | export function djb2Hash(value: string): string { 17 | return String(fasthash(value) >>> 0); 18 | } 19 | 20 | export function djb2HashForObject( 21 | object: Record | null, 22 | ): string { 23 | return djb2Hash(JSON.stringify(getSortedObject(object))); 24 | } 25 | 26 | export function sha256HashBase64(name: string) { 27 | const buffer = SHA256(name); 28 | return Base64.encodeArrayBuffer(buffer.arrayBuffer()); 29 | } 30 | 31 | export function sha256Hash(name: string): DataView { 32 | return SHA256(name).dataView(); 33 | } 34 | 35 | export function hashString( 36 | str: string, 37 | algorithm: HashingAlgorithm = 'djb2', 38 | ): string { 39 | switch (algorithm) { 40 | case 'sha256': 41 | return sha256HashBase64(str); 42 | case 'djb2': 43 | return djb2Hash(str); 44 | default: 45 | return str; 46 | } 47 | } 48 | 49 | export function hashUnitIDForIDList( 50 | unitID: string, 51 | algorithm: HashingAlgorithm = 'sha256', // Big idlists blob use sha256 for hashing 52 | ) { 53 | if (typeof unitID !== 'string' || unitID == null) { 54 | return ''; 55 | } 56 | return hashString(unitID, algorithm).substr(0, 8); 57 | } 58 | 59 | function getSortedObject( 60 | object: Record | null, 61 | ): Record | null { 62 | if (object == null) { 63 | return null; 64 | } 65 | const keys = Object.keys(object).sort(); 66 | const sortedObject: Record = {}; 67 | keys.forEach((key) => { 68 | let value = object[key]; 69 | if (value instanceof Object) { 70 | value = getSortedObject(value as Record); 71 | } 72 | 73 | sortedObject[key] = value; 74 | }); 75 | return sortedObject; 76 | } 77 | -------------------------------------------------------------------------------- /src/utils/IDListUtil.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CompressFormat, 3 | DataAdapterKeyPath, 4 | getDataAdapterKey, 5 | IDataAdapter, 6 | } from '../interfaces/IDataAdapter'; 7 | 8 | export type IDList = { 9 | creationTime: number; 10 | fileID: string; 11 | ids: Record; 12 | readBytes: number; 13 | url: string; 14 | }; 15 | 16 | export type IDListsLookupResponse = Record< 17 | string, 18 | { 19 | url?: string; 20 | fileID?: string; 21 | creationTime: number; 22 | size?: number; 23 | } 24 | >; 25 | 26 | export type IDListsLookupBootstrap = string[]; 27 | 28 | export default abstract class IDListUtil { 29 | // Typecheck the response from the network 30 | static parseLookupResponse(input: unknown): IDListsLookupResponse | null { 31 | if (typeof (input ?? undefined) !== 'object') { 32 | return null; 33 | } 34 | 35 | return input as IDListsLookupResponse; 36 | } 37 | 38 | static parseBootstrapLookup( 39 | input: string | object, 40 | ): IDListsLookupBootstrap | null { 41 | try { 42 | const result = typeof input === 'string' ? JSON.parse(input) : input; 43 | if (Array.isArray(result)) { 44 | return result as IDListsLookupBootstrap; 45 | } 46 | if (typeof result == 'object') { 47 | return Object.keys(result); 48 | } 49 | } catch (e) { 50 | /* noop */ 51 | } 52 | return null; 53 | } 54 | 55 | // Run any additions/subtractions from the ID lists file 56 | static updateIdList( 57 | lists: Record, 58 | name: string, 59 | data: string, 60 | ) { 61 | const lines = data.split(/\r?\n/); 62 | if (data.charAt(0) !== '+' && data.charAt(0) !== '-') { 63 | delete lists[name]; 64 | throw new Error('Seek range invalid.'); 65 | } 66 | 67 | for (const line of lines) { 68 | if (line.length <= 1) { 69 | continue; 70 | } 71 | 72 | const id = line.slice(1).trim(); 73 | if (line.charAt(0) === '+') { 74 | lists[name].ids[id] = true; 75 | } else if (line.charAt(0) === '-') { 76 | delete lists[name].ids[id]; 77 | } 78 | } 79 | } 80 | 81 | // Remove any old ID lists that are no longer in the Lookup 82 | static removeOldIdLists( 83 | lists: Record, 84 | lookup: IDListsLookupResponse, 85 | ) { 86 | const deletedLists = []; 87 | for (const name in lists) { 88 | if ( 89 | Object.prototype.hasOwnProperty.call(lists, name) && 90 | !Object.prototype.hasOwnProperty.call(lookup, name) 91 | ) { 92 | deletedLists.push(name); 93 | } 94 | } 95 | 96 | for (const name in deletedLists) { 97 | delete lists[name]; 98 | } 99 | } 100 | 101 | static async saveToDataAdapter( 102 | hashedSDKKey: string, 103 | dataAdapter: IDataAdapter, 104 | lists: Record, 105 | lookup: IDListsLookupResponse, 106 | ): Promise { 107 | const tasks: Promise[] = []; 108 | 109 | for (const [key, value] of Object.entries(lists)) { 110 | let ids = ''; 111 | for (const prop in value.ids) { 112 | if (!Object.prototype.hasOwnProperty.call(value.ids, prop)) continue; 113 | ids += `+${prop}\n`; 114 | } 115 | tasks.push( 116 | dataAdapter.set( 117 | getDataAdapterKey( 118 | hashedSDKKey, 119 | DataAdapterKeyPath.IDList, 120 | CompressFormat.PlainText, 121 | key, 122 | ), 123 | ids, 124 | ), 125 | ); 126 | } 127 | 128 | await Promise.all(tasks); 129 | 130 | await dataAdapter.set( 131 | getDataAdapterKey(hashedSDKKey, DataAdapterKeyPath.V1IDLists), 132 | JSON.stringify(lookup), 133 | ); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/utils/LogEventValidator.ts: -------------------------------------------------------------------------------- 1 | import OutputLogger from '../OutputLogger'; 2 | import { StatsigUser } from '../StatsigUser'; 3 | 4 | const MAX_VALUE_SIZE = 128; 5 | export const MAX_OBJ_SIZE = 4096; 6 | const MAX_USER_SIZE = 4096; 7 | 8 | export default class LogEventValidator { 9 | public static validateEventName(eventName: string): string | null { 10 | if ( 11 | eventName == null || 12 | eventName.length === 0 || 13 | typeof eventName !== 'string' 14 | ) { 15 | OutputLogger.error( 16 | 'statsigSDK> EventName needs to be a string of non-zero length.', 17 | ); 18 | return null; 19 | } 20 | if (this.shouldTrimParam(eventName, MAX_VALUE_SIZE)) { 21 | OutputLogger.warn( 22 | `statsigSDK> Event name is too large (max ${MAX_VALUE_SIZE}). It may be trimmed.`, 23 | ); 24 | } 25 | return eventName; 26 | } 27 | 28 | public static validateUserObject(user: StatsigUser): StatsigUser | null { 29 | if (user == null) { 30 | OutputLogger.warn('statsigSDK> User cannot be null.'); 31 | return null; 32 | } 33 | if (user != null && typeof user !== 'object') { 34 | OutputLogger.warn( 35 | 'statsigSDK> User is not set because it needs to be an object.', 36 | ); 37 | return null; 38 | } 39 | 40 | if ( 41 | user.userID != null && 42 | this.shouldTrimParam(user.userID, MAX_VALUE_SIZE) 43 | ) { 44 | OutputLogger.warn( 45 | `statsigSDK> User ID is too large (max ${MAX_VALUE_SIZE}). It may be trimmed.`, 46 | ); 47 | } 48 | 49 | if (this.shouldTrimParam(user, MAX_USER_SIZE)) { 50 | OutputLogger.warn( 51 | `statsigSDK> User object is too large (max ${MAX_USER_SIZE}). Some attributes may be stripped.`, 52 | ); 53 | } 54 | return user; 55 | } 56 | 57 | public static validateEventValue( 58 | value: string | number | null, 59 | ): string | number | null { 60 | if (value == null) { 61 | return null; 62 | } 63 | if ( 64 | typeof value === 'string' && 65 | this.shouldTrimParam(value, MAX_VALUE_SIZE) 66 | ) { 67 | OutputLogger.warn( 68 | `statsigSDK> Event value is too large (max ${MAX_VALUE_SIZE}). It may be trimmed.`, 69 | ); 70 | } 71 | if (typeof value === 'object') { 72 | return JSON.stringify(value); 73 | } else if (typeof value === 'number') { 74 | return value; 75 | } else { 76 | return value.toString(); 77 | } 78 | } 79 | 80 | public static validateEventMetadata( 81 | metadata: Record | null, 82 | ): Record | null { 83 | if (metadata != null && typeof metadata !== 'object') { 84 | OutputLogger.warn( 85 | 'statsigSDK> Metadata is not set because it needs to be an object.', 86 | ); 87 | return null; 88 | } 89 | if (this.shouldTrimParam(metadata, MAX_OBJ_SIZE)) { 90 | OutputLogger.warn( 91 | `statsigSDK> Event metadata is too large (max ${MAX_OBJ_SIZE}). Some attributes may be stripped.`, 92 | ); 93 | } 94 | return metadata; 95 | } 96 | 97 | public static validateEventTime(time: number): number | null { 98 | if (time != null && typeof time !== 'number') { 99 | OutputLogger.warn( 100 | 'statsigSDK> Timestamp is not set because it needs to be a number.', 101 | ); 102 | return null; 103 | } 104 | return time; 105 | } 106 | 107 | private static shouldTrimParam( 108 | param: object | string | number | null | unknown, 109 | size: number, 110 | ): boolean { 111 | if (param == null) return false; 112 | if (typeof param === 'string') return param.length > size; 113 | if (typeof param === 'object') { 114 | return this.approximateObjectSize(param) > size; 115 | } 116 | if (typeof param === 'number') return param.toString().length > size; 117 | return false; 118 | } 119 | 120 | public static approximateObjectSize(x: object): number { 121 | let size = 0; 122 | const entries = Object.entries(x); 123 | for (let i = 0; i < entries.length; i++) { 124 | const key = entries[i][0]; 125 | const value = entries[i][1] as unknown; 126 | if (typeof value === 'object' && value !== null) { 127 | size += this.approximateObjectSize(value); 128 | } else { 129 | size += String(value).length; 130 | } 131 | size += key.length; 132 | } 133 | return size; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/utils/StatsigContext.ts: -------------------------------------------------------------------------------- 1 | import { ConfigSpec } from '../ConfigSpec'; 2 | import { 3 | InitializationDetails, 4 | InitializationSource, 5 | } from '../InitializationDetails'; 6 | import { UserPersistedValues } from '../interfaces/IUserPersistentStorage'; 7 | import { PersistentAssignmentOptions } from '../StatsigOptions'; 8 | import { StatsigUser } from '../StatsigUser'; 9 | 10 | type RequestContext = { 11 | caller?: string; 12 | configName?: string; 13 | clientKey?: string; 14 | hash?: string; 15 | eventCount?: number; 16 | bypassDedupe?: boolean; 17 | targetAppID?: string; 18 | user?: StatsigUser; 19 | spec?: ConfigSpec; 20 | userPersistedValues?: UserPersistedValues | null; 21 | persistentAssignmentOptions?: PersistentAssignmentOptions; 22 | }; 23 | 24 | export class StatsigContext { 25 | readonly startTime: number; 26 | readonly caller?: string; 27 | readonly eventCount?: number; 28 | readonly configName?: string; 29 | readonly clientKey?: string; 30 | readonly hash?: string; 31 | readonly bypassDedupe?: boolean; 32 | readonly userPersistedValues?: UserPersistedValues | null; 33 | readonly persistentAssignmentOptions?: PersistentAssignmentOptions; 34 | 35 | protected constructor(protected ctx: RequestContext) { 36 | this.startTime = Date.now(); 37 | this.caller = ctx.caller; 38 | this.eventCount = ctx.eventCount; 39 | this.configName = ctx.configName; 40 | this.clientKey = ctx.clientKey; 41 | this.hash = ctx.clientKey; 42 | this.bypassDedupe = ctx.bypassDedupe; 43 | this.userPersistedValues = ctx.userPersistedValues; 44 | this.persistentAssignmentOptions = ctx.persistentAssignmentOptions; 45 | } 46 | 47 | // Create a new context to avoid modifying context up the stack 48 | static new(ctx: RequestContext): StatsigContext { 49 | return new this(ctx); 50 | } 51 | 52 | getContextForLogging(): object { 53 | return { 54 | tag: this.caller, 55 | eventCount: this.eventCount, 56 | configName: this.configName, 57 | clientKey: this.clientKey, 58 | hash: this.clientKey, 59 | }; 60 | } 61 | 62 | getRequestContext(): RequestContext { 63 | return this.ctx; 64 | } 65 | } 66 | 67 | export class EvaluationContext extends StatsigContext { 68 | readonly user: StatsigUser; 69 | readonly spec: ConfigSpec; 70 | readonly targetAppID?: string; 71 | readonly onlyEvaluateTargeting?: boolean; 72 | 73 | protected constructor( 74 | ctx: RequestContext, 75 | user: StatsigUser, 76 | spec: ConfigSpec, 77 | targetAppID?: string, 78 | onlyEvaluateTargeting?: boolean, 79 | ) { 80 | super(ctx); 81 | this.user = user; 82 | this.spec = spec; 83 | this.targetAppID = targetAppID; 84 | this.onlyEvaluateTargeting = onlyEvaluateTargeting; 85 | } 86 | 87 | public static new( 88 | ctx: RequestContext & Required>, 89 | ): EvaluationContext { 90 | const { user, spec, ...optionalCtx } = ctx; 91 | return new this(optionalCtx, user, spec); 92 | } 93 | 94 | public static get( 95 | ctx: RequestContext, 96 | evalCtx: { 97 | user: StatsigUser; 98 | spec: ConfigSpec; 99 | targetAppID?: string; 100 | onlyEvaluateTargeting?: boolean; 101 | }, 102 | ): EvaluationContext { 103 | return new EvaluationContext( 104 | ctx, 105 | evalCtx.user, 106 | evalCtx.spec, 107 | evalCtx.targetAppID, 108 | evalCtx.onlyEvaluateTargeting, 109 | ); 110 | } 111 | } 112 | 113 | export class InitializeContext extends StatsigContext { 114 | readonly sdkKey: string; 115 | private success: boolean; 116 | private error?: Error; 117 | private source?: InitializationSource; 118 | 119 | protected constructor(ctx: RequestContext, sdkKey: string) { 120 | super(ctx); 121 | this.sdkKey = sdkKey; 122 | this.success = true; 123 | } 124 | 125 | public static new( 126 | ctx: RequestContext & { 127 | sdkKey: string; 128 | }, 129 | ): InitializeContext { 130 | return new this(ctx, ctx.sdkKey); 131 | } 132 | 133 | public setSuccess(source: InitializationSource): void { 134 | this.success = true; 135 | this.source = source; 136 | } 137 | 138 | public setFailed(error?: Error): void { 139 | this.success = false; 140 | this.error = error; 141 | } 142 | 143 | public getInitDetails(): InitializationDetails { 144 | return { 145 | duration: Date.now() - this.startTime, 146 | success: this.success, 147 | error: this.error, 148 | source: this.source, 149 | }; 150 | } 151 | } 152 | 153 | export class GlobalContext { 154 | // @ts-ignore 155 | static isEdgeEnvironment = typeof EdgeRuntime === 'string'; 156 | } 157 | -------------------------------------------------------------------------------- /src/utils/__tests__/Sha256.test.ts: -------------------------------------------------------------------------------- 1 | import shajs from 'sha.js'; 2 | 3 | import { Base64 } from '../Base64'; 4 | import { SHA256 } from '../Sha256'; 5 | 6 | function getExpectedHash(value: string) { 7 | const buffer = shajs('sha256').update(value).digest(); 8 | return Base64.encodeArrayBuffer(buffer); 9 | } 10 | 11 | function getActualHash(value: string) { 12 | const buffer = SHA256(value); 13 | return Base64.encodeArrayBuffer(buffer.arrayBuffer()); 14 | } 15 | 16 | function generateRandomWordFromArray(inputArray: string[]) { 17 | if (inputArray.length < 3) { 18 | throw new Error('Array must have at least 3 elements'); 19 | } 20 | 21 | const randomIndices: number[] = []; 22 | while (randomIndices.length < 3) { 23 | const randomIndex = Math.floor(Math.random() * inputArray.length); 24 | if (!randomIndices.includes(randomIndex)) { 25 | randomIndices.push(randomIndex); 26 | } 27 | } 28 | 29 | const randomWords = randomIndices.map((index) => inputArray[index]); 30 | const randomWord = randomWords.join('_'); 31 | 32 | return randomWord; 33 | } 34 | 35 | function generateTestCases(count: number) { 36 | const words = [ 37 | 'apple', 38 | 'banana', 39 | 'orange', 40 | 'grape', 41 | 'kiwi', 42 | 'strawberry', 43 | 'melon', 44 | 'carrot', 45 | 'potato', 46 | 'broccoli', 47 | 'pepper', 48 | 'tomato', 49 | 'cucumber', 50 | 'lettuce', 51 | 'dog', 52 | 'cat', 53 | 'bird', 54 | 'fish', 55 | 'rabbit', 56 | 'hamster', 57 | 'turtle', 58 | 'horse', 59 | '大', 60 | 'بزرگ', 61 | '123', 62 | '980$@', 63 | ]; 64 | 65 | const randomWords: string[] = []; 66 | 67 | for (let i = 0; i < count; i++) { 68 | const word = generateRandomWordFromArray(words); 69 | randomWords.push(word); 70 | } 71 | 72 | return randomWords; 73 | } 74 | 75 | const ITERATIONS = 5000; 76 | 77 | describe('Sha256 Results', () => { 78 | test.each(generateTestCases(10))('%s', (word) => { 79 | expect.assertions(ITERATIONS); 80 | 81 | for (let i = 0; i < ITERATIONS; i++) { 82 | const expected = getExpectedHash(word); 83 | const actual = getActualHash(word); 84 | expect(actual).toEqual(expected); 85 | } 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /src/utils/__tests__/StatsigFetcher.test.ts: -------------------------------------------------------------------------------- 1 | import { OptionsWithDefaults } from '../../StatsigOptions'; 2 | import StatsigFetcher from '../StatsigFetcher'; 3 | 4 | let calls = 0; 5 | 6 | jest.mock('node-fetch', () => jest.fn()); 7 | // @ts-ignore 8 | const fetch = require('node-fetch'); 9 | // @ts-ignore 10 | fetch.mockImplementation((_url) => { 11 | calls++; 12 | if (calls == 1) { 13 | return Promise.reject(); 14 | } else if (calls == 2) { 15 | return Promise.resolve({ 16 | ok: true, 17 | status: 500, 18 | json: () => 19 | Promise.resolve({ 20 | name: 'gate_server', 21 | value: true, 22 | rule_id: 'rule_id_gate_server', 23 | }), 24 | }); 25 | } else { 26 | return Promise.resolve({ 27 | ok: true, 28 | status: 200, 29 | json: () => 30 | Promise.resolve({ 31 | value: true, 32 | }), 33 | }); 34 | } 35 | }); 36 | 37 | describe('Verify behavior of top level index functions', () => { 38 | let fetcher = new StatsigFetcher('secret-', OptionsWithDefaults({})); 39 | beforeEach(() => { 40 | jest.restoreAllMocks(); 41 | jest.resetModules(); 42 | 43 | fetcher = new StatsigFetcher('secret-', OptionsWithDefaults({})); 44 | }); 45 | 46 | test('Test retries', async () => { 47 | const spy = jest.spyOn(fetcher, 'request'); 48 | const result = await fetcher.request( 49 | 'POST', 50 | 'https://statsigapi.net/v1/test', 51 | { test: 123 }, 52 | { 53 | retries: 5, 54 | backoff: 10, 55 | } 56 | ); 57 | expect(spy).toHaveBeenCalledTimes(3); 58 | // @ts-ignore 59 | const json = await result.json(); 60 | expect(json).toEqual({ value: true }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/utils/__tests__/parseUserAgent.test.ts: -------------------------------------------------------------------------------- 1 | import parseUserAgent from '../parseUserAgent'; 2 | 3 | const mockUAParser = jest.fn(); 4 | jest.mock('ua-parser-js', () => ({ 5 | __esModule: true, 6 | default: (uaString: string) => mockUAParser(uaString), 7 | })); 8 | 9 | describe('parseUserAgent', () => { 10 | beforeEach(() => { 11 | jest.resetAllMocks(); 12 | }); 13 | 14 | it('returns the parsed user agent', () => { 15 | const uaString = 16 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36'; 17 | 18 | const mockRes = { 19 | ua: uaString, 20 | browser: { name: 'Chrome', version: '129.0.0.0', major: '129' }, 21 | engine: { name: 'Blink', version: '129.0.0.0' }, 22 | os: { name: 'Windows', version: '10' }, 23 | device: { vendor: undefined, model: undefined, type: undefined }, 24 | cpu: { architecture: 'amd64' }, 25 | }; 26 | mockUAParser.mockReturnValue(mockRes); 27 | 28 | expect(parseUserAgent(uaString)).toEqual(mockRes); 29 | }); 30 | 31 | it('replaces "Mac OS" with "Mac OS X"', () => { 32 | const uaString = 33 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36'; 34 | 35 | mockUAParser.mockReturnValue({ 36 | ua: uaString, 37 | browser: { name: 'Chrome', version: '129.0.0.0', major: '129' }, 38 | engine: { name: 'Blink', version: '129.0.0.0' }, 39 | os: { name: 'Mac OS', version: '10.15.7' }, 40 | device: { vendor: 'Apple', model: 'Macintosh', type: undefined }, 41 | cpu: { architecture: undefined }, 42 | }); 43 | 44 | expect(parseUserAgent(uaString).os.name).toEqual('Mac OS X'); 45 | }); 46 | 47 | it('adds "Mobile" to mobile browser names', () => { 48 | const uaString = 49 | 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.36'; 50 | 51 | mockUAParser.mockReturnValue({ 52 | ua: uaString, 53 | browser: { name: 'Chrome', version: '116.0.0.0', major: '116' }, 54 | engine: { name: 'Blink', version: '116.0.0.0' }, 55 | os: { name: 'Android', version: '13' }, 56 | device: { vendor: 'Google', model: 'Pixel 7', type: 'mobile' }, 57 | cpu: { architecture: undefined }, 58 | }); 59 | 60 | expect(parseUserAgent(uaString).browser.name).toEqual('Chrome Mobile'); 61 | }); 62 | 63 | it('memoizes the most recent call', () => { 64 | const uaString = 65 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36'; 66 | 67 | const mockRes = { 68 | ua: uaString, 69 | browser: { name: 'Chrome', version: '129.0.0.0', major: '129' }, 70 | engine: { name: 'Blink', version: '129.0.0.0' }, 71 | os: { name: 'Windows', version: '10' }, 72 | device: { vendor: undefined, model: undefined, type: undefined }, 73 | cpu: { architecture: 'amd64' }, 74 | }; 75 | mockUAParser.mockReturnValue(mockRes); 76 | 77 | parseUserAgent(uaString); 78 | parseUserAgent(uaString); 79 | parseUserAgent(uaString); 80 | 81 | expect(mockUAParser).toHaveBeenCalledTimes(1); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/utils/__tests__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { getStatsigMetadata } from '../core'; 2 | 3 | const { 4 | clone, 5 | getBoolValue, 6 | getNumericValue, 7 | isUserIdentifiable, 8 | } = require('../core'); 9 | 10 | describe('Verify behavior of core utility functions', () => { 11 | beforeEach(() => { 12 | expect.hasAssertions(); 13 | }); 14 | 15 | test('Test clone', () => { 16 | expect(clone()).toBeNull(); 17 | expect(clone({})).toStrictEqual({}); 18 | expect(clone(null)).toBeNull(); 19 | expect(clone({ test: 123 })).toStrictEqual({ test: 123 }); 20 | }); 21 | 22 | test('Test getNumericValue', () => { 23 | expect(getNumericValue(null)).toStrictEqual(null); 24 | expect(getNumericValue()).toStrictEqual(null); 25 | expect(getNumericValue(10)).toStrictEqual(10); 26 | expect(getNumericValue({})).toStrictEqual(null); 27 | expect(getNumericValue('20')).toStrictEqual(20); 28 | expect(getNumericValue(10.0)).toStrictEqual(10.0); 29 | expect(getNumericValue(false)).toStrictEqual(0); 30 | expect(getNumericValue(true)).toStrictEqual(1); 31 | expect(getNumericValue('13.1')).toStrictEqual(13.1); 32 | }); 33 | 34 | test('Test getBoolValue', () => { 35 | expect(getBoolValue(null)).toBeNull(); 36 | expect(getBoolValue()).toBeNull(); 37 | expect(getBoolValue(10)).toBeNull(); 38 | expect(getBoolValue({})).toBeNull(); 39 | expect(getBoolValue('20')).toBeNull(); 40 | expect(getBoolValue(10.0)).toBeNull(); 41 | expect(getBoolValue(false)).toStrictEqual(false); 42 | expect(getBoolValue(true)).toStrictEqual(true); 43 | expect(getBoolValue('true')).toStrictEqual(true); 44 | expect(getBoolValue('false 123')).toBeNull(); 45 | expect(getBoolValue('false')).toStrictEqual(false); 46 | }); 47 | 48 | test('Test isUserIdentifiable', () => { 49 | expect(isUserIdentifiable(null)).toStrictEqual(false); 50 | expect(isUserIdentifiable({})).toStrictEqual(false); 51 | expect(isUserIdentifiable()).toStrictEqual(false); 52 | expect(isUserIdentifiable('test_user')).toStrictEqual(false); 53 | expect(isUserIdentifiable({ id: '123' })).toStrictEqual(false); 54 | expect(isUserIdentifiable({ userID: '123' })).toStrictEqual(true); 55 | expect(isUserIdentifiable({ userID: 123 })).toStrictEqual(true); 56 | }); 57 | 58 | test('Verify StatsigMetadata', () => { 59 | const metadata = getStatsigMetadata(); 60 | expect(require('../../../package.json')?.version).toEqual( 61 | metadata.sdkVersion, 62 | ); 63 | expect(process.version).toEqual('v' + metadata.languageVersion); 64 | expect('statsig-node').toEqual(metadata.sdkType); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/utils/asyncify.ts: -------------------------------------------------------------------------------- 1 | export default function asyncify( 2 | syncFunction: (...args: unknown[]) => T, 3 | ...args: unknown[] 4 | ): Promise { 5 | return new Promise((resolve, reject) => { 6 | try { 7 | const result = syncFunction(...args); 8 | resolve(result); 9 | } catch (error) { 10 | reject(error); 11 | } 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/core.ts: -------------------------------------------------------------------------------- 1 | import { StatsigUser } from '../StatsigUser'; 2 | 3 | function getSDKVersion(): string { 4 | try { 5 | return require('../../package.json')?.version ?? ''; 6 | } catch (err) { 7 | return ''; 8 | } 9 | } 10 | 11 | function getSDKType(): string { 12 | try { 13 | return require('../../package.json')?.name ?? 'statsig-node'; 14 | } catch (err) { 15 | return 'statsig-node'; 16 | } 17 | } 18 | 19 | function notEmpty(value: TValue | null | undefined): value is TValue { 20 | return value !== null && value !== undefined; 21 | } 22 | 23 | function notEmptyObject(value: unknown | null | undefined): boolean { 24 | return ( 25 | value !== null && 26 | value !== undefined && 27 | typeof value == 'object' && 28 | Object.keys(value).length > 0 29 | ); 30 | } 31 | 32 | function clone(obj: T | null): T | null { 33 | if (obj == null) { 34 | return null; 35 | } 36 | return JSON.parse(JSON.stringify(obj)); 37 | } 38 | 39 | function cloneEnforce(obj: T): T { 40 | return JSON.parse(JSON.stringify(obj)); 41 | } 42 | 43 | // Return a number if num can be parsed to a number, otherwise return null 44 | function getNumericValue(num: unknown): number | null { 45 | if (num == null) { 46 | return null; 47 | } 48 | const n = Number(num); 49 | if (typeof n === 'number' && !isNaN(n) && isFinite(n) && num != null) { 50 | return n; 51 | } 52 | return null; 53 | } 54 | 55 | // Return the boolean value of the input if it can be casted into a boolean, null otherwise 56 | function getBoolValue(val: unknown): boolean | null { 57 | if (val == null) { 58 | return null; 59 | } else if (String(val).toLowerCase() === 'true') { 60 | return true; 61 | } else if (String(val).toLowerCase() === 'false') { 62 | return false; 63 | } 64 | return null; 65 | } 66 | 67 | export type StatsigMetadata = { 68 | sdkType: string; 69 | sdkVersion: string; 70 | languageVersion: string; 71 | sessionID?: string; 72 | }; 73 | 74 | function getStatsigMetadata( 75 | extra: Record = {}, 76 | ): StatsigMetadata { 77 | return { 78 | sdkType: getSDKType(), 79 | sdkVersion: getSDKVersion(), 80 | languageVersion: 81 | (typeof process !== 'undefined' && 82 | process && 83 | process.version && 84 | process.version.length > 1 && 85 | process.version.substring(1)) || 86 | '', 87 | ...extra, 88 | }; 89 | } 90 | 91 | function isUserIdentifiable(user: StatsigUser | null): boolean { 92 | if (user == null) return false; 93 | if (typeof user !== 'object') return false; 94 | const userID = user.userID; 95 | const customIDs = user.customIDs; 96 | return ( 97 | typeof userID === 'number' || 98 | typeof userID === 'string' || 99 | (customIDs != null && 100 | typeof customIDs === 'object' && 101 | Object.keys(customIDs).length !== 0) 102 | ); 103 | } 104 | 105 | function poll(fn: () => void, interval: number): NodeJS.Timer { 106 | const timer = setInterval(fn, interval); 107 | if (timer.unref) { 108 | timer.unref(); 109 | } 110 | return timer; 111 | } 112 | 113 | function getTypeOf(value: unknown) { 114 | return Array.isArray(value) ? 'array' : typeof value; 115 | } 116 | 117 | export { 118 | clone, 119 | cloneEnforce, 120 | getBoolValue, 121 | getNumericValue, 122 | getSDKVersion, 123 | getSDKType, 124 | getStatsigMetadata, 125 | isUserIdentifiable, 126 | notEmpty, 127 | notEmptyObject, 128 | poll, 129 | getTypeOf, 130 | }; 131 | 132 | export class ExhaustSwitchError extends Error { 133 | constructor(x: never) { 134 | super(`Unreachable case: ${JSON.stringify(x)}`); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/utils/getEncodedBody.ts: -------------------------------------------------------------------------------- 1 | import ErrorBoundary from '../ErrorBoundary'; 2 | import { StatsigContext } from './StatsigContext'; 3 | 4 | export type CompressionType = 'gzip' | 'none'; 5 | 6 | export async function getEncodedBody( 7 | body: Record | undefined, 8 | compression: CompressionType, 9 | errorBoundry: ErrorBoundary, 10 | ): Promise<{ contents: BodyInit | undefined; contentEncoding?: string }> { 11 | const bodyString = body ? JSON.stringify(body) : undefined; 12 | if (!compression || !bodyString) { 13 | return { contents: bodyString }; 14 | } 15 | 16 | try { 17 | if (compression === 'gzip') { 18 | const { gzip } = await import('zlib'); 19 | const compressed = await new Promise((resolve, reject) => { 20 | gzip(bodyString, (err, result) => { 21 | if (err) return reject(err); 22 | resolve(result); 23 | }); 24 | }); 25 | return { contents: compressed, contentEncoding: 'gzip' }; 26 | } 27 | } catch (e) { 28 | errorBoundry.logError( 29 | e, 30 | StatsigContext.new({ caller: 'getEncodedBodyAsync' }), 31 | ); 32 | // Fallback to uncompressed if import or compression fails 33 | return { contents: bodyString }; 34 | } 35 | 36 | return { contents: bodyString }; 37 | } 38 | -------------------------------------------------------------------------------- /src/utils/parseUserAgent.ts: -------------------------------------------------------------------------------- 1 | import uaparser from 'ua-parser-js'; 2 | 3 | // Memoize the most recent call 4 | const parseUserAgentMemo: 5 | | { uaString: undefined; res: undefined } 6 | | { uaString: string; res: uaparser.IResult } = { 7 | uaString: undefined, 8 | res: undefined, 9 | }; 10 | 11 | // This exists only to provide compatibility for useragent library that's used 12 | // everywhere else. 13 | export default function parseUserAgent(uaString: string) { 14 | if (parseUserAgentMemo.uaString === uaString) { 15 | return parseUserAgentMemo.res; 16 | } 17 | 18 | const res = uaparser(uaString); 19 | if (res.os.name === 'Mac OS') { 20 | res.os.name = 'Mac OS X'; 21 | } 22 | if ( 23 | (res.browser.name === 'Chrome' || res.browser.name === 'Firefox') && 24 | (res.device.type === 'mobile' || res.device.type === 'tablet') 25 | ) { 26 | res.browser.name += ' Mobile'; 27 | } 28 | 29 | parseUserAgentMemo.uaString = uaString; 30 | parseUserAgentMemo.res = res; 31 | 32 | return res; 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/safeFetch.ts: -------------------------------------------------------------------------------- 1 | import { GlobalContext } from './StatsigContext'; 2 | 3 | // @ts-ignore 4 | let nodeFetch: (...args) => Promise = null; 5 | // @ts-ignore 6 | if (!GlobalContext.isEdgeEnvironment) { 7 | try { 8 | nodeFetch = require('node-fetch'); 9 | const nfDefault = (nodeFetch as any).default; 10 | if (nfDefault && typeof nfDefault === 'function') { 11 | nodeFetch = nfDefault; 12 | } 13 | } catch (err) { 14 | // Ignore 15 | } 16 | } 17 | 18 | // @ts-ignore 19 | export default function safeFetch(...args): Promise { 20 | if (nodeFetch) { 21 | return nodeFetch(...args); 22 | } else { 23 | // @ts-ignore 24 | return fetch(...args); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*", "src/utils/*"], 3 | "exclude": [ 4 | "node_modules", 5 | "typings", 6 | "src/__tests__/*", 7 | "src/utils/__tests__/*", 8 | "dist", 9 | "dist/**/*", 10 | "dist/src/**/*", 11 | "build", 12 | "tsconfig.json" 13 | ], 14 | "compilerOptions": { 15 | "allowJs": false, 16 | "resolveJsonModule": true, 17 | "declaration": true, 18 | "outDir": "dist", 19 | "typeRoots": ["node_modules/@types"], 20 | "lib": ["ES2020.Promise"], 21 | "moduleResolution": "node", 22 | "strict": true, 23 | "esModuleInterop": true, 24 | "skipLibCheck": true, 25 | "forceConsistentCasingInFileNames": true 26 | } 27 | } 28 | --------------------------------------------------------------------------------