├── .npmignore ├── .prettierignore ├── .oxlintrc.json ├── .gitignore ├── vitest.config.mjs ├── tscheck └── example.ts ├── lib ├── flags │ ├── index.js │ ├── flags.d.ts │ ├── utils.js │ ├── remote_flags.d.ts │ ├── local_flags.d.ts │ ├── types.d.ts │ ├── flags.js │ ├── remote_flags.js │ └── local_flags.js ├── utils.js ├── groups.js ├── profile_helpers.js ├── mixpanel-node.d.ts ├── people.js └── mixpanel-node.js ├── .github └── workflows │ └── tests.yml ├── test ├── alias.js ├── config.js ├── utils.js ├── flags │ ├── utils.js │ ├── remote_flags.js │ └── local_flags.js ├── logger.js ├── import.js ├── track.js ├── send_request.js └── groups.js ├── LICENSE ├── tsconfig.json ├── package.json ├── example.js ├── history.md └── readme.md /.npmignore: -------------------------------------------------------------------------------- 1 | test/ 2 | .github/ 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | history.md 2 | -------------------------------------------------------------------------------- /.oxlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-var": "error" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | 4 | # ide files 5 | .idea 6 | 7 | .DS_Store 8 | -------------------------------------------------------------------------------- /vitest.config.mjs: -------------------------------------------------------------------------------- 1 | import { coverageConfigDefaults, defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | include: ["test/**/*.js"], 7 | coverage: { 8 | exclude: [...coverageConfigDefaults.exclude, "example.js"], 9 | }, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /tscheck/example.ts: -------------------------------------------------------------------------------- 1 | // example module where typechecking should be working both in code editors and via `tsc` 2 | 3 | import Mixpanel from "../lib/mixpanel-node.js"; 4 | 5 | const mp = Mixpanel.init("asjdf", { local_flags_config: {} }); 6 | mp.track("test event"); 7 | mp.local_flags?.getVariantValue("color", "blue", { distinct_id: "user_1" }); 8 | -------------------------------------------------------------------------------- /lib/flags/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Mixpanel Feature Flags 3 | * Exports for local and remote feature flag evaluation 4 | */ 5 | 6 | const LocalFeatureFlagsProvider = require("./local_flags"); 7 | const RemoteFeatureFlagsProvider = require("./remote_flags"); 8 | 9 | module.exports = { 10 | LocalFeatureFlagsProvider, 11 | RemoteFeatureFlagsProvider, 12 | }; 13 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [20.x, 22.x, 24.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - run: npm ci 24 | - run: npm run check-format 25 | - run: npm run lint 26 | - run: npm test -- run --coverage 27 | - run: npx tsc 28 | -------------------------------------------------------------------------------- /test/alias.js: -------------------------------------------------------------------------------- 1 | const Mixpanel = require("../lib/mixpanel-node"); 2 | 3 | describe("alias", () => { 4 | let mixpanel; 5 | beforeEach(() => { 6 | mixpanel = Mixpanel.init("token", { key: "key" }); 7 | vi.spyOn(mixpanel, "send_request"); 8 | return () => { 9 | mixpanel.send_request.mockRestore(); 10 | }; 11 | }); 12 | 13 | it("calls send_request with correct endpoint and data", () => { 14 | const alias = "test", 15 | distinct_id = "old_id", 16 | expected_endpoint = "/track", 17 | expected_data = { 18 | event: "$create_alias", 19 | properties: expect.objectContaining({ 20 | distinct_id: distinct_id, 21 | alias: alias, 22 | token: "token", 23 | }), 24 | }; 25 | 26 | mixpanel.alias(distinct_id, alias); 27 | 28 | expect(mixpanel.send_request).toHaveBeenCalledWith( 29 | expect.objectContaining({ 30 | endpoint: expected_endpoint, 31 | data: expected_data, 32 | }), 33 | undefined, 34 | ); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Carl Sverre 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a 4 | copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included 12 | in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 15 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // Visit https://aka.ms/tsconfig to read more about this file 3 | "compilerOptions": { 4 | // File Layout 5 | // "rootDir": "./lib", 6 | // "outDir": "./dist", 7 | 8 | // Environment Settings 9 | // See also https://aka.ms/tsconfig/module 10 | "module": "nodenext", 11 | "target": "esnext", 12 | 13 | // For nodejs: 14 | "lib": ["esnext"], 15 | "types": ["node"], 16 | 17 | "noEmit": true, 18 | 19 | "esModuleInterop": true, 20 | 21 | // Stricter Typechecking Options 22 | "noUncheckedIndexedAccess": true, 23 | "exactOptionalPropertyTypes": true, 24 | 25 | // Style Options 26 | // "noImplicitReturns": true, 27 | // "noImplicitOverride": true, 28 | // "noUnusedLocals": true, 29 | // "noUnusedParameters": true, 30 | // "noFallthroughCasesInSwitch": true, 31 | // "noPropertyAccessFromIndexSignature": true, 32 | 33 | // Recommended Options 34 | "strict": true, 35 | // "verbatimModuleSyntax": true, 36 | "isolatedModules": true, 37 | "noUncheckedSideEffectImports": true, 38 | "moduleDetection": "force", 39 | "skipLibCheck": true 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mixpanel", 3 | "description": "A simple server-side API for mixpanel", 4 | "keywords": [ 5 | "mixpanel", 6 | "analytics", 7 | "api", 8 | "stats" 9 | ], 10 | "version": "0.19.1", 11 | "homepage": "https://github.com/mixpanel/mixpanel-node", 12 | "author": "Carl Sverre", 13 | "license": "MIT", 14 | "main": "lib/mixpanel-node", 15 | "directories": { 16 | "lib": "lib" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+ssh://git@github.com/mixpanel/mixpanel-node.git" 21 | }, 22 | "engines": { 23 | "node": ">=10.0" 24 | }, 25 | "scripts": { 26 | "test": "vitest", 27 | "format": "prettier --write .", 28 | "check-format": "prettier --check .", 29 | "lint": "oxlint ." 30 | }, 31 | "types": "./lib/mixpanel-node.d.ts", 32 | "devDependencies": { 33 | "@types/node": "^24.10.1", 34 | "@vitest/coverage-v8": "^4.0.8", 35 | "nock": "^14.0.10", 36 | "oxlint": "^1.16.0", 37 | "prettier": "^3.6.2", 38 | "proxyquire": "^2.1.3", 39 | "typescript": "^5.9.3", 40 | "vitest": "^4.0.8" 41 | }, 42 | "dependencies": { 43 | "https-proxy-agent": "7.0.6" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /test/config.js: -------------------------------------------------------------------------------- 1 | const Mixpanel = require("../lib/mixpanel-node"); 2 | 3 | describe("config", () => { 4 | let mixpanel; 5 | beforeEach(() => { 6 | mixpanel = Mixpanel.init("asjdf"); 7 | }); 8 | it("is set to correct defaults", () => { 9 | expect(mixpanel.config).toEqual({ 10 | test: false, 11 | debug: false, 12 | verbose: false, 13 | host: "api.mixpanel.com", 14 | protocol: "https", 15 | path: "", 16 | keepAlive: true, 17 | geolocate: false, 18 | logger: console, 19 | }); 20 | }); 21 | 22 | it("is modified by set_config", () => { 23 | expect(mixpanel.config.test).toBe(false); 24 | 25 | mixpanel.set_config({ test: true }); 26 | 27 | expect(mixpanel.config.test).toBe(true); 28 | }); 29 | 30 | it("can be set during init", () => { 31 | const mp = Mixpanel.init("token", { test: true }); 32 | 33 | expect(mp.config.test).toBe(true); 34 | }); 35 | 36 | it("host config is split into host and port", () => { 37 | const exampleHost = "api.example.com"; 38 | const examplePort = 70; 39 | const hostWithoutPortConfig = Mixpanel.init("token", { 40 | host: exampleHost, 41 | }).config; 42 | expect(hostWithoutPortConfig.port).toEqual(undefined); 43 | expect(hostWithoutPortConfig.host).toEqual(exampleHost); 44 | 45 | const hostWithPortConfig = Mixpanel.init("token", { 46 | host: `${exampleHost}:${examplePort}`, 47 | }).config; 48 | expect(hostWithPortConfig.port).toBe(examplePort); 49 | expect(hostWithPortConfig.host).toBe(exampleHost); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | const async_all = require("../lib/utils").async_all; 2 | 3 | describe("async_all", () => { 4 | it("calls callback with empty results if no requests", () => { 5 | const requests = []; 6 | const handler_fn = vi.fn((_, cb) => cb()); 7 | const callback = vi.fn(); 8 | 9 | async_all(requests, handler_fn, callback); 10 | expect(callback).toHaveBeenCalledTimes(1); 11 | }); 12 | 13 | it("runs handler for each request and calls callback with results", () => { 14 | const requests = [1, 2, 3]; 15 | const handler_fn = vi 16 | .fn() 17 | .mockImplementationOnce((_, cb) => cb(null, 4)) 18 | .mockImplementationOnce((_, cb) => cb(null, 5)) 19 | .mockImplementationOnce((_, cb) => cb(null, 6)); 20 | 21 | const callback = vi.fn(); 22 | 23 | async_all(requests, handler_fn, callback); 24 | expect(handler_fn).toHaveBeenCalledTimes(requests.length); 25 | expect(handler_fn.mock.calls[0][0]).toBe(1); 26 | expect(handler_fn.mock.calls[1][0]).toBe(2); 27 | expect(handler_fn.mock.calls[2][0]).toBe(3); 28 | expect(callback).toHaveBeenCalledTimes(1); 29 | expect(callback).toHaveBeenCalledWith(null, [4, 5, 6]); 30 | }); 31 | 32 | it("calls callback with errors and results from handler", () => { 33 | const requests = [1, 2, 3]; 34 | const handler_fn = vi 35 | .fn() 36 | .mockImplementationOnce((_, cb) => cb("error1", null)) 37 | .mockImplementationOnce((_, cb) => cb("error2", null)) 38 | .mockImplementationOnce((_, cb) => cb(null, 6)); 39 | const callback = vi.fn(); 40 | 41 | async_all(requests, handler_fn, callback); 42 | expect(handler_fn).toHaveBeenCalledTimes(requests.length); 43 | expect(handler_fn.mock.calls[0][0]).toBe(1); 44 | expect(handler_fn.mock.calls[1][0]).toBe(2); 45 | expect(handler_fn.mock.calls[2][0]).toBe(3); 46 | expect(callback).toHaveBeenCalledTimes(1); 47 | expect(callback).toHaveBeenCalledWith( 48 | ["error1", "error2"], 49 | [null, null, 6], 50 | ); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /lib/flags/flags.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * TypeScript type definitions for Base Feature Flags Provider 3 | */ 4 | 5 | import { CustomLogger } from "../mixpanel-node"; 6 | import { SelectedVariant, FlagContext } from "./types"; 7 | 8 | /** 9 | * Configuration for feature flags API requests 10 | */ 11 | export interface FeatureFlagsConfig { 12 | token: string; 13 | api_host: string; 14 | request_timeout_in_seconds: number; 15 | } 16 | 17 | /** 18 | * Base Feature Flags Provider 19 | * Contains common methods for feature flag evaluation 20 | */ 21 | export class FeatureFlagsProvider { 22 | providerConfig: FeatureFlagsConfig; 23 | endpoint: string; 24 | logger: CustomLogger | null; 25 | 26 | /** 27 | * @param config - Common configuration for feature flag providers 28 | * @param endpoint - API endpoint path (i.e., '/flags' or '/flags/definitions') 29 | * @param logger - Logger instance 30 | */ 31 | constructor( 32 | config: FeatureFlagsConfig, 33 | endpoint: string, 34 | logger: CustomLogger | null, 35 | ); 36 | 37 | /** 38 | * Common HTTP request handler for flags API endpoints 39 | * @param additionalParams - Additional query parameters to append 40 | * @returns Parsed JSON response 41 | */ 42 | callFlagsEndpoint( 43 | additionalParams?: Record | null, 44 | ): Promise; 45 | 46 | /** 47 | * Manually tracks a feature flag exposure event to Mixpanel 48 | * This provides flexibility for reporting individual exposure events when using getAllVariants 49 | * If using getVariantValue or getVariant, exposure events are tracked automatically by default. 50 | * @param {string} flagKey - The key of the feature flag 51 | * @param {SelectedVariant} variant - The selected variant for the feature flag 52 | * @param {FlagContext} context - The user context used to evaluate the feature flag 53 | * @param {number|null} latencyMs - Optionally included latency in milliseconds that assignment took. 54 | */ 55 | trackExposureEvent( 56 | flagKey: string, 57 | variant: SelectedVariant, 58 | context: FlagContext, 59 | latencyMs?: number | null, 60 | ): void; 61 | } 62 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * helper to wait for all callbacks to complete; similar to `Promise.all` 3 | * exposed to metrics object for unit tests 4 | * @param {Array} requests 5 | * @param {Function} handler 6 | * @param {Function} callback 7 | */ 8 | exports.async_all = function (requests, handler, callback) { 9 | let total = requests.length, 10 | errors = null, 11 | results = [], 12 | done = function (err, result) { 13 | if (err) { 14 | // errors are `null` unless there is an error, which allows for promisification 15 | errors = errors || []; 16 | errors.push(err); 17 | } 18 | results.push(result); 19 | if (--total === 0) { 20 | callback(errors, results); 21 | } 22 | }; 23 | 24 | if (total === 0) { 25 | callback(errors, results); 26 | } else { 27 | for (let i = 0, l = requests.length; i < l; i++) { 28 | handler(requests[i], done); 29 | } 30 | } 31 | }; 32 | 33 | /** 34 | * Validate type of time property, and convert to Unix timestamp if necessary 35 | * @param {Date|number} time - value to check 36 | * @returns {number} Unix timestamp 37 | */ 38 | exports.ensure_timestamp = function (time) { 39 | if (!(time instanceof Date || typeof time === "number")) { 40 | throw new Error( 41 | "`time` property must be a Date or Unix timestamp and is only required for `import` endpoint", 42 | ); 43 | } 44 | return time instanceof Date ? time.getTime() : time; 45 | }; 46 | 47 | /** 48 | * Asserts that the provided logger object is valid 49 | * @param {CustomLogger} logger - The logger object to be validated 50 | * @throws {TypeError} If the logger object is not a valid Logger object or 51 | * if it is missing any of the required methods 52 | */ 53 | exports.assert_logger = function (logger) { 54 | if (typeof logger !== "object") { 55 | throw new TypeError(`"logger" must be a valid Logger object`); 56 | } 57 | 58 | ["trace", "debug", "info", "warn", "error"].forEach((method) => { 59 | if (typeof logger[method] !== "function") { 60 | throw new TypeError(`Logger object missing "${method}" method`); 61 | } 62 | }); 63 | }; 64 | -------------------------------------------------------------------------------- /lib/flags/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility functions for Mixpanel feature flags 3 | */ 4 | const crypto = require("crypto"); 5 | 6 | // Constants 7 | const EXPOSURE_EVENT = "$experiment_started"; 8 | 9 | const REQUEST_HEADERS = { 10 | "Content-Type": "application/json", 11 | }; 12 | 13 | /** 14 | * FNV-1a 64-bit hash function used for consistent variant assignment 15 | * https://www.ietf.org/archive/id/draft-eastlake-fnv-21.html#section-6.1.2 16 | * @param {Buffer} data - Data to hash 17 | * @returns {BigInt} - Hash value as BigInt 18 | */ 19 | function _fnv1a64(data) { 20 | const FNV_PRIME = BigInt("0x100000001B3"); 21 | let hash = BigInt("0xCBF29CE484222325"); 22 | 23 | for (let i = 0; i < data.length; i++) { 24 | hash ^= BigInt(data[i]); 25 | hash *= FNV_PRIME; 26 | hash &= BigInt("0xFFFFFFFFFFFFFFFF"); 27 | } 28 | 29 | return hash; 30 | } 31 | 32 | /** 33 | * Normalized hash function that returns a value between 0.0 and 1.0 34 | * Used for variant assignment based on rollout percentages 35 | * @param {string} key - The key to hash (usually distinct_id or other identifier) 36 | * @param {string} salt - Salt value (usually flag-specific hash_salt) 37 | * @returns {number} - Hash value normalized to the non-inclusive range, [0.0, 1.0) 38 | */ 39 | function normalizedHash(key, salt) { 40 | const combined = Buffer.from(key + salt, "utf-8"); 41 | const hashValue = _fnv1a64(combined); 42 | return Number(hashValue % BigInt(100)) / 100.0; 43 | } 44 | 45 | /** 46 | * Prepare common query parameters for feature flags API requests 47 | * @param {string} token - Mixpanel project token 48 | * @param {string} sdkVersion - SDK version string 49 | * @returns {Object} - Query parameters object 50 | */ 51 | function prepareCommonQueryParams(token, sdkVersion) { 52 | return { 53 | mp_lib: "node", 54 | $lib_version: sdkVersion, 55 | token: token, 56 | }; 57 | } 58 | 59 | /** 60 | * Generate W3C traceparent header for distributed tracing 61 | * Format: 00-{trace-id}-{parent-id}-{trace-flags} 62 | * @returns {string} - traceparent header value 63 | */ 64 | function generateTraceparent() { 65 | const version = "00"; 66 | const traceId = crypto.randomBytes(16).toString("hex"); 67 | const parentId = crypto.randomBytes(8).toString("hex"); 68 | const traceFlags = "01"; // sampled 69 | 70 | return `${version}-${traceId}-${parentId}-${traceFlags}`; 71 | } 72 | 73 | module.exports = { 74 | EXPOSURE_EVENT, 75 | REQUEST_HEADERS, 76 | normalizedHash, 77 | prepareCommonQueryParams, 78 | generateTraceparent, 79 | }; 80 | -------------------------------------------------------------------------------- /lib/flags/remote_flags.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * TypeScript definitions for Remote Feature Flags Provider 3 | */ 4 | 5 | import { CustomLogger } from "../mixpanel-node"; 6 | import { RemoteFlagsConfig, FlagContext, SelectedVariant } from "./types"; 7 | 8 | /** 9 | * Remote Feature Flags Provider 10 | * Evaluates feature flags via server-side API requests 11 | */ 12 | export default class RemoteFeatureFlagsProvider { 13 | constructor( 14 | token: string, 15 | config: RemoteFlagsConfig, 16 | logger: CustomLogger, 17 | tracker: ( 18 | distinct_id: string, 19 | event: string, 20 | properties: object, 21 | callback: (err?: Error) => void, 22 | ) => void, 23 | ); 24 | 25 | /** 26 | * Get the variant value for a feature flag 27 | * @param flagKey - Feature flag key 28 | * @param fallbackValue - Value to return if flag evaluation fails 29 | * @param context - Evaluation context (must include distinct_id) 30 | * @param reportExposure - Whether to track exposure event (default: true) 31 | * @returns Promise resolving to variant value 32 | */ 33 | getVariantValue( 34 | flagKey: string, 35 | fallbackValue: T, 36 | context: FlagContext, 37 | reportExposure?: boolean, 38 | ): Promise; 39 | 40 | /** 41 | * Get the complete variant information for a feature flag 42 | * @param flagKey - Feature flag key 43 | * @param fallbackVariant - Variant to return if flag evaluation fails 44 | * @param context - Evaluation context (must include distinct_id) 45 | * @param reportExposure - Whether to track exposure event (default: true) 46 | * @returns Promise resolving to selected variant 47 | */ 48 | getVariant( 49 | flagKey: string, 50 | fallbackVariant: SelectedVariant, 51 | context: FlagContext, 52 | reportExposure?: boolean, 53 | ): Promise; 54 | 55 | /** 56 | * Check if a feature flag is enabled. 57 | * This checks that the variant value of a selected variant is concretely the boolean 'true', which will be the case for flags setup as FeatureGates 58 | * It does not coerce other truthy values. 59 | * @param flagKey - Feature flag key 60 | * @param context - Evaluation context (must include distinct_id) 61 | * @returns Promise resolving to whether the flag is enabled 62 | */ 63 | isEnabled(flagKey: string, context: FlagContext): Promise; 64 | 65 | /** 66 | * Get all feature flag variants for the current user context from remote server 67 | * Exposure events are not automatically tracked when this method is used 68 | * @param context - Evaluation context (must include distinct_id) 69 | * @returns Promise resolving to dictionary mapping flag keys to variants, or null if the call fails 70 | */ 71 | getAllVariants( 72 | context: FlagContext, 73 | ): Promise<{ [key: string]: SelectedVariant } | null>; 74 | } 75 | -------------------------------------------------------------------------------- /lib/flags/local_flags.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * TypeScript definitions for Local Feature Flags Provider 3 | */ 4 | 5 | import { LocalFlagsConfig, FlagContext, SelectedVariant } from "./types"; 6 | import { CustomLogger } from "../mixpanel-node"; 7 | 8 | /** 9 | * Local Feature Flags Provider 10 | * Evaluates feature flags client-side using locally cached definitions 11 | */ 12 | export default class LocalFeatureFlagsProvider { 13 | constructor( 14 | token: string, 15 | config: LocalFlagsConfig, 16 | tracker: ( 17 | distinct_id: string, 18 | event: string, 19 | properties: object, 20 | callback: (err?: Error) => void, 21 | ) => void, 22 | logger: CustomLogger, 23 | ); 24 | 25 | /** 26 | * Start polling for flag definitions 27 | * Fetches immediately and then at regular intervals if polling is enabled 28 | */ 29 | startPollingForDefinitions(): Promise; 30 | 31 | /** 32 | * Stop polling for flag definitions 33 | */ 34 | stopPollingForDefinitions(): void; 35 | 36 | /** 37 | * Get the variant value for a feature flag 38 | * @param flagKey - Feature flag key 39 | * @param fallbackValue - Value to return if flag evaluation fails 40 | * @param context - Evaluation context (must include distinct_id) 41 | * @param reportExposure - Whether to track exposure event (default: true) 42 | */ 43 | getVariantValue( 44 | flagKey: string, 45 | fallbackValue: T, 46 | context: FlagContext, 47 | reportExposure?: boolean, 48 | ): T; 49 | 50 | /** 51 | * Get the complete variant information for a feature flag 52 | * @param flagKey - Feature flag key 53 | * @param fallbackVariant - Variant to return if flag evaluation fails 54 | * @param context - Evaluation context (must include distinct_id) 55 | * @param reportExposure - Whether to track exposure event (default: true) 56 | */ 57 | getVariant( 58 | flagKey: string, 59 | fallbackVariant: SelectedVariant, 60 | context: FlagContext, 61 | reportExposure?: boolean, 62 | ): SelectedVariant; 63 | 64 | /** 65 | * Check if a feature flag is enabled 66 | * This method is intended only for flags defined as Mixpanel Feature Gates (boolean flags) 67 | * This checks that the variant value of a selected variant is concretely the boolean 'true' 68 | * It does not coerce other truthy values. 69 | * @param flagKey - Feature flag key 70 | * @param context - Evaluation context (must include distinct_id) 71 | */ 72 | isEnabled(flagKey: string, context: FlagContext): boolean; 73 | 74 | /** 75 | * Get all feature flag variants for the current user context 76 | * Exposure events are not automatically tracked when this method is used 77 | * @param context - Evaluation context (must include distinct_id) 78 | */ 79 | getAllVariants(context: FlagContext): { [key: string]: SelectedVariant }; 80 | } 81 | -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | // grab the Mixpanel factory 2 | const Mixpanel = require("./lib/mixpanel-node"); 3 | 4 | // create an instance of the mixpanel client 5 | const mixpanel = Mixpanel.init("962dbca1bbc54701d402c94d65b4a20e"); 6 | mixpanel.set_config({ debug: true }); 7 | 8 | // track an event with optional properties 9 | mixpanel.track("my event", { 10 | distinct_id: "some unique client id", 11 | as: "many", 12 | properties: "as", 13 | you: "want", 14 | }); 15 | mixpanel.track("played_game"); 16 | 17 | // create or update a user in Mixpanel Engage 18 | mixpanel.people.set("billybob", { 19 | $first_name: "Billy", 20 | $last_name: "Bob", 21 | $created: new Date("jan 1 2013").toISOString(), 22 | plan: "premium", 23 | games_played: 1, 24 | points: 0, 25 | }); 26 | 27 | // create or update a user in Mixpanel Engage without altering $last_seen 28 | // - pass option `$ignore_time: true` to prevent the $last_seen property from being updated 29 | mixpanel.people.set( 30 | "billybob", 31 | { 32 | plan: "premium", 33 | games_played: 1, 34 | }, 35 | { 36 | $ignore_time: true, 37 | }, 38 | ); 39 | 40 | // set a single property on a user 41 | mixpanel.people.set("billybob", "plan", "free"); 42 | 43 | // set a single property on a user, don't override 44 | mixpanel.people.set_once( 45 | "billybob", 46 | "first_game_play", 47 | new Date("jan 1 2013").toISOString(), 48 | ); 49 | 50 | // increment a numeric property 51 | mixpanel.people.increment("billybob", "games_played"); 52 | 53 | // increment a numeric property by a different amount 54 | mixpanel.people.increment("billybob", "points", 15); 55 | 56 | // increment multiple properties 57 | mixpanel.people.increment("billybob", { points: 10, games_played: 1 }); 58 | 59 | // append value to a list 60 | mixpanel.people.append("billybob", "awards", "Great Player"); 61 | 62 | // append multiple values to a list 63 | mixpanel.people.append("billybob", { 64 | awards: "Great Player", 65 | levels_finished: "Level 4", 66 | }); 67 | 68 | // record a transaction for revenue analytics 69 | mixpanel.people.track_charge("billybob", 39.99); 70 | 71 | // clear a users transaction history 72 | mixpanel.people.clear_charges("billybob"); 73 | 74 | // delete a user 75 | mixpanel.people.delete_user("billybob"); 76 | 77 | // all functions that send data to mixpanel take an optional 78 | // callback as the last argument 79 | mixpanel.track("test", function (err) { 80 | if (err) { 81 | throw err; 82 | } 83 | }); 84 | 85 | // import an old event 86 | const mixpanel_importer = Mixpanel.init("valid mixpanel token", { 87 | secret: "valid api secret for project", 88 | }); 89 | mixpanel_importer.set_config({ debug: true }); 90 | 91 | // needs to be in the system once for it to show up in the interface 92 | mixpanel_importer.track("old event", { gender: "" }); 93 | 94 | mixpanel_importer.import("old event", new Date(2012, 4, 20, 12, 34, 56), { 95 | distinct_id: "billybob", 96 | gender: "male", 97 | }); 98 | 99 | // import multiple events at once 100 | mixpanel_importer.import_batch([ 101 | { 102 | event: "old event", 103 | properties: { 104 | time: new Date(2012, 4, 20, 12, 34, 56), 105 | distinct_id: "billybob", 106 | gender: "male", 107 | }, 108 | }, 109 | { 110 | event: "another old event", 111 | properties: { 112 | time: new Date(2012, 4, 21, 11, 33, 55), 113 | distinct_id: "billybob", 114 | color: "red", 115 | }, 116 | }, 117 | ]); 118 | -------------------------------------------------------------------------------- /lib/flags/types.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * TypeScript type definitions for Mixpanel feature flags 3 | */ 4 | 5 | /** 6 | * Base configuration for feature flags 7 | */ 8 | export interface FlagsConfig { 9 | /** API host for Mixpanel (default: 'api.mixpanel.com') */ 10 | api_host?: string; 11 | /** Request timeout in seconds (default: 10) */ 12 | request_timeout_in_seconds?: number; 13 | } 14 | 15 | /** 16 | * Configuration for local feature flags (client-side evaluation) 17 | */ 18 | export interface LocalFlagsConfig extends FlagsConfig { 19 | /** Enable automatic polling for flag definition updates (default: true) */ 20 | enable_polling?: boolean; 21 | /** Polling interval in seconds (default: 60) */ 22 | polling_interval_in_seconds?: number; 23 | } 24 | 25 | /** 26 | * Configuration for remote feature flags (server-side evaluation) 27 | */ 28 | export interface RemoteFlagsConfig extends FlagsConfig {} 29 | 30 | /** 31 | * Represents a variant in a feature flag 32 | */ 33 | export interface Variant { 34 | /** Variant key/name */ 35 | key: string; 36 | /** Variant value (can be any type) */ 37 | value: any; 38 | /** Whether this is the control variant */ 39 | is_control: boolean; 40 | /** Percentage split for this variant (0.0-1.0) */ 41 | split?: number; 42 | } 43 | 44 | /** 45 | * Variant override configuration 46 | */ 47 | export interface VariantOverride { 48 | /** Key of the variant to override to */ 49 | key: string; 50 | } 51 | 52 | /** 53 | * Rollout configuration for a feature flag 54 | */ 55 | export interface Rollout { 56 | /** Percentage of users to include in this rollout (0.0-1.0) */ 57 | rollout_percentage: number; 58 | /** Runtime evaluation conditions (property-based targeting) */ 59 | runtime_evaluation_definition?: Record; 60 | /** Variant override for this rollout */ 61 | variant_override?: VariantOverride; 62 | /** Variant split percentages (variant_key -> percentage) */ 63 | variant_splits?: Record; 64 | } 65 | 66 | /** 67 | * Test users configuration for a feature flag 68 | */ 69 | export interface FlagTestUsers { 70 | /** Map of distinct_id to variant_key */ 71 | users: Record; 72 | } 73 | 74 | /** 75 | * Rule set for a feature flag 76 | */ 77 | export interface RuleSet { 78 | /** Available variants for this flag */ 79 | variants: Variant[]; 80 | /** Rollout configurations */ 81 | rollout: Rollout[]; 82 | /** Test users configuration */ 83 | test?: FlagTestUsers; 84 | } 85 | 86 | /** 87 | * Complete feature flag definition 88 | */ 89 | export interface ExperimentationFlag { 90 | /** Flag ID */ 91 | id: string; 92 | /** Flag name */ 93 | name: string; 94 | /** Flag key (used for lookups) */ 95 | key: string; 96 | /** Flag status */ 97 | status: string; 98 | /** Project ID */ 99 | project_id: number; 100 | /** Rule set for this flag */ 101 | ruleset: RuleSet; 102 | /** Context type (e.g., 'user', 'group') */ 103 | context: string; 104 | /** Associated experiment ID */ 105 | experiment_id?: string; 106 | /** Whether the associated experiment is active */ 107 | is_experiment_active?: boolean; 108 | /** Hash salt for variant assignment */ 109 | hash_salt?: string; 110 | } 111 | 112 | export interface SelectedVariant { 113 | variant_key?: string | null; 114 | variant_value: any; 115 | experiment_id?: string; 116 | is_experiment_active?: boolean; 117 | is_qa_tester?: boolean; 118 | } 119 | 120 | export interface RemoteFlagsResponse { 121 | code: number; 122 | flags: Record; 123 | } 124 | 125 | export interface LocalFlagsResponse { 126 | flags: ExperimentationFlag[]; 127 | } 128 | 129 | export interface FlagContext { 130 | distinct_id: string; 131 | [key: string]: any; 132 | } 133 | -------------------------------------------------------------------------------- /lib/groups.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Group profile methods. Learn more: https://help.mixpanel.com/hc/en-us/articles/360025333632 3 | */ 4 | 5 | const { ProfileHelpers } = require("./profile_helpers"); 6 | 7 | class MixpanelGroups extends ProfileHelpers() { 8 | constructor(mp_instance) { 9 | super(); 10 | this.mixpanel = mp_instance; 11 | this.endpoint = "/groups"; 12 | } 13 | 14 | /** groups.set_once(group_key, group_id, prop, to, modifiers, callback) 15 | --- 16 | The same as groups.set, but adds a property value to a group only if it has not been set before. 17 | */ 18 | set_once(group_key, group_id, prop, to, modifiers, callback) { 19 | const identifiers = { $group_key: group_key, $group_id: group_id }; 20 | this._set(prop, to, modifiers, callback, { identifiers, set_once: true }); 21 | } 22 | 23 | /** 24 | groups.set(group_key, group_id, prop, to, modifiers, callback) 25 | --- 26 | set properties on a group profile 27 | 28 | usage: 29 | 30 | mixpanel.groups.set('company', 'Acme Inc.', '$name', 'Acme Inc.'); 31 | 32 | mixpanel.groups.set('company', 'Acme Inc.', { 33 | 'Industry': 'widgets', 34 | '$name': 'Acme Inc.', 35 | }); 36 | */ 37 | set(group_key, group_id, prop, to, modifiers, callback) { 38 | const identifiers = { $group_key: group_key, $group_id: group_id }; 39 | this._set(prop, to, modifiers, callback, { identifiers }); 40 | } 41 | 42 | /** 43 | groups.delete_group(group_key, group_id, modifiers, callback) 44 | --- 45 | delete a group profile permanently 46 | 47 | usage: 48 | 49 | mixpanel.groups.delete_group('company', 'Acme Inc.'); 50 | */ 51 | delete_group(group_key, group_id, modifiers, callback) { 52 | const identifiers = { $group_key: group_key, $group_id: group_id }; 53 | this._delete_profile({ identifiers, modifiers, callback }); 54 | } 55 | 56 | /** 57 | groups.remove(group_key, group_id, data, modifiers, callback) 58 | --- 59 | remove a value from a list-valued group profile property. 60 | 61 | usage: 62 | 63 | mixpanel.groups.remove('company', 'Acme Inc.', {'products': 'anvil'}); 64 | 65 | mixpanel.groups.remove('company', 'Acme Inc.', { 66 | 'products': 'anvil', 67 | 'customer segments': 'coyotes' 68 | }); 69 | */ 70 | remove(group_key, group_id, data, modifiers, callback) { 71 | const identifiers = { $group_key: group_key, $group_id: group_id }; 72 | this._remove({ identifiers, data, modifiers, callback }); 73 | } 74 | 75 | /** 76 | groups.union(group_key, group_id, data, modifiers, callback) 77 | --- 78 | merge value(s) into a list-valued group profile property. 79 | 80 | usage: 81 | 82 | mixpanel.groups.union('company', 'Acme Inc.', {'products': 'anvil'}); 83 | 84 | mixpanel.groups.union('company', 'Acme Inc.', {'products': ['anvil'], 'customer segments': ['coyotes']}); 85 | */ 86 | union(group_key, group_id, data, modifiers, callback) { 87 | const identifiers = { $group_key: group_key, $group_id: group_id }; 88 | this._union({ identifiers, data, modifiers, callback }); 89 | } 90 | 91 | /** 92 | groups.unset(group_key, group_id, prop, modifiers, callback) 93 | --- 94 | delete a property on a group profile 95 | 96 | usage: 97 | 98 | mixpanel.groups.unset('company', 'Acme Inc.', 'products'); 99 | 100 | mixpanel.groups.unset('company', 'Acme Inc.', ['products', 'customer segments']); 101 | */ 102 | unset(group_key, group_id, prop, modifiers, callback) { 103 | const identifiers = { $group_key: group_key, $group_id: group_id }; 104 | this._unset({ identifiers, prop, modifiers, callback }); 105 | } 106 | } 107 | 108 | exports.MixpanelGroups = MixpanelGroups; 109 | -------------------------------------------------------------------------------- /test/flags/utils.js: -------------------------------------------------------------------------------- 1 | const { 2 | generateTraceparent, 3 | normalizedHash, 4 | } = require("../../lib/flags/utils"); 5 | 6 | describe("Utils", function () { 7 | describe("generateTraceparent", function () { 8 | it("should generate traceparent in W3C format", function () { 9 | const traceparent = generateTraceparent(); 10 | // W3C format: 00-{32 hex chars}-{16 hex chars}-01 11 | const pattern = /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/; 12 | expect(traceparent).to.match(pattern); 13 | }); 14 | }); 15 | 16 | describe("normalizedHash", function () { 17 | const expectValidHash = (hash) => { 18 | expect(hash).to.be.a("number"); 19 | expect(hash).to.be.at.least(0); 20 | expect(hash).to.be.at.most(1); 21 | }; 22 | 23 | it("should match known test vectors", function () { 24 | const hash1 = normalizedHash("abc", "variant"); 25 | expect(hash1).equals(0.72); 26 | 27 | const hash2 = normalizedHash("def", "variant"); 28 | expect(hash2).equals(0.21, 0.01); 29 | }); 30 | 31 | it("should produce consistent results", function () { 32 | const hash1 = normalizedHash("test_key", "salt"); 33 | const hash2 = normalizedHash("test_key", "salt"); 34 | const hash3 = normalizedHash("test_key", "salt"); 35 | 36 | expect(hash1).equals(hash2); 37 | expect(hash2).equals(hash3); 38 | }); 39 | 40 | it("should produce different hashes when salt is changed", function () { 41 | const hash1 = normalizedHash("same_key", "salt1"); 42 | const hash2 = normalizedHash("same_key", "salt2"); 43 | const hash3 = normalizedHash("same_key", "different_salt"); 44 | 45 | expect(hash1).to.not.equal(hash2); 46 | expect(hash1).to.not.equal(hash3); 47 | expect(hash2).to.not.equal(hash3); 48 | }); 49 | 50 | it("should produce different hashes when order is changed", function () { 51 | const hash1 = normalizedHash("abc", "salt"); 52 | const hash2 = normalizedHash("bac", "salt"); 53 | const hash3 = normalizedHash("cba", "salt"); 54 | 55 | expect(hash1).to.not.equal(hash2); 56 | expect(hash1).to.not.equal(hash3); 57 | expect(hash2).to.not.equal(hash3); 58 | }); 59 | 60 | describe("should handle edge cases with empty strings", function () { 61 | const testCases = [ 62 | { key: "", salt: "salt", description: "empty key" }, 63 | { key: "key", salt: "", description: "empty salt" }, 64 | { key: "", salt: "", description: "both empty" }, 65 | ]; 66 | 67 | testCases.forEach(({ key, salt, description }) => { 68 | it(`should return valid hash for ${description}`, function () { 69 | const hash = normalizedHash(key, salt); 70 | expectValidHash(hash); 71 | }); 72 | }); 73 | 74 | it("empty strings in different positions should produce different results", function () { 75 | const hash1 = normalizedHash("", "salt"); 76 | const hash2 = normalizedHash("key", ""); 77 | expect(hash1).to.not.equal(hash2); 78 | }); 79 | }); 80 | 81 | describe("should handle special characters", function () { 82 | const testCases = [ 83 | { key: "🎉", description: "emoji" }, 84 | { key: "beyoncé", description: "accented characters" }, 85 | { key: "key@#$%^&*()", description: "special symbols" }, 86 | { key: "key with spaces", description: "spaces" }, 87 | ]; 88 | 89 | testCases.forEach(({ key, description }) => { 90 | it(`should return valid hash for ${description}`, function () { 91 | const hash = normalizedHash(key, "salt"); 92 | expectValidHash(hash); 93 | }); 94 | }); 95 | 96 | it("produces different results for different special characters", function () { 97 | const hashes = testCases.map((tc) => normalizedHash(tc.key, "salt")); 98 | 99 | for (let i = 0; i < hashes.length; i++) { 100 | for (let j = i + 1; j < hashes.length; j++) { 101 | expect(hashes[i]).to.not.equal(hashes[j]); 102 | } 103 | } 104 | }); 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /test/logger.js: -------------------------------------------------------------------------------- 1 | const Mixpanel = require("../lib/mixpanel-node"); 2 | 3 | describe("logger", () => { 4 | describe("console logger", () => { 5 | let mixpanel; 6 | let consoleDebugFn; 7 | beforeAll(() => { 8 | consoleDebugFn = vi.spyOn(console, "debug").mockImplementation(() => {}); 9 | 10 | mixpanel = Mixpanel.init("test token"); 11 | mixpanel.send_request = () => {}; 12 | return () => { 13 | consoleDebugFn.mockRestore(); 14 | }; 15 | }); 16 | 17 | it("defaults to console logger", () => { 18 | const loggerName = Object.prototype.toString.call(mixpanel.config.logger); 19 | expect(loggerName).toBe("[object console]"); 20 | }); 21 | 22 | it("throws an error on incorrect logger object", () => { 23 | expect(() => mixpanel.set_config({ logger: false })).toThrow( 24 | new TypeError('"logger" must be a valid Logger object'), 25 | ); 26 | expect(() => mixpanel.set_config({ logger: { log: () => {} } })).toThrow( 27 | new TypeError('Logger object missing "trace" method'), 28 | ); 29 | }); 30 | 31 | it("writes log for track() method", () => { 32 | mixpanel.set_config({ debug: true }); 33 | 34 | mixpanel.track("test", { foo: "bar" }); 35 | 36 | expect(consoleDebugFn).toHaveBeenCalledTimes(1); 37 | 38 | const [message] = consoleDebugFn.mock.calls[0]; 39 | 40 | expect(message).toMatch(/Sending the following event/); 41 | }); 42 | 43 | it("writes log for increment() method", () => { 44 | mixpanel.set_config({ debug: true }); 45 | 46 | mixpanel.people.increment("bob", "page_views", 1); 47 | 48 | expect(consoleDebugFn).toHaveBeenCalledTimes(2); 49 | 50 | const [message] = consoleDebugFn.mock.calls[1]; 51 | 52 | expect(message).toMatch(/Sending the following data/); 53 | }); 54 | 55 | it("writes log for remove() method", () => { 56 | mixpanel.set_config({ debug: true }); 57 | 58 | mixpanel.people.remove("bob", { browsers: "firefox" }); 59 | 60 | expect(consoleDebugFn).toHaveBeenCalledTimes(3); 61 | 62 | const [message] = consoleDebugFn.mock.calls[2]; 63 | 64 | expect(message).toMatch(/Sending the following data/); 65 | }); 66 | }); 67 | 68 | describe("custom logger", () => { 69 | let mixpanel; 70 | let customLogger; 71 | let consoleDebugFn; 72 | beforeAll(() => { 73 | /** 74 | * Custom logger must be an object with the following methods: 75 | * 76 | * interface CustomLogger { 77 | * trace(message?: any, ...optionalParams: any[]): void; 78 | * debug(message?: any, ...optionalParams: any[]): void; 79 | * info(message?: any, ...optionalParams: any[]): void; 80 | * warn(message?: any, ...optionalParams: any[]): void; 81 | * error(message?: any, ...optionalParams: any[]): void; 82 | * } 83 | */ 84 | customLogger = { 85 | trace: vi.fn(), 86 | debug: vi.fn(), 87 | info: vi.fn(), 88 | warn: vi.fn(), 89 | error: vi.fn(), 90 | }; 91 | consoleDebugFn = vi.spyOn(console, "debug"); 92 | 93 | mixpanel = Mixpanel.init("test token", { logger: customLogger }); 94 | 95 | mixpanel.send_request = () => {}; 96 | 97 | return () => { 98 | consoleDebugFn.mockRestore(); 99 | }; 100 | }); 101 | 102 | it("writes log for track() method", () => { 103 | mixpanel.set_config({ debug: true }); 104 | 105 | mixpanel.track("test", { foo: "bar" }); 106 | 107 | expect(customLogger.debug).toHaveBeenCalledTimes(1); 108 | expect(consoleDebugFn).toHaveBeenCalledTimes(0); 109 | 110 | const [message] = customLogger.debug.mock.calls[0]; 111 | 112 | expect(message).toMatch(/Sending the following event/); 113 | }); 114 | 115 | it("writes log for increment() method", () => { 116 | mixpanel.set_config({ debug: true }); 117 | 118 | mixpanel.people.increment("bob", "page_views", 1); 119 | 120 | expect(customLogger.debug).toHaveBeenCalledTimes(2); 121 | expect(consoleDebugFn).toHaveBeenCalledTimes(0); 122 | 123 | const [message] = customLogger.debug.mock.calls[1]; 124 | 125 | expect(message).toMatch(/Sending the following data/); 126 | }); 127 | 128 | it("writes log for remove() method", () => { 129 | mixpanel.set_config({ debug: true }); 130 | 131 | mixpanel.people.remove("bob", { browsers: "firefox" }); 132 | expect(customLogger.debug).toHaveBeenCalledTimes(3); 133 | expect(consoleDebugFn).toHaveBeenCalledTimes(0); 134 | 135 | const [message] = customLogger.debug.mock.calls[2]; 136 | 137 | expect(message).toMatch(/Sending the following data/); 138 | }); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /lib/flags/flags.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Base Feature Flags Provider 3 | * Contains common methods for feature flag evaluation 4 | */ 5 | 6 | const https = require("https"); 7 | const packageInfo = require("../../package.json"); 8 | const { 9 | prepareCommonQueryParams, 10 | generateTraceparent, 11 | EXPOSURE_EVENT, 12 | REQUEST_HEADERS, 13 | } = require("./utils"); 14 | 15 | /** 16 | * @typedef {import('./types').SelectedVariant} SelectedVariant 17 | * @typedef {import('./types').FlagContext} FlagContext 18 | */ 19 | class FeatureFlagsProvider { 20 | /** 21 | * @param {Object} providerConfig - Configuration object with token, api_host, request_timeout_in_seconds 22 | * @param {string} endpoint - API endpoint path (e.g., '/flags' or '/flags/definitions') 23 | * @param {Function} tracker - Function to track events (signature: track(distinct_id, event, properties, callback)) 24 | * @param {string} evaluationMode - The feature flag evaluation mode 25 | * @param {CustomLogger} logger - Logger instance 26 | */ 27 | constructor(providerConfig, endpoint, tracker, evaluationMode, logger) { 28 | this.providerConfig = providerConfig; 29 | this.endpoint = endpoint; 30 | this.tracker = tracker; 31 | this.evaluationMode = evaluationMode; 32 | this.logger = logger; 33 | } 34 | 35 | /** 36 | * Common HTTP request handler for flags API endpoints 37 | * @param {Object} additionalParams - Additional query parameters to append 38 | * @returns {Promise} - Parsed JSON response 39 | */ 40 | async callFlagsEndpoint(additionalParams = null) { 41 | return new Promise((resolve, reject) => { 42 | const commonParams = prepareCommonQueryParams( 43 | this.providerConfig.token, 44 | packageInfo.version, 45 | ); 46 | const params = new URLSearchParams(commonParams); 47 | 48 | if (additionalParams) { 49 | for (const [key, value] of Object.entries(additionalParams)) { 50 | params.append(key, value); 51 | } 52 | } 53 | 54 | const path = `${this.endpoint}?${params.toString()}`; 55 | 56 | const requestOptions = { 57 | host: this.providerConfig.api_host, 58 | port: 443, 59 | path: path, 60 | method: "GET", 61 | headers: { 62 | ...REQUEST_HEADERS, 63 | Authorization: 64 | "Basic " + 65 | Buffer.from(this.providerConfig.token + ":").toString("base64"), 66 | traceparent: generateTraceparent(), 67 | }, 68 | timeout: this.providerConfig.request_timeout_in_seconds * 1000, 69 | }; 70 | 71 | const request = https.request(requestOptions, (res) => { 72 | let data = ""; 73 | 74 | res.on("data", (chunk) => { 75 | data += chunk; 76 | }); 77 | 78 | res.on("end", () => { 79 | if (res.statusCode !== 200) { 80 | this.logger?.error( 81 | `HTTP ${res.statusCode} error calling flags endpoint: ${data}`, 82 | ); 83 | return reject(new Error(`HTTP ${res.statusCode}: ${data}`)); 84 | } 85 | 86 | try { 87 | const result = JSON.parse(data); 88 | resolve(result); 89 | } catch (parseErr) { 90 | this.logger?.error( 91 | `Failed to parse JSON response: ${parseErr.message}`, 92 | ); 93 | reject(parseErr); 94 | } 95 | }); 96 | }); 97 | 98 | request.on("error", (err) => { 99 | this.logger?.error( 100 | `Network error calling flags endpoint: ${err.message}`, 101 | ); 102 | reject(err); 103 | }); 104 | 105 | request.on("timeout", () => { 106 | this.logger?.error(`Request timeout calling flags endpoint`); 107 | request.destroy(); 108 | reject(new Error("Request timeout")); 109 | }); 110 | 111 | request.end(); 112 | }); 113 | } 114 | 115 | /** 116 | * Manually tracks a feature flag exposure event to Mixpanel 117 | * This provides flexibility for reporting individual exposure events when using getAllVariants 118 | * If using getVariantValue or getVariant, exposure events are tracked automatically by default. 119 | * @param {string} flagKey - The key of the feature flag 120 | * @param {SelectedVariant} variant - The selected variant for the feature flag 121 | * @param {FlagContext} context - The user context used to evaluate the feature flag 122 | * @param {number|null} latencyMs - Optionally included latency in milliseconds that assignment took. 123 | */ 124 | trackExposureEvent(flagKey, selectedVariant, context, latencyMs = null) { 125 | if (!context.distinct_id) { 126 | this.logger?.error( 127 | "Cannot track exposure event without a distinct_id in the context", 128 | ); 129 | return; 130 | } 131 | 132 | const properties = { 133 | distinct_id: context.distinct_id, 134 | "Experiment name": flagKey, 135 | "Variant name": selectedVariant.variant_key, 136 | $experiment_type: "feature_flag", 137 | "Flag evaluation mode": this.evaluationMode, 138 | }; 139 | 140 | if (latencyMs !== null && latencyMs !== undefined) { 141 | properties["Variant fetch latency (ms)"] = latencyMs; 142 | } 143 | 144 | if (selectedVariant.experiment_id !== undefined) { 145 | properties["$experiment_id"] = selectedVariant.experiment_id; 146 | } 147 | 148 | if (selectedVariant.is_experiment_active !== undefined) { 149 | properties["$is_experiment_active"] = 150 | selectedVariant.is_experiment_active; 151 | } 152 | 153 | if (selectedVariant.is_qa_tester !== undefined) { 154 | properties["$is_qa_tester"] = selectedVariant.is_qa_tester; 155 | } 156 | 157 | // Use the tracker function provided (bound to the main mixpanel instance) 158 | this.tracker(EXPOSURE_EVENT, properties, (err) => { 159 | if (err) { 160 | this.logger?.error( 161 | `[flags]Failed to track exposure event for flag '${flagKey}': ${err.message}`, 162 | ); 163 | } 164 | }); 165 | } 166 | } 167 | 168 | module.exports = FeatureFlagsProvider; 169 | -------------------------------------------------------------------------------- /lib/flags/remote_flags.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Remote Feature Flags Provider 3 | * Evaluates feature flags via server-side API requests 4 | */ 5 | 6 | /** 7 | * @typedef {import('./types').SelectedVariant} SelectedVariant 8 | * @typedef {import('./types').FlagContext} FlagContext 9 | * @typedef {import('./types').RemoteFlagsConfig} RemoteFlagsConfig 10 | * @typedef {import('./types').RemoteFlagsResponse} RemoteFlagsResponse 11 | */ 12 | 13 | const FeatureFlagsProvider = require("./flags"); 14 | 15 | class RemoteFeatureFlagsProvider extends FeatureFlagsProvider { 16 | /** 17 | * @param {string} token - Mixpanel project token 18 | * @param {RemoteFlagsConfig} config - Remote flags configuration 19 | * @param {Function} tracker - Function to track events (signature: track(distinct_id, event, properties, callback)) 20 | * @param {CustomLogger} logger - Logger instance 21 | */ 22 | constructor(token, config, tracker, logger) { 23 | const mergedConfig = { 24 | api_host: "api.mixpanel.com", 25 | request_timeout_in_seconds: 10, 26 | ...config, 27 | }; 28 | 29 | const providerConfig = { 30 | token: token, 31 | api_host: mergedConfig.api_host, 32 | request_timeout_in_seconds: mergedConfig.request_timeout_in_seconds, 33 | }; 34 | 35 | super(providerConfig, "/flags", tracker, "remote", logger); 36 | } 37 | 38 | /** 39 | * Get the variant value for a feature flag 40 | * If the user context is eligible for the rollout, one of the flag variants will be selected and an exposure event will be tracked to Mixpanel. 41 | * If the user context is not eligible, the fallback value is returned. 42 | * @param {string} flagKey - Feature flag key 43 | * @param {*} fallbackValue - Value to return if flag evaluation fails 44 | * @param {FlagContext} context - Evaluation context 45 | * @param {boolean} reportExposure - Whether to track exposure event 46 | * @returns {Promise<*>} - Variant value 47 | */ 48 | async getVariantValue( 49 | flagKey, 50 | fallbackValue, 51 | context, 52 | reportExposure = true, 53 | ) { 54 | try { 55 | const selectedVariant = await this.getVariant( 56 | flagKey, 57 | { variant_value: fallbackValue }, 58 | context, 59 | reportExposure, 60 | ); 61 | return selectedVariant.variant_value; 62 | } catch (err) { 63 | this.logger?.error( 64 | `Failed to get variant value for flag '${flagKey}': ${err.message}`, 65 | ); 66 | return fallbackValue; 67 | } 68 | } 69 | 70 | /** 71 | * Get the complete variant information for a feature flag 72 | * If the user context is eligible for the rollout, one of the flag variants will be selected and an exposure event will be tracked to Mixpanel. 73 | * If the user context is not eligible, the fallback value is returned. 74 | * @param {string} flagKey - Feature flag key 75 | * @param {SelectedVariant} fallbackVariant - Variant to return if flag evaluation fails 76 | * @param {FlagContext} context - Evaluation context 77 | * @param {boolean} reportExposure - Whether to track exposure event in the event that the user context is eligible for the rollout. 78 | * @returns {Promise} - Selected variant 79 | */ 80 | async getVariant(flagKey, fallbackVariant, context, reportExposure = true) { 81 | try { 82 | const startTime = Date.now(); 83 | const response = await this._fetchFlags(context, flagKey); 84 | const latencyMs = Date.now() - startTime; 85 | 86 | const flags = response.flags || {}; 87 | const selectedVariant = flags[flagKey]; 88 | if (!selectedVariant) { 89 | return fallbackVariant; 90 | } 91 | 92 | if (reportExposure) { 93 | this.trackExposureEvent(flagKey, selectedVariant, context, latencyMs); 94 | } 95 | 96 | return selectedVariant; 97 | } catch (err) { 98 | this.logger?.error( 99 | `Failed to get variant for flag '${flagKey}': ${err.message}`, 100 | ); 101 | return fallbackVariant; 102 | } 103 | } 104 | 105 | /** 106 | * This method is intended only for flags defined as Mixpanel Feature Gates (boolean flags) 107 | * This checks that the variant value of a selected variant is concretely the boolean 'true' 108 | * It does not coerce other truthy values. 109 | * @param {string} flagKey - Feature flag key 110 | * @param {FlagContext} context - User's evaluation context 111 | * @returns {Promise} - Whether the flag is enabled 112 | */ 113 | async isEnabled(flagKey, context) { 114 | try { 115 | const value = await this.getVariantValue(flagKey, false, context); 116 | return value === true; 117 | } catch (err) { 118 | this.logger?.error( 119 | `Failed to check if flag '${flagKey}' is enabled: ${err.message}`, 120 | ); 121 | return false; 122 | } 123 | } 124 | 125 | /** 126 | * Get all feature flag variants for the current user context from remote server 127 | * Exposure events are not automatically tracked when this method is used 128 | * @param {FlagContext} context - User's evaluation context 129 | * @returns {Promise<{[key: string]: SelectedVariant}|null>} - Dictionary mapping flag keys to variants, or null if the call fails 130 | */ 131 | async getAllVariants(context) { 132 | try { 133 | const response = await this._fetchFlags(context); 134 | return response.flags || {}; 135 | } catch (err) { 136 | this.logger?.error(`Failed to get all remote variants: ${err.message}`); 137 | return null; 138 | } 139 | } 140 | 141 | /** 142 | * Fetch flags from remote flags evaluation API 143 | * @param {FlagContext} context - Evaluation context 144 | * @param {string} [flagKey] - Optional flag key (if omitted, fetches all flags) 145 | * @returns {Promise} - API response containing flags dictionary 146 | */ 147 | _fetchFlags(context, flagKey = null) { 148 | const additionalParams = { 149 | context: JSON.stringify(context), 150 | }; 151 | 152 | if (flagKey !== null) { 153 | additionalParams.flag_key = flagKey; 154 | } 155 | 156 | return this.callFlagsEndpoint(additionalParams); 157 | } 158 | } 159 | 160 | module.exports = RemoteFeatureFlagsProvider; 161 | -------------------------------------------------------------------------------- /history.md: -------------------------------------------------------------------------------- 1 | 0.19.1 / 2025-11-13 2 | ================== 3 | * fix for .d.ts issue introduced in v0.19.0 4 | 5 | 0.19.0 / 2025-11-12 6 | ================== 7 | * initial support for Feature Flags 8 | 9 | 0.18.1 / 2025-03-12 10 | ================== 11 | * add secret to config types (thanks gierschv) 12 | 13 | 0.18.0 / 2023-09-12 14 | ================== 15 | * custom logger support (thanks iatsiuk) 16 | 17 | 0.17.0 / 2022-08-11 18 | ================== 19 | * support sending timestamps with millisecond precision 20 | 21 | 0.16.0 / 2022-06-02 22 | ================== 23 | * support automatic geolocation with `geolocate` option (thanks tmpvar) 24 | * send library version as property with events (thanks ArsalImam) 25 | 26 | 0.15.0 / 2022-05-20 27 | ================== 28 | * use keepAlive by default for requests 29 | 30 | 0.14.0 / 2021-10-29 31 | ================== 32 | * support $latitude and $longitude in profile operations (thanks wneild) 33 | 34 | 0.13.0 / 2020-09-04 35 | ================== 36 | * support API Secret auth for imports and deprecate use of API Key 37 | 38 | 0.12.0 / 2020-08-31 39 | ================== 40 | * https-proxy-agent upgrade to 5.0.0 to fix https.request patching and many subdependency upgrades (thanks veerabio) 41 | * dropped support for node 8 42 | 43 | 0.11.0 / 2019-11-26 44 | ================== 45 | * add support for Groups API 46 | 47 | 0.10.3 / 2019-10-09 48 | ================== 49 | * upgrade https-proxy-agent for security fix (thanks omrilotan) 50 | 51 | 0.10.2 / 2019-03-26 52 | ================== 53 | * type definitions for people.unset (thanks bradleyayers) 54 | 55 | 0.10.1 / 2018-12-03 56 | ================== 57 | * support configurable API path (thanks CameronDiver) 58 | 59 | 0.9.2 / 2018-05-22 60 | ================== 61 | * add type declarations file (thanks mklopets) 62 | 63 | 0.9.1 / 2018-04-12 64 | ================== 65 | * upgrade https-proxy-agent for security fix 66 | 67 | 0.9.0 / 2018-02-09 68 | ================== 69 | * default to tracking over HTTPS (thanks jhermsmeier) 70 | 71 | 0.8.0 / 2017-11-28 72 | ================== 73 | * upgraded node-https-proxy-agent to v2.1.1 for security patch (see 74 | https://github.com/TooTallNate/node-https-proxy-agent/issues/37) 75 | 76 | 0.7.0 / 2017-04-07 77 | =================== 78 | * added `track_batch` for tracking multiple recent events per request (thanks cruzanmo) 79 | * support for routing requests through proxy server specified in env var `HTTPS_PROXY` 80 | or `HTTP_PROXY` (thanks colestrode) 81 | * dropped support for node 0.10 and 0.12 82 | 83 | 0.6.0 / 2017-01-03 84 | =================== 85 | * support for `time` field in `mixpanel.track()` (thanks cruzanmo) 86 | 87 | 0.5.0 / 2016-09-15 88 | =================== 89 | * optional https support (thanks chiangf) 90 | 91 | 0.4.1 / 2016-09-09 92 | =================== 93 | * include `$ignore_alias` in permitted `people` modifiers (thanks Left47) 94 | 95 | 0.4.0 / 2016-02-09 96 | =================== 97 | * allow optional `modifiers` in all `people` calls for `$ignore_time`, `$ip`, 98 | and `$time` fields 99 | 100 | 0.3.2 / 2015-12-10 101 | =================== 102 | * correct `$delete` field in `people.delete_user` request (thanks godspeedelbow) 103 | 104 | 0.3.1 / 2015-08-06 105 | =================== 106 | * added config option for API host (thanks gmichael225) 107 | 108 | 0.3.0 / 2015-08-06 109 | =================== 110 | * added people.union support (thanks maeldur) 111 | 112 | 0.2.0 / 2015-04-14 113 | =================== 114 | * added batch import support 115 | 116 | 0.1.1 / 2015-03-27 117 | =================== 118 | * fixed callback behavior in track_charges when no properties supplied 119 | (thanks sorribas) 120 | 121 | 0.1.0 / 2015-03-20 122 | =================== 123 | * updated URL metadata (thanks freeall) 124 | * updated dev dependencies 125 | * added builds for iojs, node 0.12, dropped support for node <0.10 126 | 127 | 0.0.20 / 2014-05-11 128 | ==================== 129 | * removed hardcoded port 80 for more flexibility (thanks zeevl) 130 | 131 | 0.0.19 / 2014.04.03 132 | ==================== 133 | * added people.append (thanks jylauril) 134 | 135 | 0.0.18 / 2013-08-23 136 | ==================== 137 | * added callback to alias (thanks to sandinmyjoints) 138 | * added verbose config option (thanks to sandinmyjoints) 139 | * added unset method (thanks to lukapril) 140 | 141 | 0.0.17 / 2013-08-12 142 | ==================== 143 | * added alias method (thanks to PierrickP) 144 | 145 | 0.0.16 / 2013-06-29 146 | ==================== 147 | * allow special key "ip" to be 0 in people.set (thanks to wwlinx) 148 | 149 | 0.0.15 / 2013-05-24 150 | ==================== 151 | * adds set once functionality to people (thanks to avoid3d) 152 | * $ignore_time in people.set (thanks to Rick Cotter) 153 | 154 | 0.0.14 / 2013-03-28 155 | ==================== 156 | * revert Randal's http only patch since Mixpanel indeed supports https. 157 | * handles the ip property in a property object properly for people calls 158 | 159 | 0.0.13 / 2013-03-25 160 | ==================== 161 | * force requests to go over http [reverted in 0.0.14] 162 | 163 | 0.0.12 / 2013-01-24 164 | ==================== 165 | * track_charge() no longer includes $time by default, rather it lets 166 | Mixpanel's servers set the time when they receive the transaction. This 167 | doesn't modify the ability for the user to pass in their own $time (for 168 | importing transactions). 169 | 170 | 0.0.11 / 2013-01-11 171 | ==================== 172 | * added track_charge() method which provides the ability to record user 173 | transactions for revenue analytics. 174 | * added clear_charges() method which provides the ability to remove a 175 | users transactions from Mixpanel 176 | * added tests for delete_user() 177 | 178 | 0.0.10 / 2012-11-26 179 | ==================== 180 | * added import() method which provides the ability to import events 181 | older than 5 days. Contributions from Thomas Watson Steen. 182 | 183 | 0.0.9 / 2012-11-15 184 | =================== 185 | * removed time from properties sent to server. This is to ensure that 186 | UTC is always used. Mixpanel will set the correct time as soon as they 187 | receive the event. 188 | 189 | 0.0.8 / 2012-10-24 190 | =================== 191 | * added mp_lib property, so people can segment by library 192 | 193 | 0.0.7 / 2012-01-05 194 | =================== 195 | * added unit tests 196 | * people.increment() only prints error message if debug is true 197 | 198 | 0.0.6 / 2012-01-01 199 | =================== 200 | * added engage support 201 | * people.set() 202 | * people.increment() 203 | * people.delete_user() 204 | * deprecated old constructor: require("mixpanel").Client(token) 205 | * added new constructor: require("mixpanel").init(token) 206 | -------------------------------------------------------------------------------- /lib/profile_helpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Mixin with profile-related helpers (for people and groups) 3 | */ 4 | 5 | const { ensure_timestamp } = require("./utils"); 6 | 7 | function merge_modifiers(data, modifiers) { 8 | if (modifiers) { 9 | if (modifiers.$ignore_alias) { 10 | data.$ignore_alias = modifiers.$ignore_alias; 11 | } 12 | if (modifiers.$ignore_time) { 13 | data.$ignore_time = modifiers.$ignore_time; 14 | } 15 | if (modifiers.hasOwnProperty("$ip")) { 16 | data.$ip = modifiers.$ip; 17 | } 18 | if (modifiers.hasOwnProperty("$time")) { 19 | data.$time = ensure_timestamp(modifiers.$time); 20 | } 21 | if ( 22 | modifiers.hasOwnProperty("$latitude") && 23 | modifiers.hasOwnProperty("$longitude") 24 | ) { 25 | data.$latitude = modifiers.$latitude; 26 | data.$longitude = modifiers.$longitude; 27 | } 28 | } 29 | return data; 30 | } 31 | exports.merge_modifiers = merge_modifiers; 32 | 33 | exports.ProfileHelpers = (Base = Object) => 34 | class extends Base { 35 | get token() { 36 | return this.mixpanel.token; 37 | } 38 | 39 | get config() { 40 | return this.mixpanel.config; 41 | } 42 | 43 | _set(prop, to, modifiers, callback, { identifiers, set_once = false }) { 44 | let $set = {}; 45 | 46 | if (typeof prop === "object") { 47 | if (typeof to === "object") { 48 | callback = modifiers; 49 | modifiers = to; 50 | } else { 51 | callback = to; 52 | } 53 | $set = prop; 54 | } else { 55 | $set[prop] = to; 56 | if (typeof modifiers === "function" || !modifiers) { 57 | callback = modifiers; 58 | } 59 | } 60 | 61 | let data = { 62 | $token: this.token, 63 | ...identifiers, 64 | }; 65 | 66 | const set_key = set_once ? "$set_once" : "$set"; 67 | data[set_key] = $set; 68 | 69 | if ("ip" in $set) { 70 | data.$ip = $set.ip; 71 | delete $set.ip; 72 | } 73 | 74 | if ($set.$ignore_time) { 75 | data.$ignore_time = $set.$ignore_time; 76 | delete $set.$ignore_time; 77 | } 78 | 79 | data = merge_modifiers(data, modifiers); 80 | 81 | if (this.config.debug) { 82 | this.mixpanel.config.logger.debug( 83 | `Sending the following data to Mixpanel (${this.endpoint})`, 84 | { data }, 85 | ); 86 | } 87 | 88 | this.mixpanel.send_request( 89 | { method: "GET", endpoint: this.endpoint, data }, 90 | callback, 91 | ); 92 | } 93 | 94 | _delete_profile({ identifiers, modifiers, callback }) { 95 | let data = { 96 | $delete: "", 97 | $token: this.token, 98 | ...identifiers, 99 | }; 100 | 101 | if (typeof modifiers === "function") { 102 | callback = modifiers; 103 | } 104 | 105 | data = merge_modifiers(data, modifiers); 106 | 107 | if (this.config.debug) { 108 | this.mixpanel.config.logger.debug("Deleting profile", { identifiers }); 109 | } 110 | 111 | this.mixpanel.send_request( 112 | { method: "GET", endpoint: this.endpoint, data }, 113 | callback, 114 | ); 115 | } 116 | 117 | _remove({ identifiers, data, modifiers, callback }) { 118 | let $remove = {}; 119 | 120 | if (typeof data !== "object" || Array.isArray(data)) { 121 | if (this.config.debug) { 122 | this.mixpanel.config.logger.error( 123 | "Invalid value passed to #remove - data must be an object with scalar values", 124 | ); 125 | } 126 | return; 127 | } 128 | 129 | for (const [key, val] of Object.entries(data)) { 130 | if (typeof val === "string" || typeof val === "number") { 131 | $remove[key] = val; 132 | } else { 133 | if (this.config.debug) { 134 | this.mixpanel.config.logger.error( 135 | "Invalid argument passed to #remove - values must be scalar", 136 | { key, value: val }, 137 | ); 138 | } 139 | return; 140 | } 141 | } 142 | 143 | if (Object.keys($remove).length === 0) { 144 | return; 145 | } 146 | 147 | data = { 148 | $remove: $remove, 149 | $token: this.token, 150 | ...identifiers, 151 | }; 152 | 153 | if (typeof modifiers === "function") { 154 | callback = modifiers; 155 | } 156 | 157 | data = merge_modifiers(data, modifiers); 158 | 159 | if (this.config.debug) { 160 | this.mixpanel.config.logger.debug( 161 | `Sending the following data to Mixpanel (${this.endpoint})`, 162 | { data }, 163 | ); 164 | } 165 | 166 | this.mixpanel.send_request( 167 | { method: "GET", endpoint: this.endpoint, data }, 168 | callback, 169 | ); 170 | } 171 | 172 | _union({ identifiers, data, modifiers, callback }) { 173 | let $union = {}; 174 | 175 | if (typeof data !== "object" || Array.isArray(data)) { 176 | if (this.config.debug) { 177 | this.mixpanel.config.logger.error( 178 | "Invalid value passed to #union - data must be an object with scalar or array values", 179 | ); 180 | } 181 | return; 182 | } 183 | 184 | for (const [key, val] of Object.entries(data)) { 185 | if (Array.isArray(val)) { 186 | const merge_values = val.filter(function (v) { 187 | return typeof v === "string" || typeof v === "number"; 188 | }); 189 | if (merge_values.length > 0) { 190 | $union[key] = merge_values; 191 | } 192 | } else if (typeof val === "string" || typeof val === "number") { 193 | $union[key] = [val]; 194 | } else { 195 | if (this.config.debug) { 196 | this.mixpanel.config.logger.error( 197 | "Invalid argument passed to #union - values must be a scalar value or array", 198 | { key, value: val }, 199 | ); 200 | } 201 | } 202 | } 203 | 204 | if (Object.keys($union).length === 0) { 205 | return; 206 | } 207 | 208 | data = { 209 | $union: $union, 210 | $token: this.token, 211 | ...identifiers, 212 | }; 213 | 214 | if (typeof modifiers === "function") { 215 | callback = modifiers; 216 | } 217 | 218 | data = merge_modifiers(data, modifiers); 219 | 220 | if (this.config.debug) { 221 | this.mixpanel.config.logger.debug( 222 | `Sending the following data to Mixpanel (${this.endpoint})`, 223 | { data }, 224 | ); 225 | } 226 | 227 | this.mixpanel.send_request( 228 | { method: "GET", endpoint: this.endpoint, data }, 229 | callback, 230 | ); 231 | } 232 | 233 | _unset({ identifiers, prop, modifiers, callback }) { 234 | let $unset = []; 235 | 236 | if (Array.isArray(prop)) { 237 | $unset = prop; 238 | } else if (typeof prop === "string") { 239 | $unset = [prop]; 240 | } else { 241 | if (this.config.debug) { 242 | this.mixpanel.config.logger.error( 243 | "Invalid argument passed to #unset - must be a string or array", 244 | { prop }, 245 | ); 246 | } 247 | return; 248 | } 249 | 250 | let data = { 251 | $unset: $unset, 252 | $token: this.token, 253 | ...identifiers, 254 | }; 255 | 256 | if (typeof modifiers === "function") { 257 | callback = modifiers; 258 | } 259 | 260 | data = merge_modifiers(data, modifiers); 261 | 262 | if (this.config.debug) { 263 | this.mixpanel.config.logger.debug( 264 | `Sending the following data to Mixpanel (${this.endpoint})`, 265 | { data }, 266 | ); 267 | } 268 | 269 | this.mixpanel.send_request( 270 | { method: "GET", endpoint: this.endpoint, data }, 271 | callback, 272 | ); 273 | } 274 | }; 275 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Mixpanel-node 2 | 3 | ![Build Status](https://github.com/mixpanel/mixpanel-node/actions/workflows/tests.yml/badge.svg) 4 | 5 | This library provides many of the features in the official JavaScript mixpanel library. It is easy to use, and fully async. It is intended to be used on the server (it is not a client module). The in-browser client library is available 6 | at [https://github.com/mixpanel/mixpanel-js](https://github.com/mixpanel/mixpanel-js). 7 | 8 | ## Installation 9 | 10 | npm install mixpanel 11 | 12 | ## Quick Start 13 | 14 | ```javascript 15 | // grab the Mixpanel factory 16 | var Mixpanel = require("mixpanel"); 17 | 18 | // create an instance of the mixpanel client 19 | var mixpanel = Mixpanel.init(""); 20 | 21 | // initialize mixpanel client configured to communicate over http instead of https 22 | var mixpanel = Mixpanel.init("", { 23 | protocol: "http", 24 | }); 25 | 26 | // turn off keepAlive (reestablish connection on each request) 27 | var mixpanel = Mixpanel.init("", { 28 | keepAlive: false, 29 | }); 30 | 31 | // pass the custom logger (default is console) 32 | var mixpanel = Mixpanel.init("", { 33 | debug: true, 34 | logger: pinoLogger, // or bunyan, or any other logger that implements the same interface 35 | }); 36 | 37 | // track an event with optional properties 38 | mixpanel.track("my event", { 39 | distinct_id: "some unique client id", 40 | as: "many", 41 | properties: "as", 42 | you: "want", 43 | }); 44 | mixpanel.track("played_game"); 45 | 46 | // set an IP address to get automatic geolocation info 47 | mixpanel.track("my event", { ip: "127.0.0.1" }); 48 | 49 | // track an event with a specific timestamp (up to 5 days old; 50 | // use mixpanel.import() for older events) 51 | mixpanel.track("timed event", { time: new Date() }); 52 | 53 | // create or update a user in Mixpanel Engage 54 | mixpanel.people.set("billybob", { 55 | $first_name: "Billy", 56 | $last_name: "Bob", 57 | $created: new Date("jan 1 2013").toISOString(), 58 | plan: "premium", 59 | games_played: 1, 60 | points: 0, 61 | }); 62 | 63 | // create or update a user in Mixpanel Engage without altering $last_seen 64 | // - pass option $ignore_time: true to prevent the $last_seen property from being updated 65 | mixpanel.people.set( 66 | "billybob", 67 | { 68 | plan: "premium", 69 | games_played: 1, 70 | }, 71 | { 72 | $ignore_time: true, 73 | }, 74 | ); 75 | 76 | // set a user profile's IP address to get automatic geolocation info 77 | mixpanel.people.set( 78 | "billybob", 79 | { 80 | plan: "premium", 81 | games_played: 1, 82 | }, 83 | { 84 | $ip: "127.0.0.1", 85 | }, 86 | ); 87 | 88 | // set a user profile's latitude and longitude to get automatic geolocation info 89 | mixpanel.people.set( 90 | "billybob", 91 | { 92 | plan: "premium", 93 | games_played: 1, 94 | }, 95 | { 96 | $latitude: 40.7127753, 97 | $longitude: -74.0059728, 98 | }, 99 | ); 100 | 101 | // set a single property on a user 102 | mixpanel.people.set("billybob", "plan", "free"); 103 | 104 | // set a single property on a user, don't override 105 | mixpanel.people.set_once( 106 | "billybob", 107 | "first_game_play", 108 | new Date("jan 1 2013").toISOString(), 109 | ); 110 | 111 | // increment a numeric property 112 | mixpanel.people.increment("billybob", "games_played"); 113 | 114 | // increment a numeric property by a different amount 115 | mixpanel.people.increment("billybob", "points", 15); 116 | 117 | // increment multiple properties 118 | mixpanel.people.increment("billybob", { points: 10, games_played: 1 }); 119 | 120 | // append value to a list 121 | mixpanel.people.append("billybob", "awards", "Great Player"); 122 | 123 | // append multiple values to a list 124 | mixpanel.people.append("billybob", { 125 | awards: "Great Player", 126 | levels_finished: "Level 4", 127 | }); 128 | 129 | // merge value to a list (ignoring duplicates) 130 | mixpanel.people.union("billybob", { browsers: "ie" }); 131 | 132 | // merge multiple values to a list (ignoring duplicates) 133 | mixpanel.people.union("billybob", { browsers: ["ie", "chrome"] }); 134 | 135 | // record a transaction for revenue analytics 136 | mixpanel.people.track_charge("billybob", 39.99); 137 | 138 | // clear a users transaction history 139 | mixpanel.people.clear_charges("billybob"); 140 | 141 | // delete a user 142 | mixpanel.people.delete_user("billybob"); 143 | 144 | // delete a user in Mixpanel Engage without altering $last_seen or resolving aliases 145 | // - pass option $ignore_time: true to prevent the $last_seen property from being updated 146 | // (useful if you subsequently re-import data for the same distinct ID) 147 | mixpanel.people.delete_user("billybob", { 148 | $ignore_time: true, 149 | $ignore_alias: true, 150 | }); 151 | 152 | // Create an alias for an existing distinct id 153 | mixpanel.alias("distinct_id", "your_alias"); 154 | 155 | // all functions that send data to mixpanel take an optional 156 | // callback as the last argument 157 | mixpanel.track("test", function (err) { 158 | if (err) throw err; 159 | }); 160 | 161 | // track multiple events at once 162 | mixpanel.track_batch([ 163 | { 164 | event: "recent event", 165 | properties: { 166 | time: new Date(), 167 | distinct_id: "billybob", 168 | gender: "male", 169 | }, 170 | }, 171 | { 172 | event: "another recent event", 173 | properties: { 174 | distinct_id: "billybob", 175 | color: "red", 176 | }, 177 | }, 178 | ]); 179 | 180 | // import an old event 181 | var mixpanel_importer = Mixpanel.init("valid mixpanel token", { 182 | secret: "valid api secret for project", 183 | }); 184 | 185 | // needs to be in the system once for it to show up in the interface 186 | mixpanel_importer.track("old event", { gender: "" }); 187 | 188 | mixpanel_importer.import("old event", new Date(2012, 4, 20, 12, 34, 56), { 189 | distinct_id: "billybob", 190 | gender: "male", 191 | }); 192 | 193 | // import multiple events at once 194 | mixpanel_importer.import_batch([ 195 | { 196 | event: "old event", 197 | properties: { 198 | time: new Date(2012, 4, 20, 12, 34, 56), 199 | distinct_id: "billybob", 200 | gender: "male", 201 | }, 202 | }, 203 | { 204 | event: "another old event", 205 | properties: { 206 | time: new Date(2012, 4, 21, 11, 33, 55), 207 | distinct_id: "billybob", 208 | color: "red", 209 | }, 210 | }, 211 | ]); 212 | ``` 213 | 214 | ## FAQ 215 | 216 | **Where is `mixpanel.identify()`?** 217 | 218 | `mixpanel-node` is a server-side library, optimized for stateless shared usage; e.g., 219 | in a web application, the same mixpanel instance is used across requests for all users. 220 | Rather than setting a `distinct_id` through `identify()` calls like Mixpanel client-side 221 | libraries (where a single Mixpanel instance is tied to a single user), this library 222 | requires you to pass the `distinct_id` with every tracking call. See 223 | https://github.com/mixpanel/mixpanel-node/issues/13. 224 | 225 | **How do I get or set superproperties?** 226 | 227 | See the previous answer: the library does not maintain user state internally and so has 228 | no concept of superproperties for individual users. If you wish to preserve properties 229 | for users between requests, you will need to load these properties from a source specific 230 | to your app (e.g., your session store or database) and pass them explicitly with each 231 | tracking call. 232 | 233 | ## Tests 234 | 235 | # in the mixpanel directory 236 | npm install 237 | npm test 238 | 239 | ## Alternative Clients and Related Tools 240 | 241 | - [Mixpanel-CLI](https://github.com/FGRibreau/mixpanel-cli) - CLI for Mixpanel API (currently only supports tracking functions) 242 | - [Mixpanel Data Export](https://github.com/michaelcarter/mixpanel-data-export-js) - Supports various query and data-management APIs; runs in both Node.js and browser 243 | - [Mixpanel Data Export (strawbrary)](https://github.com/strawbrary/mixpanel-data-export-js) - Fork of previous library, optimized for Node.js with support for streaming large raw exports 244 | 245 | ## Attribution/Credits 246 | 247 | Heavily inspired by the original js library copyright Mixpanel, Inc. 248 | (http://mixpanel.com/) 249 | 250 | Copyright (c) 2014-21 Mixpanel 251 | Original Library Copyright (c) 2012-14 Carl Sverre 252 | 253 | Contributions from: 254 | 255 | - [Andres Gottlieb](https://github.com/andresgottlieb) 256 | - [Ken Perkins](https://github.com/kenperkins) 257 | - [Nathan Rajlich](https://github.com/TooTallNate) 258 | - [Thomas Watson Steen](https://github.com/watson) 259 | - [Gabor Ratky](https://github.com/rgabo) 260 | - [wwlinx](https://github.com/wwlinx) 261 | - [PierrickP](https://github.com/PierrickP) 262 | - [lukapril](https://github.com/lukapril) 263 | - [sandinmyjoints](https://github.com/sandinmyjoints) 264 | - [Jyrki Laurila](https://github.com/jylauril) 265 | - [Zeevl](https://github.com/zeevl) 266 | - [Tobias Baunbæk](https://github.com/freeall) 267 | - [Eduardo Sorribas](https://github.com/sorribas) 268 | - [Nick Chang](https://github.com/maeldur) 269 | - [Michael G](https://github.com/gmichael225) 270 | - [Tejas Manohar](https://github.com/tejasmanohar) 271 | - [Eelke Boezeman](https://github.com/godspeedelbow) 272 | - [Jim Thomas](https://github.com/Left47) 273 | - [Frank Chiang](https://github.com/chiangf) 274 | - [Morgan Croney](https://github.com/cruzanmo) 275 | - [Cole Furfaro-Strode](https://github.com/colestrode) 276 | - [Jonas Hermsmeier](https://github.com/jhermsmeier) 277 | - [Marko Klopets](https://github.com/mklopets) 278 | - [Cameron Diver](https://github.com/CameronDiver) 279 | - [veerabio](https://github.com/veerabio) 280 | - [Will Neild](https://github.com/wneild) 281 | - [Elijah Insua](https://github.com/tmpvar) 282 | - [Arsal Imam](https://github.com/ArsalImam) 283 | - [Aleksei Iatsiuk](https://github.com/iatsiuk) 284 | - [Vincent Giersch](https://github.com/gierschv) 285 | 286 | ## License 287 | 288 | Released under the MIT license. See file called LICENSE for more 289 | details. 290 | -------------------------------------------------------------------------------- /test/import.js: -------------------------------------------------------------------------------- 1 | const proxyquire = require("proxyquire"), 2 | https = require("https"), 3 | events = require("events"), 4 | Mixpanel = require("../lib/mixpanel-node"); 5 | 6 | const mock_now_time = new Date(2016, 1, 1).getTime(), 7 | six_days_ago_timestamp = mock_now_time - 1000 * 60 * 60 * 24 * 6; 8 | 9 | describe("import", () => { 10 | let mixpanel; 11 | beforeEach(() => { 12 | mixpanel = Mixpanel.init("token", { secret: "my api secret" }); 13 | 14 | vi.spyOn(mixpanel, "send_request"); 15 | 16 | return () => { 17 | mixpanel.send_request.mockRestore(); 18 | }; 19 | }); 20 | 21 | it("calls send_request with correct endpoint and data", () => { 22 | const event = "test", 23 | time = six_days_ago_timestamp, 24 | props = { key1: "val1" }, 25 | expected_endpoint = "/import", 26 | expected_data = { 27 | event: "test", 28 | properties: expect.objectContaining({ 29 | key1: "val1", 30 | token: "token", 31 | time: time, 32 | }), 33 | }; 34 | 35 | mixpanel.import(event, time, props); 36 | 37 | expect(mixpanel.send_request).toHaveBeenCalledWith( 38 | expect.objectContaining({ 39 | endpoint: expected_endpoint, 40 | data: expected_data, 41 | }), 42 | undefined, 43 | ); 44 | }); 45 | 46 | it("supports a Date instance greater than 5 days old", () => { 47 | const event = "test", 48 | time = new Date(six_days_ago_timestamp), 49 | props = { key1: "val1" }, 50 | expected_endpoint = "/import", 51 | expected_data = { 52 | event: "test", 53 | properties: expect.objectContaining({ 54 | key1: "val1", 55 | token: "token", 56 | time: six_days_ago_timestamp, 57 | }), 58 | }; 59 | 60 | mixpanel.import(event, time, props); 61 | 62 | expect(mixpanel.send_request).toHaveBeenCalledWith( 63 | expect.objectContaining({ 64 | endpoint: expected_endpoint, 65 | data: expected_data, 66 | }), 67 | undefined, 68 | ); 69 | }); 70 | 71 | it("supports a Date instance less than 5 days old", () => { 72 | const event = "test", 73 | time = new Date(mock_now_time), 74 | props = { key1: "val1" }, 75 | expected_endpoint = "/import", 76 | expected_data = { 77 | event: "test", 78 | properties: expect.objectContaining({ 79 | key1: "val1", 80 | token: "token", 81 | time: mock_now_time, 82 | }), 83 | }; 84 | 85 | mixpanel.import(event, time, props); 86 | 87 | expect(mixpanel.send_request).toHaveBeenCalledWith( 88 | expect.objectContaining({ 89 | endpoint: expected_endpoint, 90 | data: expected_data, 91 | }), 92 | undefined, 93 | ); 94 | }); 95 | 96 | it("supports a unix timestamp", () => { 97 | const event = "test", 98 | time = mock_now_time, 99 | props = { key1: "val1" }, 100 | expected_endpoint = "/import", 101 | expected_data = { 102 | event: "test", 103 | properties: expect.objectContaining({ 104 | key1: "val1", 105 | token: "token", 106 | time: time, 107 | }), 108 | }; 109 | 110 | mixpanel.import(event, time, props); 111 | expect(mixpanel.send_request).toHaveBeenCalledWith( 112 | expect.objectContaining({ 113 | endpoint: expected_endpoint, 114 | data: expected_data, 115 | }), 116 | undefined, 117 | ); 118 | }); 119 | 120 | it("requires the time argument to be a number or Date", () => { 121 | expect(() => mixpanel.import("test", new Date())).not.toThrowError(); 122 | expect(() => mixpanel.import("test", Date.now())).not.toThrowError(); 123 | expect(() => mixpanel.import("test", "not a number or Date")).toThrowError( 124 | /`time` property must be a Date or Unix timestamp/, 125 | ); 126 | expect(() => mixpanel.import("test")).toThrowError( 127 | /`time` property must be a Date or Unix timestamp/, 128 | ); 129 | }); 130 | }); 131 | 132 | describe("import_batch", () => { 133 | let mixpanel; 134 | beforeEach(() => { 135 | mixpanel = Mixpanel.init("token", { secret: "my api secret" }); 136 | 137 | vi.spyOn(mixpanel, "send_request"); 138 | 139 | return () => { 140 | mixpanel.send_request.mockRestore(); 141 | }; 142 | }); 143 | 144 | it("calls send_request with correct endpoint, data, and method", () => { 145 | const expected_endpoint = "/import", 146 | event_list = [ 147 | { event: "test", properties: { key1: "val1", time: 500 } }, 148 | { event: "test", properties: { key2: "val2", time: 1000 } }, 149 | { event: "test2", properties: { key2: "val2", time: 1500 } }, 150 | ], 151 | expected_data = [ 152 | { 153 | event: "test", 154 | properties: { key1: "val1", time: 500, token: "token" }, 155 | }, 156 | { 157 | event: "test", 158 | properties: { key2: "val2", time: 1000, token: "token" }, 159 | }, 160 | { 161 | event: "test2", 162 | properties: { key2: "val2", time: 1500, token: "token" }, 163 | }, 164 | ]; 165 | 166 | mixpanel.import_batch(event_list); 167 | 168 | expect(mixpanel.send_request).toHaveBeenCalledWith( 169 | { 170 | method: "POST", 171 | endpoint: expected_endpoint, 172 | data: expected_data, 173 | }, 174 | expect.any(Function), 175 | ); 176 | }); 177 | 178 | it("requires the time argument for every event", () => { 179 | const event_list = [ 180 | { event: "test", properties: { key1: "val1", time: 500 } }, 181 | { event: "test", properties: { key2: "val2", time: 1000 } }, 182 | { event: "test2", properties: { key2: "val2" } }, 183 | ]; 184 | expect(() => mixpanel.import_batch(event_list)).toThrowError( 185 | "`time` property must be a Date or Unix timestamp and is only required for `import` endpoint", 186 | ); 187 | }); 188 | 189 | it("batches 50 events at a time", () => { 190 | const event_list = []; 191 | for (let ei = 0; ei < 130; ei++) { 192 | // 3 batches: 50 + 50 + 30 193 | event_list.push({ 194 | event: "test", 195 | properties: { key1: "val1", time: 500 + ei }, 196 | }); 197 | } 198 | 199 | mixpanel.import_batch(event_list); 200 | expect(mixpanel.send_request).toHaveBeenCalledTimes(3); 201 | }); 202 | }); 203 | 204 | describe("import_batch_integration", () => { 205 | let mixpanel; 206 | let _http_emitter; 207 | let event_list; 208 | let res; 209 | beforeEach(() => { 210 | mixpanel = Mixpanel.init("token", { secret: "my api secret" }); 211 | 212 | vi.spyOn(https, "request"); 213 | 214 | _http_emitter = new events.EventEmitter(); 215 | 216 | // stub sequence of https responses 217 | res = []; 218 | for (let ri = 0; ri < 5; ri++) { 219 | res.push(new events.EventEmitter()); 220 | https.request.mockImplementationOnce((_, cb) => { 221 | cb(res[ri]); 222 | return { 223 | write: () => {}, 224 | end: () => {}, 225 | on: () => {}, 226 | }; 227 | }); 228 | } 229 | 230 | event_list = []; 231 | for (let ei = 0; ei < 130; ei++) { 232 | // 3 batches: 50 + 50 + 30 233 | event_list.push({ 234 | event: "test", 235 | properties: { key1: "val1", time: 500 + ei }, 236 | }); 237 | } 238 | 239 | return () => { 240 | https.request.mockRestore(); 241 | }; 242 | }); 243 | 244 | it("calls provided callback after all requests finish", () => { 245 | mixpanel.import_batch(event_list, function (error_list) { 246 | expect(https.request).toHaveBeenCalledTimes(3); 247 | expect(error_list).toBe(null); 248 | }); 249 | for (let ri = 0; ri < 3; ri++) { 250 | res[ri].emit("data", "1"); 251 | res[ri].emit("end"); 252 | } 253 | }); 254 | 255 | it("passes error list to callback", () => { 256 | mixpanel.import_batch(event_list, function (error_list) { 257 | expect(error_list.length).toBe(3); 258 | }); 259 | for (let ri = 0; ri < 3; ri++) { 260 | res[ri].emit("data", "0"); 261 | res[ri].emit("end"); 262 | } 263 | }); 264 | 265 | it("calls provided callback when options are passed", () => { 266 | mixpanel.import_batch( 267 | event_list, 268 | { max_batch_size: 100 }, 269 | function (error_list) { 270 | expect(https.request).toHaveBeenCalledTimes(3); 271 | expect(error_list).toBe(null); 272 | }, 273 | ); 274 | for (let ri = 0; ri < 3; ri++) { 275 | res[ri].emit("data", "1"); 276 | res[ri].emit("end"); 277 | } 278 | }); 279 | 280 | it("sends more requests when max_batch_size < 50", () => { 281 | mixpanel.import_batch( 282 | event_list, 283 | { max_batch_size: 30 }, 284 | function (error_list) { 285 | expect(https.request).toHaveBeenCalledTimes(5); // 30 + 30 + 30 + 30 + 10 286 | expect(error_list).toBe(null); 287 | }, 288 | ); 289 | for (let ri = 0; ri < 5; ri++) { 290 | res[ri].emit("data", "1"); 291 | res[ri].emit("end"); 292 | } 293 | }); 294 | 295 | it("can set max concurrent requests", () => { 296 | const async_all_stub = vi.fn(); 297 | const PatchedMixpanel = proxyquire("../lib/mixpanel-node", { 298 | "./utils": { async_all: async_all_stub }, 299 | }); 300 | async_all_stub.mockImplementationOnce((_, __, cb) => cb(null)); 301 | mixpanel = PatchedMixpanel.init("token", { secret: "my api secret" }); 302 | 303 | mixpanel.import_batch( 304 | event_list, 305 | { max_batch_size: 30, max_concurrent_requests: 2 }, 306 | function (error_list) { 307 | // should send 5 event batches over 3 request batches: 308 | // request batch 1: 30 events, 30 events 309 | // request batch 2: 30 events, 30 events 310 | // request batch 3: 10 events 311 | expect(async_all_stub).toHaveBeenCalledTimes(3); 312 | expect(error_list).toBe(null); 313 | }, 314 | ); 315 | for (let ri = 0; ri < 5; ri++) { 316 | res[ri].emit("data", "1"); 317 | res[ri].emit("end"); 318 | } 319 | }); 320 | 321 | it("behaves well without a callback", () => { 322 | mixpanel.import_batch(event_list); 323 | expect(https.request).toHaveBeenCalledTimes(3); 324 | mixpanel.import_batch(event_list, { max_batch_size: 100 }); 325 | expect(https.request).toHaveBeenCalledTimes(5); 326 | }); 327 | }); 328 | -------------------------------------------------------------------------------- /test/track.js: -------------------------------------------------------------------------------- 1 | const https = require("https"); 2 | const events = require("events"); 3 | const proxyquire = require("proxyquire"); 4 | const Mixpanel = require("../lib/mixpanel-node"); 5 | const packageInfo = require("../package.json"); 6 | 7 | const mock_now_time = new Date(2016, 1, 1).getTime(); 8 | 9 | describe("track", () => { 10 | let mixpanel; 11 | beforeAll(() => { 12 | mixpanel = Mixpanel.init("token"); 13 | vi.useFakeTimers(); 14 | vi.setSystemTime(mock_now_time); 15 | vi.spyOn(mixpanel, "send_request"); 16 | 17 | return () => { 18 | vi.useRealTimers(); 19 | mixpanel.send_request.mockRestore(); 20 | }; 21 | }); 22 | 23 | it("calls send_request with correct endpoint and data", () => { 24 | const event = "test", 25 | props = { key1: "val1" }, 26 | expected_endpoint = "/track", 27 | expected_data = { 28 | event: "test", 29 | properties: expect.objectContaining({ 30 | key1: "val1", 31 | token: "token", 32 | }), 33 | }; 34 | 35 | mixpanel.track(event, props); 36 | 37 | expect(mixpanel.send_request).toHaveBeenCalledWith( 38 | expect.objectContaining({ 39 | endpoint: expected_endpoint, 40 | data: expected_data, 41 | }), 42 | undefined, 43 | ); 44 | }); 45 | 46 | it("can be called with optional properties", () => { 47 | const expected_endpoint = "/track", 48 | expected_data = { 49 | event: "test", 50 | properties: expect.objectContaining({ 51 | token: "token", 52 | }), 53 | }; 54 | 55 | mixpanel.track("test"); 56 | 57 | expect(mixpanel.send_request).toHaveBeenCalledWith( 58 | expect.objectContaining({ 59 | endpoint: expected_endpoint, 60 | data: expected_data, 61 | }), 62 | undefined, 63 | ); 64 | }); 65 | 66 | it("can be called with optional callback", () => { 67 | mixpanel.send_request.mockImplementationOnce((_, cb) => cb(undefined)); 68 | 69 | const callback = vi.fn(); 70 | mixpanel.track("test", callback); 71 | expect(callback).toHaveBeenCalledWith(undefined); 72 | }); 73 | 74 | it("supports Date object for time", () => { 75 | const event = "test", 76 | time = new Date(mock_now_time), 77 | props = { time: time }, 78 | expected_endpoint = "/track", 79 | expected_data = { 80 | event: "test", 81 | properties: expect.objectContaining({ 82 | token: "token", 83 | time: time.getTime(), 84 | mp_lib: "node", 85 | $lib_version: packageInfo.version, 86 | }), 87 | }; 88 | 89 | mixpanel.track(event, props); 90 | 91 | expect(mixpanel.send_request).toHaveBeenCalledWith( 92 | expect.objectContaining({ 93 | endpoint: expected_endpoint, 94 | data: expected_data, 95 | }), 96 | undefined, 97 | ); 98 | }); 99 | 100 | it("supports unix timestamp for time", () => { 101 | const event = "test", 102 | time = mock_now_time, 103 | props = { time: time }, 104 | expected_endpoint = "/track", 105 | expected_data = { 106 | event: "test", 107 | properties: expect.objectContaining({ 108 | token: "token", 109 | time: time, 110 | mp_lib: "node", 111 | $lib_version: packageInfo.version, 112 | }), 113 | }; 114 | 115 | mixpanel.track(event, props); 116 | 117 | expect(mixpanel.send_request).toHaveBeenCalledWith( 118 | expect.objectContaining({ 119 | endpoint: expected_endpoint, 120 | data: expected_data, 121 | }), 122 | undefined, 123 | ); 124 | }); 125 | 126 | it("throws error if time is not a number or Date", () => { 127 | const event = "test", 128 | props = { time: "not a number or Date" }; 129 | 130 | expect(() => mixpanel.track(event, props)).toThrowError( 131 | /`time` property must be a Date or Unix timestamp/, 132 | ); 133 | }); 134 | 135 | it("does not require time property", () => { 136 | const event = "test", 137 | props = {}; 138 | 139 | expect(() => mixpanel.track(event, props)).not.toThrowError(); 140 | }); 141 | }); 142 | 143 | describe("track_batch", () => { 144 | let mixpanel; 145 | beforeEach(() => { 146 | mixpanel = Mixpanel.init("token"); 147 | vi.useFakeTimers(); 148 | vi.spyOn(mixpanel, "send_request"); 149 | 150 | return () => { 151 | vi.useRealTimers(); 152 | mixpanel.send_request.mockRestore(); 153 | }; 154 | }); 155 | 156 | it("calls send_request with correct endpoint, data, and method", () => { 157 | const expected_endpoint = "/track", 158 | event_list = [ 159 | { event: "test", properties: { key1: "val1", time: 500 } }, 160 | { event: "test", properties: { key2: "val2", time: 1000 } }, 161 | { event: "test2", properties: { key2: "val2", time: 1500 } }, 162 | ], 163 | expected_data = [ 164 | { 165 | event: "test", 166 | properties: { key1: "val1", time: 500, token: "token" }, 167 | }, 168 | { 169 | event: "test", 170 | properties: { key2: "val2", time: 1000, token: "token" }, 171 | }, 172 | { 173 | event: "test2", 174 | properties: { key2: "val2", time: 1500, token: "token" }, 175 | }, 176 | ].map((val) => expect.objectContaining(val)); 177 | 178 | mixpanel.track_batch(event_list); 179 | 180 | expect(mixpanel.send_request).toHaveBeenCalledWith( 181 | { 182 | method: "POST", 183 | endpoint: expected_endpoint, 184 | data: expected_data, 185 | }, 186 | expect.any(Function), 187 | ); 188 | }); 189 | 190 | it("does not require the time argument for every event", () => { 191 | const event_list = [ 192 | { event: "test", properties: { key1: "val1", time: 500 } }, 193 | { event: "test", properties: { key2: "val2", time: 1000 } }, 194 | { event: "test2", properties: { key2: "val2" } }, 195 | ]; 196 | expect(() => mixpanel.track_batch(event_list)).not.toThrowError(); 197 | }); 198 | 199 | it("batches 50 events at a time", () => { 200 | const event_list = []; 201 | for (let ei = 0; ei < 130; ei++) { 202 | // 3 batches: 50 + 50 + 30 203 | event_list.push({ 204 | event: "test", 205 | properties: { key1: "val1", time: 500 + ei }, 206 | }); 207 | } 208 | 209 | mixpanel.track_batch(event_list); 210 | 211 | expect(mixpanel.send_request).toHaveBeenCalledTimes(3); 212 | }); 213 | }); 214 | 215 | describe("track_batch_integration", () => { 216 | let mixpanel; 217 | let _http_emitter; 218 | let res; 219 | let event_list; 220 | beforeEach(() => { 221 | mixpanel = Mixpanel.init("token", { key: "key" }); 222 | vi.useFakeTimers(); 223 | 224 | vi.spyOn(https, "request"); 225 | 226 | _http_emitter = new events.EventEmitter(); 227 | 228 | // stub sequence of https responses 229 | res = []; 230 | for (let ri = 0; ri < 5; ri++) { 231 | res.push(new events.EventEmitter()); 232 | https.request.mockImplementationOnce((_, cb) => { 233 | cb(res[ri]); 234 | return { 235 | write: function () {}, 236 | end: function () {}, 237 | on: function () {}, 238 | }; 239 | }); 240 | } 241 | 242 | event_list = []; 243 | for (let ei = 0; ei < 130; ei++) { 244 | // 3 batches: 50 + 50 + 30 245 | event_list.push({ 246 | event: "test", 247 | properties: { key1: "val1", time: 500 + ei }, 248 | }); 249 | } 250 | 251 | return () => { 252 | vi.restoreAllMocks(); 253 | }; 254 | }); 255 | 256 | it("calls provided callback after all requests finish", () => { 257 | const callback = vi.fn(); 258 | mixpanel.track_batch(event_list, callback); 259 | for (let ri = 0; ri < 3; ri++) { 260 | res[ri].emit("data", "1"); 261 | res[ri].emit("end"); 262 | } 263 | expect(https.request).toHaveBeenCalledTimes(3); 264 | expect(callback).toHaveBeenCalledTimes(1); 265 | expect(callback).toHaveBeenCalledWith(null, [ 266 | undefined, 267 | undefined, 268 | undefined, 269 | ]); 270 | }); 271 | 272 | it("passes error list to callback", () => { 273 | const callback = vi.fn(); 274 | mixpanel.track_batch(event_list, callback); 275 | for (let ri = 0; ri < 3; ri++) { 276 | res[ri].emit("data", "0"); 277 | res[ri].emit("end"); 278 | } 279 | expect(callback.mock.calls[0][0].length).toBe(3); 280 | }); 281 | 282 | it("calls provided callback when options are passed", () => { 283 | const callback = vi.fn(); 284 | mixpanel.track_batch(event_list, { max_batch_size: 100 }, callback); 285 | for (let ri = 0; ri < 3; ri++) { 286 | res[ri].emit("data", "1"); 287 | res[ri].emit("end"); 288 | } 289 | expect(callback).toHaveBeenCalledTimes(1); 290 | expect(https.request).toHaveBeenCalledTimes(3); 291 | expect(callback).toHaveBeenCalledWith(null, [undefined]); 292 | }); 293 | 294 | it("sends more requests when max_batch_size < 50", () => { 295 | const callback = vi.fn(); 296 | mixpanel.track_batch(event_list, { max_batch_size: 30 }, callback); 297 | for (let ri = 0; ri < 5; ri++) { 298 | res[ri].emit("data", "1"); 299 | res[ri].emit("end"); 300 | } 301 | expect(callback).toHaveBeenCalledTimes(1); 302 | expect(https.request).toHaveBeenCalledTimes(5); 303 | expect(callback).toHaveBeenCalledWith(null, [ 304 | undefined, 305 | undefined, 306 | undefined, 307 | undefined, 308 | undefined, 309 | ]); 310 | }); 311 | 312 | it("can set max concurrent requests", () => { 313 | const async_all_stub = vi.fn(); 314 | async_all_stub.mockImplementation((_, __, cb) => cb(null)); 315 | const PatchedMixpanel = proxyquire("../lib/mixpanel-node", { 316 | "./utils": { async_all: async_all_stub }, 317 | }); 318 | mixpanel = PatchedMixpanel.init("token", { key: "key" }); 319 | 320 | const callback = vi.fn(); 321 | 322 | mixpanel.track_batch( 323 | event_list, 324 | { max_batch_size: 30, max_concurrent_requests: 2 }, 325 | callback, 326 | ); 327 | for (let ri = 0; ri < 3; ri++) { 328 | res[ri].emit("data", "1"); 329 | res[ri].emit("end"); 330 | } 331 | expect(callback).toHaveBeenCalledTimes(1); 332 | expect(async_all_stub).toHaveBeenCalledTimes(3); 333 | expect(callback).toHaveBeenCalledWith(null, undefined); 334 | }); 335 | 336 | it("behaves well without a callback", () => { 337 | mixpanel.track_batch(event_list); 338 | expect(https.request).toHaveBeenCalledTimes(3); 339 | mixpanel.track_batch(event_list, { max_batch_size: 100 }); 340 | expect(https.request).toHaveBeenCalledTimes(5); 341 | }); 342 | }); 343 | -------------------------------------------------------------------------------- /test/send_request.js: -------------------------------------------------------------------------------- 1 | let Mixpanel; 2 | const proxyquire = require("proxyquire"); 3 | const https = require("https"); 4 | const events = require("events"); 5 | const httpProxyOrig = process.env.HTTP_PROXY; 6 | const httpsProxyOrig = process.env.HTTPS_PROXY; 7 | let HttpsProxyAgent; 8 | 9 | describe("send_request", () => { 10 | let mixpanel; 11 | let http_emitter; 12 | let res; 13 | beforeEach(() => { 14 | HttpsProxyAgent = vi.fn(); 15 | Mixpanel = proxyquire("../lib/mixpanel-node", { 16 | "https-proxy-agent": HttpsProxyAgent, 17 | }); 18 | 19 | http_emitter = new events.EventEmitter(); 20 | res = new events.EventEmitter(); 21 | vi.spyOn(https, "request").mockImplementation((_, cb) => { 22 | cb(res); 23 | return http_emitter; 24 | }); 25 | http_emitter.write = vi.fn(); 26 | http_emitter.end = vi.fn(); 27 | 28 | mixpanel = Mixpanel.init("token"); 29 | 30 | return () => { 31 | https.request.mockRestore(); 32 | 33 | // restore proxy variables 34 | process.env.HTTP_PROXY = httpProxyOrig; 35 | process.env.HTTPS_PROXY = httpsProxyOrig; 36 | }; 37 | }); 38 | 39 | it("sends correct data on GET", () => { 40 | const endpoint = "/track", 41 | data = { 42 | event: "test", 43 | properties: { 44 | key1: "val1", 45 | token: "token", 46 | time: 1346876621, 47 | }, 48 | }, 49 | expected_http_request = { 50 | method: "GET", 51 | host: "api.mixpanel.com", 52 | headers: {}, 53 | path: "/track?ip=0&verbose=0&data=eyJldmVudCI6InRlc3QiLCJwcm9wZXJ0aWVzIjp7ImtleTEiOiJ2YWwxIiwidG9rZW4iOiJ0b2tlbiIsInRpbWUiOjEzNDY4NzY2MjF9fQ%3D%3D", 54 | }; 55 | 56 | mixpanel.send_request({ method: "get", endpoint: endpoint, data: data }); 57 | expect(https.request).toHaveBeenCalledWith( 58 | expect.objectContaining(expected_http_request), 59 | expect.any(Function), 60 | ); 61 | expect(http_emitter.end).toHaveBeenCalledTimes(1); 62 | expect(http_emitter.write).toHaveBeenCalledTimes(0); 63 | }); 64 | 65 | it("defaults to GET", () => { 66 | const endpoint = "/track", 67 | data = { 68 | event: "test", 69 | properties: { 70 | key1: "val1", 71 | token: "token", 72 | time: 1346876621, 73 | }, 74 | }, 75 | expected_http_request = { 76 | method: "GET", 77 | host: "api.mixpanel.com", 78 | headers: {}, 79 | path: "/track?ip=0&verbose=0&data=eyJldmVudCI6InRlc3QiLCJwcm9wZXJ0aWVzIjp7ImtleTEiOiJ2YWwxIiwidG9rZW4iOiJ0b2tlbiIsInRpbWUiOjEzNDY4NzY2MjF9fQ%3D%3D", 80 | }; 81 | 82 | mixpanel.send_request({ endpoint: endpoint, data: data }); // method option not defined 83 | 84 | expect(https.request).toHaveBeenCalledWith( 85 | expect.objectContaining(expected_http_request), 86 | expect.any(Function), 87 | ); 88 | }); 89 | 90 | it("sends correct data on POST", () => { 91 | const endpoint = "/track", 92 | data = { 93 | event: "test", 94 | properties: { 95 | key1: "val1", 96 | token: "token", 97 | time: 1346876621, 98 | }, 99 | }, 100 | expected_http_request = { 101 | method: "POST", 102 | host: "api.mixpanel.com", 103 | headers: expect.any(Object), 104 | path: "/track?ip=0&verbose=0", 105 | }, 106 | expected_http_request_body = 107 | "data=eyJldmVudCI6InRlc3QiLCJwcm9wZXJ0aWVzIjp7ImtleTEiOiJ2YWwxIiwidG9rZW4iOiJ0b2tlbiIsInRpbWUiOjEzNDY4NzY2MjF9fQ=="; 108 | 109 | mixpanel.send_request({ method: "post", endpoint: endpoint, data: data }); 110 | 111 | expect(https.request).toHaveBeenCalledWith( 112 | expect.objectContaining(expected_http_request), 113 | expect.any(Function), 114 | ); 115 | expect(http_emitter.end).toHaveBeenCalledTimes(1); 116 | expect(http_emitter.write).toHaveBeenCalledWith(expected_http_request_body); 117 | }); 118 | 119 | it("sets ip=1 when geolocate option is on", () => { 120 | mixpanel.set_config({ geolocate: true }); 121 | 122 | mixpanel.send_request({ 123 | method: "get", 124 | endpoint: "/track", 125 | event: "test", 126 | data: {}, 127 | }); 128 | 129 | expect(https.request).toHaveBeenCalledWith( 130 | expect.objectContaining({ 131 | path: expect.stringContaining("ip=1"), 132 | }), 133 | expect.any(Function), 134 | ); 135 | }); 136 | 137 | it("handles mixpanel errors", () => { 138 | mixpanel.send_request( 139 | { endpoint: "/track", data: { event: "test" } }, 140 | function (e) { 141 | expect(e.message).toBe("Mixpanel Server Error: 0"); 142 | }, 143 | ); 144 | 145 | res.emit("data", "0"); 146 | res.emit("end"); 147 | }); 148 | 149 | it("handles https.request errors", () => { 150 | mixpanel.send_request( 151 | { endpoint: "/track", data: { event: "test" } }, 152 | function (e) { 153 | expect(e).toBe("error"); 154 | }, 155 | ); 156 | http_emitter.emit("error", "error"); 157 | }); 158 | 159 | it("default use keepAlive agent", () => { 160 | const agent = new https.Agent({ keepAlive: false }); 161 | const httpsStub = { 162 | request: vi.fn().mockImplementation((_, cb) => { 163 | cb(res); 164 | return http_emitter; 165 | }), 166 | Agent: vi.fn().mockImplementation(function () { 167 | return agent; 168 | }), 169 | }; 170 | // force SDK not use `undefined` string to initialize proxy-agent 171 | delete process.env.HTTP_PROXY; 172 | delete process.env.HTTPS_PROXY; 173 | Mixpanel = proxyquire("../lib/mixpanel-node", { 174 | https: httpsStub, 175 | }); 176 | const proxyMixpanel = Mixpanel.init("token"); 177 | proxyMixpanel.send_request({ endpoint: "", data: {} }); 178 | 179 | const getConfig = httpsStub.request.mock.calls[0][0]; 180 | const agentOpts = httpsStub.Agent.mock.calls[0][0]; 181 | expect(agentOpts.keepAlive).toBe(true); 182 | expect(getConfig.agent).toBe(agent); 183 | }); 184 | 185 | it("uses correct hostname", () => { 186 | const host = "testhost.fakedomain"; 187 | const customHostnameMixpanel = Mixpanel.init("token", { host: host }); 188 | const expected_http_request = { 189 | host: host, 190 | }; 191 | 192 | customHostnameMixpanel.send_request({ endpoint: "", data: {} }); 193 | 194 | expect(https.request).toHaveBeenCalledWith( 195 | expect.objectContaining(expected_http_request), 196 | expect.any(Function), 197 | ); 198 | }); 199 | 200 | it("uses correct port", () => { 201 | const host = "testhost.fakedomain:1337"; 202 | const customHostnameMixpanel = Mixpanel.init("token", { host: host }); 203 | const expected_http_request = { 204 | host: "testhost.fakedomain", 205 | port: 1337, 206 | }; 207 | 208 | customHostnameMixpanel.send_request({ endpoint: "", data: {} }); 209 | 210 | expect(https.request).toHaveBeenCalledWith( 211 | expect.objectContaining(expected_http_request), 212 | expect.any(Function), 213 | ); 214 | }); 215 | 216 | it("uses correct path", () => { 217 | const host = "testhost.fakedomain"; 218 | const customPath = "/mypath"; 219 | const customHostnameMixpanel = Mixpanel.init("token", { 220 | host, 221 | path: customPath, 222 | }); 223 | const expected_http_request = { 224 | host, 225 | path: "/mypath?ip=0&verbose=0&data=e30%3D", 226 | }; 227 | 228 | customHostnameMixpanel.send_request({ endpoint: "", data: {} }); 229 | expect(https.request).toHaveBeenCalledWith( 230 | expect.objectContaining(expected_http_request), 231 | expect.any(Function), 232 | ); 233 | }); 234 | 235 | it("combines custom path and endpoint", () => { 236 | const host = "testhost.fakedomain"; 237 | const customPath = "/mypath"; 238 | const customHostnameMixpanel = Mixpanel.init("token", { 239 | host, 240 | path: customPath, 241 | }); 242 | const expected_http_request = { 243 | host, 244 | path: "/mypath/track?ip=0&verbose=0&data=e30%3D", 245 | }; 246 | 247 | customHostnameMixpanel.send_request({ endpoint: "/track", data: {} }); 248 | expect(https.request).toHaveBeenCalledWith( 249 | expect.objectContaining(expected_http_request), 250 | expect.any(Function), 251 | ); 252 | }); 253 | 254 | it("uses HTTP_PROXY if set", () => { 255 | HttpsProxyAgent.mockReset(); // Mixpanel is instantiated in setup, need to reset callcount 256 | delete process.env.HTTPS_PROXY; 257 | process.env.HTTP_PROXY = "this.aint.real.https"; 258 | 259 | const proxyMixpanel = Mixpanel.init("token"); 260 | proxyMixpanel.send_request({ endpoint: "", data: {} }); 261 | 262 | expect(HttpsProxyAgent).toHaveBeenCalledTimes(1); 263 | 264 | const agentOpts = HttpsProxyAgent.mock.calls[0][0]; 265 | expect(agentOpts.pathname).toBe("this.aint.real.https"); 266 | expect(agentOpts.keepAlive).toBe(true); 267 | 268 | const getConfig = https.request.mock.calls[0][0]; 269 | expect(getConfig.agent).toBeTruthy(); 270 | }); 271 | 272 | it("uses HTTPS_PROXY if set", () => { 273 | HttpsProxyAgent.mockReset(); // Mixpanel is instantiated in setup, need to reset callcount 274 | delete process.env.HTTP_PROXY; 275 | process.env.HTTPS_PROXY = "this.aint.real.https"; 276 | 277 | const proxyMixpanel = Mixpanel.init("token"); 278 | proxyMixpanel.send_request({ endpoint: "", data: {} }); 279 | 280 | expect(HttpsProxyAgent).toHaveBeenCalledTimes(1); 281 | 282 | const proxyOpts = HttpsProxyAgent.mock.calls[0][0]; 283 | expect(proxyOpts.pathname).toBe("this.aint.real.https"); 284 | 285 | const getConfig = https.request.mock.calls[0][0]; 286 | expect(getConfig.agent).toBeTruthy(); 287 | }); 288 | 289 | it("requires credentials for import requests", () => { 290 | expect(() => { 291 | mixpanel.send_request({ 292 | endpoint: `/import`, 293 | data: { event: `test event` }, 294 | }); 295 | }).toThrowError( 296 | /The Mixpanel Client needs a Mixpanel API Secret when importing old events/, 297 | ); 298 | }); 299 | 300 | it("sets basic auth header if API secret is provided", () => { 301 | mixpanel.set_config({ secret: `foobar` }); 302 | mixpanel.send_request({ 303 | endpoint: `/import`, 304 | data: { event: `test event` }, 305 | }); 306 | expect(https.request).toHaveBeenCalledTimes(1); 307 | expect(https.request.mock.calls[0][0].headers).toEqual({ 308 | Authorization: `Basic Zm9vYmFyOg==`, // base64 of "foobar:" 309 | }); 310 | }); 311 | 312 | it("still supports import with api_key (legacy)", () => { 313 | mixpanel.set_config({ key: `barbaz` }); 314 | mixpanel.send_request({ 315 | endpoint: `/import`, 316 | data: {}, 317 | }); 318 | expect(https.request).toHaveBeenCalledTimes(1); 319 | expect(https.request.mock.calls[0][0].path).toBe( 320 | `/import?ip=0&verbose=0&data=e30%3D&api_key=barbaz`, 321 | ); 322 | }); 323 | }); 324 | -------------------------------------------------------------------------------- /lib/mixpanel-node.d.ts: -------------------------------------------------------------------------------- 1 | import LocalFeatureFlagsProvider from "./flags/local_flags"; 2 | import RemoteFeatureFlagsProvider from "./flags/remote_flags"; 3 | import { LocalFlagsConfig, RemoteFlagsConfig } from "./flags/types"; 4 | 5 | declare const mixpanel: mixpanel.Mixpanel; 6 | 7 | declare namespace mixpanel { 8 | export type Callback = (err: Error | undefined) => any; 9 | export type BatchCallback = (errors: [Error] | undefined) => any; 10 | 11 | type Scalar = string | number | boolean; 12 | 13 | export interface CustomLogger { 14 | trace(message?: any, ...optionalParams: any[]): void; 15 | debug(message?: any, ...optionalParams: any[]): void; 16 | info(message?: any, ...optionalParams: any[]): void; 17 | warn(message?: any, ...optionalParams: any[]): void; 18 | error(message?: any, ...optionalParams: any[]): void; 19 | } 20 | 21 | export interface InitConfig { 22 | test: boolean; 23 | debug: boolean; 24 | verbose: boolean; 25 | host: string; 26 | protocol: string; 27 | path: string; 28 | secret: string; 29 | keepAlive: boolean; 30 | geolocate: boolean; 31 | logger: CustomLogger; 32 | local_flags_config?: LocalFlagsConfig; 33 | remote_flags_config?: RemoteFlagsConfig; 34 | } 35 | 36 | export interface PropertyDict { 37 | [key: string]: any; 38 | } 39 | 40 | export interface NumberMap { 41 | [key: string]: number; 42 | } 43 | 44 | export interface Event { 45 | event: string; 46 | properties: PropertyDict; 47 | } 48 | export interface Modifiers { 49 | $ip?: string; 50 | $ignore_time?: boolean; 51 | $time?: string; 52 | $ignore_alias?: boolean; 53 | $latitude?: number; 54 | $longitude?: number; 55 | } 56 | 57 | export interface BatchOptions { 58 | max_concurrent_requests?: number; 59 | max_batch_size?: number; 60 | } 61 | 62 | export interface UnionData { 63 | [key: string]: Scalar | Scalar[]; 64 | } 65 | 66 | export interface RemoveData { 67 | [key: string]: string | number; 68 | } 69 | 70 | interface Mixpanel { 71 | init(mixpanelToken: string, config?: Partial): Mixpanel; 72 | 73 | track(eventName: string, callback?: Callback): void; 74 | track( 75 | eventName: string, 76 | properties: PropertyDict, 77 | callback?: Callback, 78 | ): void; 79 | 80 | track_batch( 81 | events: Event[], 82 | options?: BatchOptions, 83 | callback?: BatchCallback, 84 | ): void; 85 | track_batch(events: Event[], callback: BatchCallback): void; 86 | track_batch( 87 | eventNames: string[], 88 | options?: BatchOptions, 89 | callback?: BatchCallback, 90 | ): void; 91 | track_batch(eventNames: string[], callback?: BatchCallback): void; 92 | 93 | import( 94 | eventName: string, 95 | time: Date | number, 96 | properties?: PropertyDict, 97 | callback?: Callback, 98 | ): void; 99 | import(eventName: string, time: Date | number, callback: Callback): void; 100 | 101 | import_batch( 102 | eventNames: string[], 103 | options?: BatchOptions, 104 | callback?: BatchCallback, 105 | ): void; 106 | import_batch(eventNames: string[], callback?: BatchCallback): void; 107 | import_batch(events: Event[], callback?: BatchCallback): void; 108 | 109 | alias(distinctId: string, alias: string, callback?: Callback): void; 110 | 111 | people: People; 112 | 113 | groups: Groups; 114 | 115 | local_flags?: LocalFeatureFlagsProvider; 116 | 117 | remote_flags?: RemoteFeatureFlagsProvider; 118 | } 119 | 120 | interface People { 121 | set( 122 | distinctId: string, 123 | properties: PropertyDict, 124 | callback?: Callback, 125 | ): void; 126 | set( 127 | distinctId: string, 128 | properties: PropertyDict, 129 | modifiers?: Modifiers, 130 | callback?: Callback, 131 | ): void; 132 | set( 133 | distinctId: string, 134 | propertyName: string, 135 | value: string | number, 136 | modifiers: Modifiers, 137 | ): void; 138 | set( 139 | distinctId: string, 140 | propertyName: string, 141 | value: string | number, 142 | callback?: Callback, 143 | ): void; 144 | set( 145 | distinctId: string, 146 | propertyName: string, 147 | value: string | number, 148 | modifiers: Modifiers, 149 | callback: Callback, 150 | ): void; 151 | 152 | unset( 153 | distinctId: string, 154 | propertyName: string | string[], 155 | callback?: Callback, 156 | ): void; 157 | unset( 158 | distinctId: string, 159 | propertyName: string | string[], 160 | modifiers?: Modifiers, 161 | callback?: Callback, 162 | ): void; 163 | 164 | set_once( 165 | distinctId: string, 166 | propertyName: string, 167 | value: string, 168 | callback?: Callback, 169 | ): void; 170 | set_once( 171 | distinctId: string, 172 | propertyName: string, 173 | value: string, 174 | modifiers: Modifiers, 175 | callback?: Callback, 176 | ): void; 177 | set_once( 178 | distinctId: string, 179 | properties: PropertyDict, 180 | callback?: Callback, 181 | ): void; 182 | set_once( 183 | distinctId: string, 184 | properties: PropertyDict, 185 | modifiers?: Modifiers, 186 | callback?: Callback, 187 | ): void; 188 | 189 | increment( 190 | distinctId: string, 191 | propertyName: string, 192 | modifiers?: Modifiers, 193 | callback?: Callback, 194 | ): void; 195 | increment( 196 | distinctId: string, 197 | propertyName: string, 198 | incrementBy: number, 199 | modifiers: Modifiers, 200 | callback?: Callback, 201 | ): void; 202 | increment( 203 | distinctId: string, 204 | propertyName: string, 205 | incrementBy: number, 206 | callback?: Callback, 207 | ): void; 208 | increment( 209 | distinctId: string, 210 | properties: NumberMap, 211 | modifiers: Modifiers, 212 | callback?: Callback, 213 | ): void; 214 | increment( 215 | distinctId: string, 216 | properties: NumberMap, 217 | callback?: Callback, 218 | ): void; 219 | 220 | append( 221 | distinctId: string, 222 | propertyName: string, 223 | value: any, 224 | modifiers: Modifiers, 225 | callback?: Callback, 226 | ): void; 227 | append( 228 | distinctId: string, 229 | propertyName: string, 230 | value: any, 231 | callback?: Callback, 232 | ): void; 233 | append( 234 | distinctId: string, 235 | properties: PropertyDict, 236 | callback?: Callback, 237 | ): void; 238 | append( 239 | distinctId: string, 240 | properties: PropertyDict, 241 | modifiers: Modifiers, 242 | callback?: Callback, 243 | ): void; 244 | 245 | union( 246 | distinctId: string, 247 | data: UnionData, 248 | modifiers?: Modifiers, 249 | callback?: Callback, 250 | ): void; 251 | union(distinctId: string, data: UnionData, callback: Callback): void; 252 | 253 | remove( 254 | distinctId: string, 255 | data: RemoveData, 256 | modifiers?: Modifiers, 257 | callback?: Callback, 258 | ): void; 259 | remove(distinctId: string, data: RemoveData, callback: Callback): void; 260 | 261 | track_charge( 262 | distinctId: string, 263 | amount: number | string, 264 | properties?: PropertyDict, 265 | callback?: Callback, 266 | ): void; 267 | track_charge( 268 | distinctId: string, 269 | amount: number | string, 270 | properties: PropertyDict, 271 | modifiers?: Modifiers, 272 | callback?: Callback, 273 | ): void; 274 | 275 | clear_charges( 276 | distinctId: string, 277 | modifiers?: Modifiers, 278 | callback?: Callback, 279 | ): void; 280 | clear_charges(distinctId: string, callback: Callback): void; 281 | 282 | delete_user( 283 | distinctId: string, 284 | modifiers?: Modifiers, 285 | callback?: Callback, 286 | ): void; 287 | delete_user(distinctId: string, callback: Callback): void; 288 | } 289 | 290 | interface Groups { 291 | set( 292 | groupKey: string, 293 | groupId: string, 294 | properties: PropertyDict, 295 | callback?: Callback, 296 | ): void; 297 | set( 298 | groupKey: string, 299 | groupId: string, 300 | properties: PropertyDict, 301 | modifiers?: Modifiers, 302 | callback?: Callback, 303 | ): void; 304 | set( 305 | groupKey: string, 306 | groupId: string, 307 | propertyName: string, 308 | value: string | number, 309 | modifiers: Modifiers, 310 | ): void; 311 | set( 312 | groupKey: string, 313 | groupId: string, 314 | propertyName: string, 315 | value: string | number, 316 | callback?: Callback, 317 | ): void; 318 | set( 319 | groupKey: string, 320 | groupId: string, 321 | propertyName: string, 322 | value: string | number, 323 | modifiers: Modifiers, 324 | callback: Callback, 325 | ): void; 326 | 327 | unset( 328 | groupKey: string, 329 | groupId: string, 330 | propertyName: string | string[], 331 | callback?: Callback, 332 | ): void; 333 | unset( 334 | groupKey: string, 335 | groupId: string, 336 | propertyName: string | string[], 337 | modifiers?: Modifiers, 338 | callback?: Callback, 339 | ): void; 340 | 341 | set_once( 342 | groupKey: string, 343 | groupId: string, 344 | propertyName: string, 345 | value: string, 346 | callback?: Callback, 347 | ): void; 348 | set_once( 349 | groupKey: string, 350 | groupId: string, 351 | propertyName: string, 352 | value: string, 353 | modifiers: Modifiers, 354 | callback?: Callback, 355 | ): void; 356 | set_once( 357 | groupKey: string, 358 | groupId: string, 359 | properties: PropertyDict, 360 | callback?: Callback, 361 | ): void; 362 | set_once( 363 | groupKey: string, 364 | groupId: string, 365 | properties: PropertyDict, 366 | modifiers?: Modifiers, 367 | callback?: Callback, 368 | ): void; 369 | 370 | union( 371 | groupKey: string, 372 | groupId: string, 373 | data: UnionData, 374 | modifiers?: Modifiers, 375 | callback?: Callback, 376 | ): void; 377 | union( 378 | groupKey: string, 379 | groupId: string, 380 | data: UnionData, 381 | callback: Callback, 382 | ): void; 383 | 384 | remove( 385 | groupKey: string, 386 | groupId: string, 387 | data: RemoveData, 388 | modifiers?: Modifiers, 389 | callback?: Callback, 390 | ): void; 391 | remove( 392 | groupKey: string, 393 | groupId: string, 394 | data: RemoveData, 395 | callback: Callback, 396 | ): void; 397 | 398 | delete_group( 399 | groupKey: string, 400 | groupId: string, 401 | modifiers?: Modifiers, 402 | callback?: Callback, 403 | ): void; 404 | delete_group(groupKey: string, groupId: string, callback: Callback): void; 405 | } 406 | } 407 | 408 | export = mixpanel; 409 | -------------------------------------------------------------------------------- /lib/people.js: -------------------------------------------------------------------------------- 1 | const { merge_modifiers, ProfileHelpers } = require("./profile_helpers"); 2 | 3 | class MixpanelPeople extends ProfileHelpers() { 4 | constructor(mp_instance) { 5 | super(); 6 | this.mixpanel = mp_instance; 7 | this.endpoint = "/engage"; 8 | } 9 | 10 | /** people.set_once(distinct_id, prop, to, modifiers, callback) 11 | --- 12 | The same as people.set but in the words of mixpanel: 13 | mixpanel.people.set_once 14 | 15 | " This method allows you to set a user attribute, only if 16 | it is not currently set. It can be called multiple times 17 | safely, so is perfect for storing things like the first date 18 | you saw a user, or the referrer that brought them to your 19 | website for the first time. " 20 | 21 | */ 22 | set_once(distinct_id, prop, to, modifiers, callback) { 23 | const identifiers = { $distinct_id: distinct_id }; 24 | this._set(prop, to, modifiers, callback, { identifiers, set_once: true }); 25 | } 26 | 27 | /** 28 | people.set(distinct_id, prop, to, modifiers, callback) 29 | --- 30 | set properties on an user record in engage 31 | 32 | usage: 33 | 34 | mixpanel.people.set('bob', 'gender', 'm'); 35 | 36 | mixpanel.people.set('joe', { 37 | 'company': 'acme', 38 | 'plan': 'premium' 39 | }); 40 | */ 41 | set(distinct_id, prop, to, modifiers, callback) { 42 | const identifiers = { $distinct_id: distinct_id }; 43 | this._set(prop, to, modifiers, callback, { identifiers }); 44 | } 45 | 46 | /** 47 | people.increment(distinct_id, prop, by, modifiers, callback) 48 | --- 49 | increment/decrement properties on an user record in engage 50 | 51 | usage: 52 | 53 | mixpanel.people.increment('bob', 'page_views', 1); 54 | 55 | // or, for convenience, if you're just incrementing a counter by 1, you can 56 | // simply do 57 | mixpanel.people.increment('bob', 'page_views'); 58 | 59 | // to decrement a counter, pass a negative number 60 | mixpanel.people.increment('bob', 'credits_left', -1); 61 | 62 | // like mixpanel.people.set(), you can increment multiple properties at once: 63 | mixpanel.people.increment('bob', { 64 | counter1: 1, 65 | counter2: 3, 66 | counter3: -2 67 | }); 68 | */ 69 | increment(distinct_id, prop, by, modifiers, callback) { 70 | // TODO extract to ProfileHelpers 71 | 72 | const $add = {}; 73 | 74 | if (typeof prop === "object") { 75 | if (typeof by === "object") { 76 | callback = modifiers; 77 | modifiers = by; 78 | } else { 79 | callback = by; 80 | } 81 | for (const [key, val] of Object.entries(prop)) { 82 | if (isNaN(parseFloat(val))) { 83 | if (this.mixpanel.config.debug) { 84 | this.mixpanel.config.logger.error( 85 | "Invalid increment value passed to mixpanel.people.increment - must be a number", 86 | { key, value: val }, 87 | ); 88 | } 89 | } else { 90 | $add[key] = val; 91 | } 92 | } 93 | } else { 94 | if (typeof by === "number" || !by) { 95 | by = by || 1; 96 | $add[prop] = by; 97 | if (typeof modifiers === "function") { 98 | callback = modifiers; 99 | } 100 | } else if (typeof by === "function") { 101 | callback = by; 102 | $add[prop] = 1; 103 | } else { 104 | callback = modifiers; 105 | modifiers = typeof by === "object" ? by : {}; 106 | $add[prop] = 1; 107 | } 108 | } 109 | 110 | let data = { 111 | $add: $add, 112 | $token: this.mixpanel.token, 113 | $distinct_id: distinct_id, 114 | }; 115 | 116 | data = merge_modifiers(data, modifiers); 117 | 118 | if (this.mixpanel.config.debug) { 119 | this.mixpanel.config.logger.debug( 120 | "Sending the following data to Mixpanel (Engage)", 121 | { data }, 122 | ); 123 | } 124 | 125 | this.mixpanel.send_request( 126 | { method: "GET", endpoint: "/engage", data: data }, 127 | callback, 128 | ); 129 | } 130 | 131 | /** 132 | people.append(distinct_id, prop, value, modifiers, callback) 133 | --- 134 | Append a value to a list-valued people analytics property. 135 | 136 | usage: 137 | 138 | // append a value to a list, creating it if needed 139 | mixpanel.people.append('bob', 'pages_visited', 'homepage'); 140 | 141 | // like mixpanel.people.set(), you can append multiple properties at once: 142 | mixpanel.people.append('bob', { 143 | list1: 'bob', 144 | list2: 123 145 | }); 146 | */ 147 | append(distinct_id, prop, value, modifiers, callback) { 148 | // TODO extract to ProfileHelpers 149 | 150 | const $append = {}; 151 | 152 | if (typeof prop === "object") { 153 | if (typeof value === "object") { 154 | callback = modifiers; 155 | modifiers = value; 156 | } else { 157 | callback = value; 158 | } 159 | Object.keys(prop).forEach(function (key) { 160 | $append[key] = prop[key]; 161 | }); 162 | } else { 163 | $append[prop] = value; 164 | if (typeof modifiers === "function") { 165 | callback = modifiers; 166 | } 167 | } 168 | 169 | let data = { 170 | $append: $append, 171 | $token: this.mixpanel.token, 172 | $distinct_id: distinct_id, 173 | }; 174 | 175 | data = merge_modifiers(data, modifiers); 176 | 177 | if (this.mixpanel.config.debug) { 178 | this.mixpanel.config.logger.debug( 179 | "Sending the following data to Mixpanel (Engage)", 180 | { data }, 181 | ); 182 | } 183 | 184 | this.mixpanel.send_request( 185 | { method: "GET", endpoint: "/engage", data: data }, 186 | callback, 187 | ); 188 | } 189 | 190 | /** 191 | people.track_charge(distinct_id, amount, properties, modifiers, callback) 192 | --- 193 | Record that you have charged the current user a certain 194 | amount of money. 195 | 196 | usage: 197 | 198 | // charge a user $29.99 199 | mixpanel.people.track_charge('bob', 29.99); 200 | 201 | // charge a user $19 on the 1st of february 202 | mixpanel.people.track_charge('bob', 19, { '$time': new Date('feb 1 2012') }); 203 | */ 204 | track_charge(distinct_id, amount, properties, modifiers, callback) { 205 | if (typeof properties === "function" || !properties) { 206 | callback = properties || undefined; 207 | properties = {}; 208 | } else { 209 | if (typeof modifiers === "function" || !modifiers) { 210 | callback = modifiers || undefined; 211 | if (properties.$ignore_time || properties.hasOwnProperty("$ip")) { 212 | modifiers = {}; 213 | Object.keys(properties).forEach(function (key) { 214 | modifiers[key] = properties[key]; 215 | delete properties[key]; 216 | }); 217 | } 218 | } 219 | } 220 | 221 | if (typeof amount !== "number") { 222 | amount = parseFloat(amount); 223 | if (isNaN(amount)) { 224 | this.mixpanel.config.logger.error( 225 | "Invalid value passed to mixpanel.people.track_charge - must be a number", 226 | ); 227 | return; 228 | } 229 | } 230 | 231 | properties.$amount = amount; 232 | 233 | if (properties.hasOwnProperty("$time")) { 234 | const time = properties.$time; 235 | if (Object.prototype.toString.call(time) === "[object Date]") { 236 | properties.$time = time.toISOString(); 237 | } 238 | } 239 | 240 | let data = { 241 | $append: { $transactions: properties }, 242 | $token: this.mixpanel.token, 243 | $distinct_id: distinct_id, 244 | }; 245 | 246 | data = merge_modifiers(data, modifiers); 247 | 248 | if (this.mixpanel.config.debug) { 249 | this.mixpanel.config.logger.debug( 250 | "Sending the following data to Mixpanel (Engage)", 251 | { data }, 252 | ); 253 | } 254 | 255 | this.mixpanel.send_request( 256 | { method: "GET", endpoint: "/engage", data: data }, 257 | callback, 258 | ); 259 | } 260 | 261 | /** 262 | people.clear_charges(distinct_id, modifiers, callback) 263 | --- 264 | Clear all the current user's transactions. 265 | 266 | usage: 267 | 268 | mixpanel.people.clear_charges('bob'); 269 | */ 270 | clear_charges(distinct_id, modifiers, callback) { 271 | let data = { 272 | $set: { $transactions: [] }, 273 | $token: this.mixpanel.token, 274 | $distinct_id: distinct_id, 275 | }; 276 | 277 | if (typeof modifiers === "function") { 278 | callback = modifiers; 279 | } 280 | 281 | data = merge_modifiers(data, modifiers); 282 | 283 | if (this.mixpanel.config.debug) { 284 | this.mixpanel.config.logger.debug("Clearing this user's charges", { 285 | $distinct_id: distinct_id, 286 | }); 287 | } 288 | 289 | this.mixpanel.send_request( 290 | { method: "GET", endpoint: "/engage", data: data }, 291 | callback, 292 | ); 293 | } 294 | 295 | /** 296 | people.delete_user(distinct_id, modifiers, callback) 297 | --- 298 | delete an user record in engage 299 | 300 | usage: 301 | 302 | mixpanel.people.delete_user('bob'); 303 | */ 304 | delete_user(distinct_id, modifiers, callback) { 305 | const identifiers = { $distinct_id: distinct_id }; 306 | this._delete_profile({ identifiers, modifiers, callback }); 307 | } 308 | 309 | /** 310 | people.remove(distinct_id, data, modifiers, callback) 311 | --- 312 | remove a value from a list-valued user profile property. 313 | 314 | usage: 315 | 316 | mixpanel.people.remove('bob', {'browsers': 'firefox'}); 317 | 318 | mixpanel.people.remove('bob', {'browsers': 'chrome', 'os': 'linux'}); 319 | */ 320 | remove(distinct_id, data, modifiers, callback) { 321 | const identifiers = { $distinct_id: distinct_id }; 322 | this._remove({ identifiers, data, modifiers, callback }); 323 | } 324 | 325 | /** 326 | people.union(distinct_id, data, modifiers, callback) 327 | --- 328 | merge value(s) into a list-valued people analytics property. 329 | 330 | usage: 331 | 332 | mixpanel.people.union('bob', {'browsers': 'firefox'}); 333 | 334 | mixpanel.people.union('bob', {'browsers': ['chrome'], os: ['linux']}); 335 | */ 336 | union(distinct_id, data, modifiers, callback) { 337 | const identifiers = { $distinct_id: distinct_id }; 338 | this._union({ identifiers, data, modifiers, callback }); 339 | } 340 | 341 | /** 342 | people.unset(distinct_id, prop, modifiers, callback) 343 | --- 344 | delete a property on an user record in engage 345 | 346 | usage: 347 | 348 | mixpanel.people.unset('bob', 'page_views'); 349 | 350 | mixpanel.people.unset('bob', ['page_views', 'last_login']); 351 | */ 352 | unset(distinct_id, prop, modifiers, callback) { 353 | const identifiers = { $distinct_id: distinct_id }; 354 | this._unset({ identifiers, prop, modifiers, callback }); 355 | } 356 | } 357 | 358 | exports.MixpanelPeople = MixpanelPeople; 359 | -------------------------------------------------------------------------------- /lib/flags/local_flags.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Local Feature Flags Provider 3 | * Evaluates feature flags client-side using locally cached definitions 4 | */ 5 | 6 | /** 7 | * @typedef {import('./types').SelectedVariant} SelectedVariant 8 | * @typedef {import('./types').FlagContext} FlagContext 9 | * @typedef {import('./types').LocalFlagsConfig} LocalFlagsConfig 10 | * @typedef {import('./types').ExperimentationFlag} ExperimentationFlag 11 | * @typedef {import('./types').LocalFlagsResponse} LocalFlagsResponse 12 | * */ 13 | 14 | const FeatureFlagsProvider = require("./flags"); 15 | const { normalizedHash } = require("./utils"); 16 | 17 | class LocalFeatureFlagsProvider extends FeatureFlagsProvider { 18 | /** 19 | * @param {string} token - Mixpanel project token 20 | * @param {LocalFlagsConfig} config - Local flags configuration 21 | * @param {Function} tracker - Function to track events (signature: track(distinct_id, event, properties, callback)) 22 | * @param {CustomLogger} logger - Logger 23 | */ 24 | constructor(token, config, tracker, logger) { 25 | const mergedConfig = { 26 | api_host: "api.mixpanel.com", 27 | request_timeout_in_seconds: 10, 28 | enable_polling: true, 29 | polling_interval_in_seconds: 60, 30 | ...config, 31 | }; 32 | 33 | const providerConfig = { 34 | token: token, 35 | api_host: mergedConfig.api_host, 36 | request_timeout_in_seconds: mergedConfig.request_timeout_in_seconds, 37 | }; 38 | 39 | super(providerConfig, "/flags/definitions", tracker, "local", logger); 40 | 41 | this.config = mergedConfig; 42 | this.flagDefinitions = new Map(); 43 | this.pollingInterval = null; 44 | } 45 | 46 | /** 47 | * Start polling for flag definitions. 48 | * Fetches immediately and then at regular intervals if polling is enabled 49 | * @returns {Promise} 50 | */ 51 | async startPollingForDefinitions() { 52 | try { 53 | await this._fetchFlagDefinitions(); 54 | 55 | if (this.config.enable_polling && !this.pollingInterval) { 56 | this.pollingInterval = setInterval(async () => { 57 | try { 58 | await this._fetchFlagDefinitions(); 59 | } catch (err) { 60 | this.logger?.error( 61 | `Error polling for flag definition: ${err.message}`, 62 | ); 63 | } 64 | }, this.config.polling_interval_in_seconds * 1000); 65 | } 66 | } catch (err) { 67 | this.logger?.error( 68 | `Initial flag definitions fetch failed: ${err.message}`, 69 | ); 70 | } 71 | } 72 | 73 | /** 74 | * Stop polling for flag definitions 75 | */ 76 | stopPollingForDefinitions() { 77 | if (this.pollingInterval) { 78 | clearInterval(this.pollingInterval); 79 | this.pollingInterval = null; 80 | } else { 81 | this.logger?.warn( 82 | "stopPollingForDefinitions called but polling was not active", 83 | ); 84 | } 85 | } 86 | 87 | /** 88 | * Check if a feature flag is enabled 89 | * This method is intended only for flags defined as Mixpanel Feature Gates (boolean flags) 90 | * This checks that the variant value of a selected variant is concretely the boolean 'true' 91 | * It does not coerce other truthy values. 92 | * @param {string} flagKey - Feature flag key 93 | * @param {FlagContext} context - Evaluation context (must include distinct_id) 94 | * @returns {boolean} 95 | */ 96 | isEnabled(flagKey, context) { 97 | const value = this.getVariantValue(flagKey, false, context); 98 | return value === true; 99 | } 100 | 101 | /** 102 | * Get the variant value for a feature flag 103 | * @param {string} flagKey - Feature flag key 104 | * @param {*} fallbackValue - Value to return if the user context is not in the rollout for a flag or if evaluation fails 105 | * @param {FlagContext} context - Evaluation context 106 | * @param {boolean} [reportExposure=true] - Whether to track exposure event 107 | * @returns {*} The variant value 108 | */ 109 | getVariantValue(flagKey, fallbackValue, context, reportExposure = true) { 110 | const result = this.getVariant( 111 | flagKey, 112 | { variant_value: fallbackValue }, 113 | context, 114 | reportExposure, 115 | ); 116 | return result.variant_value; 117 | } 118 | 119 | /** 120 | * Get the complete variant information for a feature flag 121 | * @param {string} flagKey - Feature flag key 122 | * @param {SelectedVariant} fallbackVariant - Variant to return if flag evaluation fails 123 | * @param {FlagContext} context - Evaluation context (must include distinct_id) 124 | * @param {boolean} [reportExposure=true] - Whether to track exposure event 125 | * @returns {SelectedVariant} 126 | */ 127 | getVariant(flagKey, fallbackVariant, context, reportExposure = true) { 128 | const flag = this.flagDefinitions.get(flagKey); 129 | 130 | if (!flag) { 131 | this.logger?.warn(`Cannot find flag definition for key: '${flagKey}`); 132 | return fallbackVariant; 133 | } 134 | 135 | if (!Object.hasOwn(context, flag.context)) { 136 | this.logger?.warn( 137 | `The variant assignment key, '${flag.context}' for flag, '${flagKey}' is not present in the supplied user context dictionary`, 138 | ); 139 | return fallbackVariant; 140 | } 141 | 142 | const contextValue = context[flag.context]; 143 | 144 | let selectedVariant = null; 145 | 146 | const testUserVariant = this._getVariantOverrideForTestUser(flag, context); 147 | if (testUserVariant) { 148 | selectedVariant = testUserVariant; 149 | } else { 150 | const rollout = this._getAssignedRollout(flag, contextValue, context); 151 | if (rollout) { 152 | selectedVariant = this._getAssignedVariant( 153 | flag, 154 | contextValue, 155 | flagKey, 156 | rollout, 157 | ); 158 | } 159 | } 160 | 161 | if (selectedVariant) { 162 | if (reportExposure) { 163 | this.trackExposureEvent(flagKey, selectedVariant, context); 164 | } 165 | return selectedVariant; 166 | } 167 | 168 | return fallbackVariant; 169 | } 170 | 171 | /** 172 | * Get all feature flag variants for the current user context 173 | * Exposure events are not automatically tracked when this method is used 174 | * @param {FlagContext} context - Evaluation context (must include distinct_id) 175 | * @returns {{[key: string]: SelectedVariant}} 176 | */ 177 | getAllVariants(context) { 178 | const variants = {}; 179 | 180 | for (const flagKey of this.flagDefinitions.keys()) { 181 | const variant = this.getVariant(flagKey, null, context, false); 182 | if (variant !== null) { 183 | variants[flagKey] = variant; 184 | } 185 | } 186 | 187 | return variants; 188 | } 189 | 190 | /** 191 | * Fetch flag definitions from API. 192 | * @returns {Promise} 193 | */ 194 | async _fetchFlagDefinitions() { 195 | const response = await this.callFlagsEndpoint(); 196 | 197 | const newDefinitions = new Map(); 198 | response.flags.forEach((flag) => { 199 | newDefinitions.set(flag.key, flag); 200 | }); 201 | 202 | this.flagDefinitions = newDefinitions; 203 | 204 | return response; 205 | } 206 | 207 | /** 208 | * Find a variant by key (case-insensitive) and return complete SelectedVariant 209 | * @param {string} variantKey - Variant key to find 210 | * @param {ExperimentationFlag} flag - Flag definition 211 | * @returns {SelectedVariant|null} 212 | */ 213 | _getMatchingVariant(variantKey, flag) { 214 | for (const variant of flag.ruleset.variants) { 215 | if (variantKey.toLowerCase() === variant.key.toLowerCase()) { 216 | return { 217 | variant_key: variant.key, 218 | variant_value: variant.value, 219 | experiment_id: flag.experiment_id, 220 | is_experiment_active: flag.is_experiment_active, 221 | }; 222 | } 223 | } 224 | return null; 225 | } 226 | 227 | _getVariantOverrideForTestUser(flag, context) { 228 | if (!flag.ruleset.test?.users) { 229 | return null; 230 | } 231 | 232 | const distinctId = context.distinct_id; 233 | if (!distinctId) { 234 | return null; 235 | } 236 | 237 | const variantKey = flag.ruleset.test.users[distinctId]; 238 | if (!variantKey) { 239 | return null; 240 | } 241 | 242 | let selected_variant = this._getMatchingVariant(variantKey, flag); 243 | if (selected_variant) { 244 | selected_variant.is_qa_tester = true; 245 | } 246 | return selected_variant; 247 | } 248 | 249 | _getAssignedRollout(flag, contextValue, context) { 250 | for (let index = 0; index < flag.ruleset.rollout.length; index++) { 251 | const rollout = flag.ruleset.rollout[index]; 252 | 253 | let salt; 254 | if (flag.hash_salt !== null && flag.hash_salt !== undefined) { 255 | salt = flag.key + flag.hash_salt + index.toString(); 256 | } else { 257 | salt = flag.key + "rollout"; 258 | } 259 | 260 | const rolloutHash = normalizedHash(String(contextValue), salt); 261 | 262 | if ( 263 | rolloutHash < rollout.rollout_percentage && 264 | this._isRuntimeEvaluationSatisfied(rollout, context) 265 | ) { 266 | return rollout; 267 | } 268 | } 269 | 270 | return null; 271 | } 272 | 273 | _getAssignedVariant(flag, contextValue, flagKey, rollout) { 274 | if (rollout.variant_override) { 275 | const variant = this._getMatchingVariant( 276 | rollout.variant_override.key, 277 | flag, 278 | ); 279 | if (variant) { 280 | return { ...variant, is_qa_tester: false }; 281 | } 282 | } 283 | 284 | const storedSalt = 285 | flag.hash_salt !== null && flag.hash_salt !== undefined 286 | ? flag.hash_salt 287 | : ""; 288 | const salt = flagKey + storedSalt + "variant"; 289 | const variantHash = normalizedHash(String(contextValue), salt); 290 | 291 | const variants = flag.ruleset.variants.map((v) => ({ ...v })); 292 | if (rollout.variant_splits) { 293 | for (const variant of variants) { 294 | if (variant.key in rollout.variant_splits) { 295 | variant.split = rollout.variant_splits[variant.key]; 296 | } 297 | } 298 | } 299 | 300 | let selected = variants[0]; 301 | let cumulative = 0.0; 302 | for (const variant of variants) { 303 | selected = variant; 304 | cumulative += variant.split || 0.0; 305 | if (variantHash < cumulative) { 306 | break; 307 | } 308 | } 309 | 310 | return { 311 | variant_key: selected.key, 312 | variant_value: selected.value, 313 | experiment_id: flag.experiment_id, 314 | is_experiment_active: flag.is_experiment_active, 315 | is_qa_tester: false, 316 | }; 317 | } 318 | 319 | _isRuntimeEvaluationSatisfied(rollout, context) { 320 | if (!rollout.runtime_evaluation_definition) { 321 | return true; 322 | } 323 | 324 | const customProperties = context.custom_properties; 325 | if (!customProperties || typeof customProperties !== "object") { 326 | return false; 327 | } 328 | 329 | for (const [key, expectedValue] of Object.entries( 330 | rollout.runtime_evaluation_definition, 331 | )) { 332 | if (!(key in customProperties)) { 333 | return false; 334 | } 335 | 336 | const actualValue = customProperties[key]; 337 | if ( 338 | String(actualValue).toLowerCase() !== 339 | String(expectedValue).toLowerCase() 340 | ) { 341 | return false; 342 | } 343 | } 344 | return true; 345 | } 346 | } 347 | 348 | module.exports = LocalFeatureFlagsProvider; 349 | -------------------------------------------------------------------------------- /test/groups.js: -------------------------------------------------------------------------------- 1 | const Mixpanel = require("../lib/mixpanel-node"); 2 | 3 | describe("groups", () => { 4 | const endpoint = "/groups"; 5 | const group_key = "company"; 6 | const group_id = "Acme Inc."; 7 | const token = "token"; 8 | let mixpanel; 9 | beforeEach(() => { 10 | mixpanel = Mixpanel.init(token); 11 | vi.spyOn(mixpanel, "send_request"); 12 | 13 | return () => { 14 | mixpanel.send_request.mockRestore(); 15 | }; 16 | }); 17 | 18 | // shared test case 19 | const test_send_request_args = function ( 20 | func, 21 | { args, expected, use_modifiers, use_callback } = {}, 22 | ) { 23 | let expected_data = { 24 | $token: token, 25 | $group_key: group_key, 26 | $group_id: group_id, 27 | ...expected, 28 | }; 29 | let callback; 30 | 31 | args = [group_key, group_id, ...(args ? args : [])]; 32 | 33 | if (use_modifiers) { 34 | let modifiers = { 35 | $ignore_alias: true, 36 | $ignore_time: true, 37 | $ip: "1.2.3.4", 38 | $time: 1234567890, 39 | }; 40 | Object.assign(expected_data, modifiers); 41 | args.push(modifiers); 42 | } 43 | if (use_callback) { 44 | callback = function () {}; 45 | args.push(callback); 46 | } 47 | 48 | mixpanel.groups[func](...args); 49 | 50 | const expectedSendRequestArgs = [ 51 | { method: "GET", endpoint, data: expected_data }, 52 | use_callback ? callback : undefined, 53 | ]; 54 | expect(mixpanel.send_request).toHaveBeenCalledWith( 55 | ...expectedSendRequestArgs, 56 | ); 57 | }; 58 | 59 | describe("_set", () => { 60 | it("handles set_once correctly", () => { 61 | test_send_request_args("set_once", { 62 | args: ["key1", "val1"], 63 | expected: { $set_once: { key1: "val1" } }, 64 | }); 65 | }); 66 | 67 | it("calls send_request with correct endpoint and data", () => { 68 | test_send_request_args("set", { 69 | args: ["key1", "val1"], 70 | expected: { $set: { key1: "val1" } }, 71 | }); 72 | }); 73 | 74 | it("supports being called with a property object", () => { 75 | test_send_request_args("set", { 76 | args: [{ key1: "val1", key2: "val2" }], 77 | expected: { $set: { key1: "val1", key2: "val2" } }, 78 | }); 79 | }); 80 | 81 | it("supports being called with a property object (set_once)", () => { 82 | test_send_request_args("set_once", { 83 | args: [{ key1: "val1", key2: "val2" }], 84 | expected: { $set_once: { key1: "val1", key2: "val2" } }, 85 | }); 86 | }); 87 | 88 | it("supports being called with a modifiers argument", () => { 89 | test_send_request_args("set", { 90 | args: ["key1", "val1"], 91 | expected: { $set: { key1: "val1" } }, 92 | use_modifiers: true, 93 | }); 94 | }); 95 | 96 | it("supports being called with a modifiers argument (set_once)", () => { 97 | test_send_request_args("set_once", { 98 | args: ["key1", "val1"], 99 | expected: { $set_once: { key1: "val1" } }, 100 | use_modifiers: true, 101 | }); 102 | }); 103 | 104 | it("supports being called with a properties object and a modifiers argument", () => { 105 | test_send_request_args("set", { 106 | args: [{ key1: "val1", key2: "val2" }], 107 | expected: { $set: { key1: "val1", key2: "val2" } }, 108 | use_modifiers: true, 109 | }); 110 | }); 111 | 112 | it("supports being called with a properties object and a modifiers argument (set_once)", () => { 113 | test_send_request_args("set_once", { 114 | args: [{ key1: "val1", key2: "val2" }], 115 | expected: { $set_once: { key1: "val1", key2: "val2" } }, 116 | use_modifiers: true, 117 | }); 118 | }); 119 | 120 | it("handles the ip property in a property object properly", () => { 121 | test_send_request_args("set", { 122 | args: [{ ip: "1.2.3.4", key1: "val1", key2: "val2" }], 123 | expected: { 124 | $ip: "1.2.3.4", 125 | $set: { key1: "val1", key2: "val2" }, 126 | }, 127 | }); 128 | }); 129 | 130 | it("handles the $ignore_time property in a property object properly", () => { 131 | test_send_request_args("set", { 132 | args: [{ $ignore_time: true, key1: "val1", key2: "val2" }], 133 | expected: { 134 | $ignore_time: true, 135 | $set: { key1: "val1", key2: "val2" }, 136 | }, 137 | }); 138 | }); 139 | 140 | it("supports being called with a callback", () => { 141 | test_send_request_args("set", { 142 | args: ["key1", "val1"], 143 | expected: { $set: { key1: "val1" } }, 144 | use_callback: true, 145 | }); 146 | }); 147 | 148 | it("supports being called with a callback (set_once)", () => { 149 | test_send_request_args("set_once", { 150 | args: ["key1", "val1"], 151 | expected: { $set_once: { key1: "val1" } }, 152 | use_callback: true, 153 | }); 154 | }); 155 | 156 | it("supports being called with a properties object and a callback", () => { 157 | test_send_request_args("set", { 158 | args: [{ key1: "val1", key2: "val2" }], 159 | expected: { $set: { key1: "val1", key2: "val2" } }, 160 | use_callback: true, 161 | }); 162 | }); 163 | 164 | it("supports being called with a properties object and a callback (set_once)", () => { 165 | test_send_request_args("set_once", { 166 | args: [{ key1: "val1", key2: "val2" }], 167 | expected: { $set_once: { key1: "val1", key2: "val2" } }, 168 | use_callback: true, 169 | }); 170 | }); 171 | 172 | it("supports being called with a modifiers argument and a callback", () => { 173 | test_send_request_args("set", { 174 | args: ["key1", "val1"], 175 | expected: { $set: { key1: "val1" } }, 176 | use_callback: true, 177 | use_modifiers: true, 178 | }); 179 | }); 180 | 181 | it("supports being called with a modifiers argument and a callback (set_once)", () => { 182 | test_send_request_args("set_once", { 183 | args: ["key1", "val1"], 184 | expected: { $set_once: { key1: "val1" } }, 185 | use_callback: true, 186 | use_modifiers: true, 187 | }); 188 | }); 189 | 190 | it("supports being called with a properties object, a modifiers argument and a callback", () => { 191 | test_send_request_args("set", { 192 | args: [{ key1: "val1", key2: "val2" }], 193 | expected: { $set: { key1: "val1", key2: "val2" } }, 194 | use_callback: true, 195 | use_modifiers: true, 196 | }); 197 | }); 198 | 199 | it("supports being called with a properties object, a modifiers argument and a callback (set_once)", () => { 200 | test_send_request_args("set_once", { 201 | args: [{ key1: "val1", key2: "val2" }], 202 | expected: { $set_once: { key1: "val1", key2: "val2" } }, 203 | use_callback: true, 204 | use_modifiers: true, 205 | }); 206 | }); 207 | }); 208 | 209 | describe("delete_group", () => { 210 | it("calls send_request with correct endpoint and data", () => { 211 | test_send_request_args("delete_group", { 212 | expected: { $delete: "" }, 213 | }); 214 | }); 215 | 216 | it("supports being called with a modifiers argument", () => { 217 | test_send_request_args("delete_group", { 218 | expected: { $delete: "" }, 219 | use_modifiers: true, 220 | }); 221 | }); 222 | 223 | it("supports being called with a callback", () => { 224 | test_send_request_args("delete_group", { 225 | expected: { $delete: "" }, 226 | use_callback: true, 227 | }); 228 | }); 229 | 230 | it("supports being called with a modifiers argument and a callback", () => { 231 | test_send_request_args("delete_group", { 232 | expected: { $delete: "" }, 233 | use_callback: true, 234 | use_modifiers: true, 235 | }); 236 | }); 237 | }); 238 | 239 | describe("remove", () => { 240 | it("calls send_request with correct endpoint and data", () => { 241 | test_send_request_args("remove", { 242 | args: [{ key1: "value1", key2: "value2" }], 243 | expected: { $remove: { key1: "value1", key2: "value2" } }, 244 | }); 245 | }); 246 | 247 | it("errors on non-scalar argument types", () => { 248 | mixpanel.groups.remove(group_key, group_id, { key1: ["value1"] }); 249 | mixpanel.groups.remove(group_key, group_id, { key1: { key: "val" } }); 250 | mixpanel.groups.remove(group_key, group_id, 1231241.123); 251 | mixpanel.groups.remove(group_key, group_id, [5]); 252 | mixpanel.groups.remove(group_key, group_id, { key1: function () {} }); 253 | mixpanel.groups.remove(group_key, group_id, { key1: [function () {}] }); 254 | 255 | expect(mixpanel.send_request).not.toHaveBeenCalled(); 256 | }); 257 | 258 | it("supports being called with a modifiers argument", () => { 259 | test_send_request_args("remove", { 260 | args: [{ key1: "value1" }], 261 | expected: { $remove: { key1: "value1" } }, 262 | use_modifiers: true, 263 | }); 264 | }); 265 | 266 | it("supports being called with a callback", () => { 267 | test_send_request_args("remove", { 268 | args: [{ key1: "value1" }], 269 | expected: { $remove: { key1: "value1" } }, 270 | use_callback: true, 271 | }); 272 | }); 273 | 274 | it("supports being called with a modifiers argument and a callback", () => { 275 | test_send_request_args("remove", { 276 | args: [{ key1: "value1" }], 277 | expected: { $remove: { key1: "value1" } }, 278 | use_callback: true, 279 | use_modifiers: true, 280 | }); 281 | }); 282 | }); 283 | 284 | describe("union", () => { 285 | it("calls send_request with correct endpoint and data", () => { 286 | test_send_request_args("union", { 287 | args: [{ key1: ["value1", "value2"] }], 288 | expected: { $union: { key1: ["value1", "value2"] } }, 289 | }); 290 | }); 291 | 292 | it("supports being called with a scalar value", () => { 293 | test_send_request_args("union", { 294 | args: [{ key1: "value1" }], 295 | expected: { $union: { key1: ["value1"] } }, 296 | }); 297 | }); 298 | 299 | it("errors on other argument types", () => { 300 | mixpanel.groups.union(group_key, group_id, { key1: { key: "val" } }); 301 | mixpanel.groups.union(group_key, group_id, 1231241.123); 302 | mixpanel.groups.union(group_key, group_id, [5]); 303 | mixpanel.groups.union(group_key, group_id, { key1: function () {} }); 304 | mixpanel.groups.union(group_key, group_id, { key1: [function () {}] }); 305 | 306 | expect(mixpanel.send_request).not.toHaveBeenCalled(); 307 | }); 308 | 309 | it("supports being called with a modifiers argument", () => { 310 | test_send_request_args("union", { 311 | args: [{ key1: ["value1", "value2"] }], 312 | expected: { $union: { key1: ["value1", "value2"] } }, 313 | use_modifiers: true, 314 | }); 315 | }); 316 | 317 | it("supports being called with a callback", () => { 318 | test_send_request_args("union", { 319 | args: [{ key1: ["value1", "value2"] }], 320 | expected: { $union: { key1: ["value1", "value2"] } }, 321 | use_callback: true, 322 | }); 323 | }); 324 | 325 | it("supports being called with a modifiers argument and a callback", () => { 326 | test_send_request_args("union", { 327 | args: [{ key1: ["value1", "value2"] }], 328 | expected: { $union: { key1: ["value1", "value2"] } }, 329 | use_callback: true, 330 | use_modifiers: true, 331 | }); 332 | }); 333 | }); 334 | 335 | describe("unset", () => { 336 | it("calls send_request with correct endpoint and data", () => { 337 | test_send_request_args("unset", { 338 | args: ["key1"], 339 | expected: { $unset: ["key1"] }, 340 | }); 341 | }); 342 | 343 | it("supports being called with a property array", () => { 344 | test_send_request_args("unset", { 345 | args: [["key1", "key2"]], 346 | expected: { $unset: ["key1", "key2"] }, 347 | }); 348 | }); 349 | 350 | it("errors on other argument types", () => { 351 | mixpanel.groups.unset(group_key, group_id, { 352 | key1: "val1", 353 | key2: "val2", 354 | }); 355 | mixpanel.groups.unset(group_key, group_id, 1231241.123); 356 | 357 | expect(mixpanel.send_request).not.toHaveBeenCalled(); 358 | }); 359 | 360 | it("supports being called with a modifiers argument", () => { 361 | test_send_request_args("unset", { 362 | args: ["key1"], 363 | expected: { $unset: ["key1"] }, 364 | use_modifiers: true, 365 | }); 366 | }); 367 | 368 | it("supports being called with a callback", () => { 369 | test_send_request_args("unset", { 370 | args: ["key1"], 371 | expected: { $unset: ["key1"] }, 372 | use_callback: true, 373 | }); 374 | }); 375 | 376 | it("supports being called with a modifiers argument and a callback", () => { 377 | test_send_request_args("unset", { 378 | args: ["key1"], 379 | expected: { $unset: ["key1"] }, 380 | use_callback: true, 381 | use_modifiers: true, 382 | }); 383 | }); 384 | }); 385 | }); 386 | -------------------------------------------------------------------------------- /test/flags/remote_flags.js: -------------------------------------------------------------------------------- 1 | const nock = require("nock"); 2 | const RemoteFeatureFlagsProvider = require("../../lib/flags/remote_flags"); 3 | 4 | const mockSuccessResponse = (flags_with_selected_variant) => { 5 | const remote_response = { 6 | code: 200, 7 | flags: flags_with_selected_variant, 8 | }; 9 | 10 | nock("https://localhost") 11 | .get("/flags") 12 | .query(true) 13 | .reply(200, remote_response); 14 | }; 15 | 16 | describe("RemoteFeatureFlagProvider", () => { 17 | const flagsEndpointHostName = "localhost"; 18 | const TEST_TOKEN = "test-token"; 19 | 20 | const TEST_CONTEXT = { 21 | distinct_id: "test-user", 22 | }; 23 | 24 | let provider; 25 | let mockTracker; 26 | 27 | beforeEach(() => { 28 | mockTracker = vi.fn(); 29 | 30 | let mockLogger = { 31 | error: vi.fn(), 32 | warn: vi.fn(), 33 | info: vi.fn(), 34 | }; 35 | 36 | let config = { 37 | api_host: flagsEndpointHostName, 38 | }; 39 | 40 | provider = new RemoteFeatureFlagsProvider( 41 | TEST_TOKEN, 42 | config, 43 | mockTracker, 44 | mockLogger, 45 | ); 46 | }); 47 | 48 | afterEach(() => { 49 | vi.restoreAllMocks(); 50 | nock.cleanAll(); 51 | }); 52 | 53 | describe("getVariant", () => { 54 | it("should return variant when served", async () => { 55 | mockSuccessResponse({ 56 | "new-feature": { 57 | variant_key: "on", 58 | variant_value: true, 59 | }, 60 | }); 61 | 62 | const expectedVariant = { 63 | variant_key: "on", 64 | variant_value: true, 65 | }; 66 | 67 | const result = await provider.getVariant( 68 | "new-feature", 69 | null, 70 | TEST_CONTEXT, 71 | ); 72 | 73 | expect(result).toEqual(expectedVariant); 74 | }); 75 | 76 | it("should select fallback variant when no flags are served", async () => { 77 | nock("https://localhost") 78 | .get("/flags") 79 | .query(true) 80 | .reply(200, { code: 200, flags: {} }); 81 | 82 | const fallbackVariant = { 83 | variant_key: "control", 84 | variant_value: false, 85 | }; 86 | 87 | const result = await provider.getVariant( 88 | "any-flag", 89 | fallbackVariant, 90 | TEST_CONTEXT, 91 | ); 92 | 93 | expect(result).toEqual(fallbackVariant); 94 | expect(mockTracker).not.toHaveBeenCalled(); 95 | }); 96 | 97 | it("should select fallback variant if flag does not exist in served flags", async () => { 98 | mockSuccessResponse({ 99 | "different-flag": { 100 | variant_key: "on", 101 | variant_value: true, 102 | }, 103 | }); 104 | 105 | const fallbackVariant = { 106 | variant_key: "control", 107 | variant_value: false, 108 | }; 109 | 110 | const result = await provider.getVariant( 111 | "missing-flag", 112 | fallbackVariant, 113 | TEST_CONTEXT, 114 | ); 115 | 116 | expect(result).toEqual(fallbackVariant); 117 | expect(mockTracker).not.toHaveBeenCalled(); 118 | }); 119 | 120 | it("No exposure events are tracked when fallback variant is selected", async () => { 121 | nock("https://localhost") 122 | .get("/flags") 123 | .query(true) 124 | .reply(200, { code: 200, flags: {} }); 125 | 126 | const fallbackVariant = { 127 | variant_key: "control", 128 | variant_value: false, 129 | }; 130 | 131 | await provider.getVariant("any-flag", fallbackVariant, TEST_CONTEXT); 132 | 133 | expect(mockTracker).not.toHaveBeenCalled(); 134 | }); 135 | 136 | it("Exposure event is tracked when a variant is selected", async () => { 137 | mockSuccessResponse({ 138 | "test-flag": { 139 | variant_key: "treatment", 140 | variant_value: true, 141 | }, 142 | }); 143 | 144 | const fallbackVariant = { 145 | variant_key: "control", 146 | variant_value: false, 147 | }; 148 | 149 | const result = await provider.getVariant( 150 | "test-flag", 151 | fallbackVariant, 152 | TEST_CONTEXT, 153 | ); 154 | 155 | expect(result).toEqual({ 156 | variant_key: "treatment", 157 | variant_value: true, 158 | }); 159 | 160 | expect(mockTracker).toHaveBeenCalledTimes(1); 161 | 162 | expect(mockTracker).toHaveBeenCalledWith( 163 | "$experiment_started", 164 | expect.objectContaining({ 165 | distinct_id: "test-user", 166 | "Experiment name": "test-flag", 167 | "Variant name": "treatment", 168 | $experiment_type: "feature_flag", 169 | "Flag evaluation mode": "remote", 170 | }), 171 | expect.any(Function), 172 | ); 173 | }); 174 | }); 175 | 176 | describe("getVariantValue", () => { 177 | it("should return variant value when flag exists", async () => { 178 | mockSuccessResponse({ 179 | "test-flag": { 180 | variant_key: "treatment", 181 | variant_value: "blue", 182 | }, 183 | }); 184 | 185 | const result = await provider.getVariantValue( 186 | "test-flag", 187 | "default", 188 | TEST_CONTEXT, 189 | ); 190 | 191 | expect(result).toEqual("blue"); 192 | }); 193 | 194 | it("should return fallback value when flag doesn't exist", async () => { 195 | mockSuccessResponse({ 196 | "different-flag": { 197 | variant_key: "on", 198 | variant_value: true, 199 | }, 200 | }); 201 | 202 | const result = await provider.getVariantValue( 203 | "missing-flag", 204 | "default-value", 205 | TEST_CONTEXT, 206 | ); 207 | 208 | expect(result).toEqual("default-value"); 209 | }); 210 | 211 | it("should track exposure event by default", async () => { 212 | mockSuccessResponse({ 213 | "test-flag": { 214 | variant_key: "treatment", 215 | variant_value: "value", 216 | }, 217 | }); 218 | 219 | await provider.getVariantValue("test-flag", "default", TEST_CONTEXT); 220 | 221 | expect(mockTracker).toHaveBeenCalledTimes(1); 222 | expect(mockTracker).toHaveBeenCalledWith( 223 | "$experiment_started", 224 | expect.objectContaining({ 225 | "Experiment name": "test-flag", 226 | "Variant name": "treatment", 227 | }), 228 | expect.any(Function), 229 | ); 230 | }); 231 | 232 | it("should NOT track exposure event when reportExposure is false", async () => { 233 | mockSuccessResponse({ 234 | "test-flag": { 235 | variant_key: "treatment", 236 | variant_value: "value", 237 | }, 238 | }); 239 | 240 | await provider.getVariantValue( 241 | "test-flag", 242 | "default", 243 | TEST_CONTEXT, 244 | false, 245 | ); 246 | 247 | expect(mockTracker).not.toHaveBeenCalled(); 248 | }); 249 | 250 | it("should handle different variant value types", async () => { 251 | // Test string 252 | mockSuccessResponse({ 253 | "string-flag": { 254 | variant_key: "treatment", 255 | variant_value: "text-value", 256 | }, 257 | }); 258 | let result = await provider.getVariantValue( 259 | "string-flag", 260 | "default", 261 | TEST_CONTEXT, 262 | ); 263 | expect(result).toEqual("text-value"); 264 | 265 | // Test number 266 | nock.cleanAll(); 267 | mockSuccessResponse({ 268 | "number-flag": { 269 | variant_key: "treatment", 270 | variant_value: 42, 271 | }, 272 | }); 273 | result = await provider.getVariantValue("number-flag", 0, TEST_CONTEXT); 274 | expect(result).toEqual(42); 275 | 276 | // Test object 277 | nock.cleanAll(); 278 | mockSuccessResponse({ 279 | "object-flag": { 280 | variant_key: "treatment", 281 | variant_value: { key: "value" }, 282 | }, 283 | }); 284 | result = await provider.getVariantValue("object-flag", {}, TEST_CONTEXT); 285 | expect(result).toEqual({ key: "value" }); 286 | }); 287 | 288 | it("should return fallback on network error", async () => { 289 | nock("https://localhost") 290 | .get("/flags") 291 | .query(true) 292 | .replyWithError("Network error"); 293 | 294 | const result = await provider.getVariantValue( 295 | "test-flag", 296 | "fallback", 297 | TEST_CONTEXT, 298 | ); 299 | 300 | expect(result).toEqual("fallback"); 301 | }); 302 | 303 | it("should return fallback when no flags are served", async () => { 304 | nock("https://localhost") 305 | .get("/flags") 306 | .query(true) 307 | .reply(200, { code: 200, flags: {} }); 308 | 309 | const result = await provider.getVariantValue( 310 | "test-flag", 311 | "fallback", 312 | TEST_CONTEXT, 313 | ); 314 | 315 | expect(result).toEqual("fallback"); 316 | }); 317 | 318 | it("should NOT track exposure when fallback is returned", async () => { 319 | nock("https://localhost") 320 | .get("/flags") 321 | .query(true) 322 | .reply(200, { code: 200, flags: {} }); 323 | 324 | await provider.getVariantValue("test-flag", "fallback", TEST_CONTEXT); 325 | 326 | expect(mockTracker).not.toHaveBeenCalled(); 327 | }); 328 | }); 329 | 330 | describe("getAllVariants", () => { 331 | it("should return all variants from API", async () => { 332 | mockSuccessResponse({ 333 | "flag-1": { 334 | variant_key: "treatment", 335 | variant_value: true, 336 | }, 337 | "flag-2": { 338 | variant_key: "control", 339 | variant_value: false, 340 | }, 341 | "flag-3": { 342 | variant_key: "blue", 343 | variant_value: "blue-theme", 344 | }, 345 | }); 346 | 347 | const result = await provider.getAllVariants(TEST_CONTEXT); 348 | 349 | expect(result).toEqual({ 350 | "flag-1": { 351 | variant_key: "treatment", 352 | variant_value: true, 353 | }, 354 | "flag-2": { 355 | variant_key: "control", 356 | variant_value: false, 357 | }, 358 | "flag-3": { 359 | variant_key: "blue", 360 | variant_value: "blue-theme", 361 | }, 362 | }); 363 | }); 364 | 365 | it("should return empty object when no flags served", async () => { 366 | nock("https://localhost") 367 | .get("/flags") 368 | .query(true) 369 | .reply(200, { code: 200, flags: {} }); 370 | 371 | const result = await provider.getAllVariants(TEST_CONTEXT); 372 | 373 | expect(result).toEqual({}); 374 | }); 375 | 376 | it("should NOT track any exposure events", async () => { 377 | mockSuccessResponse({ 378 | "flag-1": { 379 | variant_key: "treatment", 380 | variant_value: true, 381 | }, 382 | "flag-2": { 383 | variant_key: "control", 384 | variant_value: false, 385 | }, 386 | }); 387 | 388 | await provider.getAllVariants(TEST_CONTEXT); 389 | 390 | expect(mockTracker).not.toHaveBeenCalled(); 391 | }); 392 | }); 393 | 394 | describe("isEnabled", () => { 395 | it("should return true when variant value is boolean true", async () => { 396 | mockSuccessResponse({ 397 | "test-flag": { 398 | variant_key: "on", 399 | variant_value: true, 400 | }, 401 | }); 402 | 403 | const result = await provider.isEnabled("test-flag", TEST_CONTEXT); 404 | 405 | expect(result).toBe(true); 406 | }); 407 | 408 | it("should return false when variant value is boolean false", async () => { 409 | mockSuccessResponse({ 410 | "test-flag": { 411 | variant_key: "off", 412 | variant_value: false, 413 | }, 414 | }); 415 | 416 | const result = await provider.isEnabled("test-flag", TEST_CONTEXT); 417 | 418 | expect(result).toBe(false); 419 | }); 420 | 421 | it("should return false for truthy non-boolean values", async () => { 422 | // Test string "true" 423 | mockSuccessResponse({ 424 | "string-flag": { 425 | variant_key: "treatment", 426 | variant_value: "true", 427 | }, 428 | }); 429 | let result = await provider.isEnabled("string-flag", TEST_CONTEXT); 430 | expect(result).toBe(false); 431 | 432 | // Test number 1 433 | nock.cleanAll(); 434 | mockSuccessResponse({ 435 | "number-flag": { 436 | variant_key: "treatment", 437 | variant_value: 1, 438 | }, 439 | }); 440 | result = await provider.isEnabled("number-flag", TEST_CONTEXT); 441 | expect(result).toBe(false); 442 | }); 443 | 444 | it("should return false when flag doesn't exist", async () => { 445 | mockSuccessResponse({ 446 | "different-flag": { 447 | variant_key: "on", 448 | variant_value: true, 449 | }, 450 | }); 451 | 452 | const result = await provider.isEnabled("missing-flag", TEST_CONTEXT); 453 | 454 | expect(result).toBe(false); 455 | }); 456 | 457 | it("should track exposure event", async () => { 458 | mockSuccessResponse({ 459 | "test-flag": { 460 | variant_key: "on", 461 | variant_value: true, 462 | }, 463 | }); 464 | 465 | await provider.isEnabled("test-flag", TEST_CONTEXT); 466 | 467 | expect(mockTracker).toHaveBeenCalledTimes(1); 468 | expect(mockTracker).toHaveBeenCalledWith( 469 | "$experiment_started", 470 | expect.objectContaining({ 471 | "Experiment name": "test-flag", 472 | "Variant name": "on", 473 | }), 474 | expect.any(Function), 475 | ); 476 | }); 477 | 478 | it("should return false on network error", async () => { 479 | nock("https://localhost") 480 | .get("/flags") 481 | .query(true) 482 | .replyWithError("Network error"); 483 | 484 | const result = await provider.isEnabled("test-flag", TEST_CONTEXT); 485 | 486 | expect(result).toBe(false); 487 | }); 488 | }); 489 | }); 490 | -------------------------------------------------------------------------------- /lib/mixpanel-node.js: -------------------------------------------------------------------------------- 1 | /* 2 | Heavily inspired by the original js library copyright Mixpanel, Inc. 3 | (http://mixpanel.com/) 4 | 5 | Copyright (c) 2012 Carl Sverre 6 | 7 | Released under the MIT license. 8 | */ 9 | 10 | const querystring = require("querystring"); 11 | const Buffer = require("buffer").Buffer; 12 | const http = require("http"); 13 | const https = require("https"); 14 | const HttpsProxyAgent = require("https-proxy-agent"); 15 | const url = require("url"); 16 | const packageInfo = require("../package.json"); 17 | 18 | const { async_all, ensure_timestamp, assert_logger } = require("./utils"); 19 | const { MixpanelGroups } = require("./groups"); 20 | const { MixpanelPeople } = require("./people"); 21 | const { 22 | LocalFeatureFlagsProvider, 23 | RemoteFeatureFlagsProvider, 24 | } = require("./flags"); 25 | 26 | const DEFAULT_CONFIG = { 27 | test: false, 28 | debug: false, 29 | verbose: false, 30 | host: "api.mixpanel.com", 31 | protocol: "https", 32 | path: "", 33 | keepAlive: true, 34 | // set this to true to automatically geolocate based on the client's ip. 35 | // e.g., when running under electron 36 | geolocate: false, 37 | logger: console, 38 | }; 39 | 40 | const create_client = function (token, config) { 41 | if (!token) { 42 | throw new Error( 43 | "The Mixpanel Client needs a Mixpanel token: `init(token)`", 44 | ); 45 | } 46 | 47 | const metrics = { 48 | token, 49 | config: { ...DEFAULT_CONFIG }, 50 | }; 51 | const { keepAlive } = metrics.config; 52 | 53 | // mixpanel constants 54 | const MAX_BATCH_SIZE = 50; 55 | const REQUEST_LIBS = { http, https }; 56 | const REQUEST_AGENTS = { 57 | http: new http.Agent({ keepAlive }), 58 | https: new https.Agent({ keepAlive }), 59 | }; 60 | const proxyPath = process.env.HTTPS_PROXY || process.env.HTTP_PROXY; 61 | const proxyAgent = proxyPath 62 | ? new HttpsProxyAgent( 63 | Object.assign(url.parse(proxyPath), { 64 | keepAlive, 65 | }), 66 | ) 67 | : null; 68 | 69 | /** 70 | * sends an async GET or POST request to mixpanel 71 | * for batch processes data must be send in the body of a POST 72 | * @param {object} options 73 | * @param {string} options.endpoint 74 | * @param {object} options.data the data to send in the request 75 | * @param {string} [options.method] e.g. `get` or `post`, defaults to `get` 76 | * @param {function} callback called on request completion or error 77 | */ 78 | metrics.send_request = function (options, callback) { 79 | callback = callback || function () {}; 80 | 81 | let content = Buffer.from(JSON.stringify(options.data)).toString("base64"); 82 | const endpoint = options.endpoint; 83 | const method = (options.method || "GET").toUpperCase(); 84 | let query_params = { 85 | ip: metrics.config.geolocate ? 1 : 0, 86 | verbose: metrics.config.verbose ? 1 : 0, 87 | }; 88 | const key = metrics.config.key; 89 | const secret = metrics.config.secret; 90 | const request_lib = REQUEST_LIBS[metrics.config.protocol]; 91 | let request_options = { 92 | host: metrics.config.host, 93 | port: metrics.config.port, 94 | headers: {}, 95 | method: method, 96 | }; 97 | let request; 98 | 99 | if (!request_lib) { 100 | throw new Error( 101 | "Mixpanel Initialization Error: Unsupported protocol " + 102 | metrics.config.protocol + 103 | ". " + 104 | "Supported protocols are: " + 105 | Object.keys(REQUEST_LIBS), 106 | ); 107 | } 108 | 109 | if (method === "POST") { 110 | content = "data=" + content; 111 | request_options.headers["Content-Type"] = 112 | "application/x-www-form-urlencoded"; 113 | request_options.headers["Content-Length"] = Buffer.byteLength(content); 114 | } else if (method === "GET") { 115 | query_params.data = content; 116 | } 117 | 118 | // add auth params 119 | if (secret) { 120 | if (request_lib !== https) { 121 | throw new Error("Must use HTTPS if authenticating with API Secret"); 122 | } 123 | const encoded = Buffer.from(secret + ":").toString("base64"); 124 | request_options.headers["Authorization"] = "Basic " + encoded; 125 | } else if (key) { 126 | query_params.api_key = key; 127 | } else if (endpoint === "/import") { 128 | throw new Error( 129 | "The Mixpanel Client needs a Mixpanel API Secret when importing old events: `init(token, { secret: ... })`", 130 | ); 131 | } 132 | 133 | request_options.agent = 134 | proxyAgent || REQUEST_AGENTS[metrics.config.protocol]; 135 | 136 | if (metrics.config.test) { 137 | query_params.test = 1; 138 | } 139 | 140 | request_options.path = 141 | metrics.config.path + 142 | endpoint + 143 | "?" + 144 | querystring.stringify(query_params); 145 | 146 | request = request_lib.request(request_options, function (res) { 147 | let data = ""; 148 | res.on("data", function (chunk) { 149 | data += chunk; 150 | }); 151 | 152 | res.on("end", function () { 153 | let e; 154 | if (metrics.config.verbose) { 155 | try { 156 | const result = JSON.parse(data); 157 | if (result.status != 1) { 158 | e = new Error("Mixpanel Server Error: " + result.error); 159 | } 160 | } catch (ex) { 161 | e = new Error( 162 | "Could not parse response from Mixpanel " + ex.message, 163 | ); 164 | } 165 | } else { 166 | e = 167 | data !== "1" 168 | ? new Error("Mixpanel Server Error: " + data) 169 | : undefined; 170 | } 171 | 172 | callback(e); 173 | }); 174 | }); 175 | 176 | request.on("error", function (e) { 177 | if (metrics.config.debug) { 178 | metrics.config.logger.error("Got Error: " + e.message); 179 | } 180 | callback(e); 181 | }); 182 | 183 | if (method === "POST") { 184 | request.write(content); 185 | } 186 | request.end(); 187 | }; 188 | 189 | /** 190 | * Send an event to Mixpanel, using the specified endpoint (e.g., track/import) 191 | * @param {string} endpoint - API endpoint name 192 | * @param {string} event - event name 193 | * @param {object} properties - event properties 194 | * @param {Function} [callback] - callback for request completion/error 195 | */ 196 | metrics.send_event_request = function ( 197 | endpoint, 198 | event, 199 | properties, 200 | callback, 201 | ) { 202 | properties.token = metrics.token; 203 | properties.mp_lib = "node"; 204 | properties.$lib_version = packageInfo.version; 205 | 206 | const data = { 207 | event: event, 208 | properties: properties, 209 | }; 210 | 211 | if (metrics.config.debug) { 212 | metrics.config.logger.debug("Sending the following event to Mixpanel", { 213 | data, 214 | }); 215 | } 216 | 217 | metrics.send_request( 218 | { method: "GET", endpoint: endpoint, data: data }, 219 | callback, 220 | ); 221 | }; 222 | 223 | /** 224 | * breaks array into equal-sized chunks, with the last chunk being the remainder 225 | * @param {Array} arr 226 | * @param {number} size 227 | * @returns {Array} 228 | */ 229 | const chunk = function (arr, size) { 230 | let chunks = [], 231 | i = 0, 232 | total = arr.length; 233 | 234 | while (i < total) { 235 | chunks.push(arr.slice(i, (i += size))); 236 | } 237 | return chunks; 238 | }; 239 | 240 | /** 241 | * sends events in batches 242 | * @param {object} options 243 | * @param {[{}]} options.event_list array of event objects 244 | * @param {string} options.endpoint e.g. `/track` or `/import` 245 | * @param {number} [options.max_concurrent_requests] limits concurrent async requests over the network 246 | * @param {number} [options.max_batch_size] limits number of events sent to mixpanel per request 247 | * @param {Function} [callback] callback receives array of errors if any 248 | * 249 | */ 250 | const send_batch_requests = function (options, callback) { 251 | const event_list = options.event_list, 252 | endpoint = options.endpoint, 253 | max_batch_size = options.max_batch_size 254 | ? Math.min(MAX_BATCH_SIZE, options.max_batch_size) 255 | : MAX_BATCH_SIZE, 256 | // to maintain original intention of max_batch_size; if max_batch_size is greater than 50, we assume the user is trying to set max_concurrent_requests 257 | max_concurrent_requests = 258 | options.max_concurrent_requests || 259 | (options.max_batch_size > MAX_BATCH_SIZE && 260 | Math.ceil(options.max_batch_size / MAX_BATCH_SIZE)), 261 | event_batches = chunk(event_list, max_batch_size), 262 | request_batches = max_concurrent_requests 263 | ? chunk(event_batches, max_concurrent_requests) 264 | : [event_batches], 265 | total_event_batches = event_batches.length, 266 | total_request_batches = request_batches.length; 267 | 268 | /** 269 | * sends a batch of events to mixpanel through http api 270 | * @param {Array} batch 271 | * @param {Function} cb 272 | */ 273 | function send_event_batch(batch, cb) { 274 | if (batch.length > 0) { 275 | batch = batch.map(function (event) { 276 | if (endpoint === "/import" || event.properties.time) { 277 | // usually there will be a time property, but not required for `/track` endpoint 278 | event.properties.time = ensure_timestamp(event.properties.time); 279 | } 280 | event.properties.token = event.properties.token || metrics.token; 281 | return event; 282 | }); 283 | 284 | // must be a POST 285 | metrics.send_request( 286 | { method: "POST", endpoint: endpoint, data: batch }, 287 | cb, 288 | ); 289 | } 290 | } 291 | 292 | /** 293 | * Asynchronously sends batches of requests 294 | * @param {number} index 295 | */ 296 | function send_next_request_batch(index) { 297 | const request_batch = request_batches[index], 298 | cb = function (errors, results) { 299 | index += 1; 300 | if (index === total_request_batches) { 301 | callback && callback(errors, results); 302 | } else { 303 | send_next_request_batch(index); 304 | } 305 | }; 306 | 307 | async_all(request_batch, send_event_batch, cb); 308 | } 309 | 310 | // init recursive function 311 | send_next_request_batch(0); 312 | 313 | if (metrics.config.debug) { 314 | metrics.config.logger.debug( 315 | "Sending " + 316 | event_list.length + 317 | " events to Mixpanel in " + 318 | total_event_batches + 319 | " batches of events and " + 320 | total_request_batches + 321 | " batches of requests", 322 | ); 323 | } 324 | }; 325 | 326 | /** 327 | track(event, properties, callback) 328 | --- 329 | this function sends an event to mixpanel. 330 | 331 | event:string the event name 332 | properties:object additional event properties to send 333 | callback:function(err:Error) callback is called when the request is 334 | finished or an error occurs 335 | */ 336 | metrics.track = function (event, properties, callback) { 337 | if (!properties || typeof properties === "function") { 338 | callback = properties; 339 | properties = {}; 340 | } 341 | 342 | // time is optional for `track` 343 | if (properties.time) { 344 | properties.time = ensure_timestamp(properties.time); 345 | } 346 | 347 | metrics.send_event_request("/track", event, properties, callback); 348 | }; 349 | 350 | /** 351 | * send a batch of events to mixpanel `track` endpoint: this should only be used if events are less than 5 days old 352 | * @param {Array} event_list array of event objects to track 353 | * @param {object} [options] 354 | * @param {number} [options.max_concurrent_requests] number of concurrent http requests that can be made to mixpanel 355 | * @param {number} [options.max_batch_size] number of events that can be sent to mixpanel per request 356 | * @param {Function} [callback] callback receives array of errors if any 357 | */ 358 | metrics.track_batch = function (event_list, options, callback) { 359 | options = options || {}; 360 | if (typeof options === "function") { 361 | callback = options; 362 | options = {}; 363 | } 364 | const batch_options = { 365 | event_list: event_list, 366 | endpoint: "/track", 367 | max_concurrent_requests: options.max_concurrent_requests, 368 | max_batch_size: options.max_batch_size, 369 | }; 370 | 371 | send_batch_requests(batch_options, callback); 372 | }; 373 | 374 | /** 375 | import(event, time, properties, callback) 376 | --- 377 | This function sends an event to mixpanel using the import 378 | endpoint. The time argument should be either a Date or Number, 379 | and should signify the time the event occurred. 380 | 381 | It is highly recommended that you specify the distinct_id 382 | property for each event you import, otherwise the events will be 383 | tied to the IP address of the sending machine. 384 | 385 | For more information look at: 386 | https://mixpanel.com/docs/api-documentation/importing-events-older-than-31-days 387 | 388 | event:string the event name 389 | time:date|number the time of the event 390 | properties:object additional event properties to send 391 | callback:function(err:Error) callback is called when the request is 392 | finished or an error occurs 393 | */ 394 | metrics.import = function (event, time, properties, callback) { 395 | if (!properties || typeof properties === "function") { 396 | callback = properties; 397 | properties = {}; 398 | } 399 | 400 | properties.time = ensure_timestamp(time); 401 | 402 | metrics.send_event_request("/import", event, properties, callback); 403 | }; 404 | 405 | /** 406 | import_batch(event_list, options, callback) 407 | --- 408 | This function sends a list of events to mixpanel using the import 409 | endpoint. The format of the event array should be: 410 | 411 | [ 412 | { 413 | "event": "event name", 414 | "properties": { 415 | "time": new Date(), // Number or Date; required for each event 416 | "key": "val", 417 | ... 418 | } 419 | }, 420 | { 421 | "event": "event name", 422 | "properties": { 423 | "time": new Date() // Number or Date; required for each event 424 | } 425 | }, 426 | ... 427 | ] 428 | 429 | See import() for further information about the import endpoint. 430 | 431 | Options: 432 | max_batch_size: the maximum number of events to be transmitted over 433 | the network simultaneously. useful for capping bandwidth 434 | usage. 435 | max_concurrent_requests: the maximum number of concurrent http requests that 436 | can be made to mixpanel; also useful for capping bandwidth. 437 | 438 | N.B.: the Mixpanel API only accepts 50 events per request, so regardless 439 | of max_batch_size, larger lists of events will be chunked further into 440 | groups of 50. 441 | 442 | event_list:array list of event names and properties 443 | options:object optional batch configuration 444 | callback:function(error_list:array) callback is called when the request is 445 | finished or an error occurs 446 | */ 447 | metrics.import_batch = function (event_list, options, callback) { 448 | let batch_options; 449 | 450 | if (typeof options === "function" || !options) { 451 | callback = options; 452 | options = {}; 453 | } 454 | batch_options = { 455 | event_list: event_list, 456 | endpoint: "/import", 457 | max_concurrent_requests: options.max_concurrent_requests, 458 | max_batch_size: options.max_batch_size, 459 | }; 460 | send_batch_requests(batch_options, callback); 461 | }; 462 | 463 | /** 464 | alias(distinct_id, alias) 465 | --- 466 | This function creates an alias for distinct_id 467 | 468 | For more information look at: 469 | https://mixpanel.com/docs/integration-libraries/using-mixpanel-alias 470 | 471 | distinct_id:string the current identifier 472 | alias:string the future alias 473 | */ 474 | metrics.alias = function (distinct_id, alias, callback) { 475 | const properties = { 476 | distinct_id: distinct_id, 477 | alias: alias, 478 | }; 479 | 480 | metrics.track("$create_alias", properties, callback); 481 | }; 482 | 483 | metrics.groups = new MixpanelGroups(metrics); 484 | metrics.people = new MixpanelPeople(metrics); 485 | 486 | /** 487 | set_config(config) 488 | --- 489 | Modifies the mixpanel config 490 | 491 | config:object an object with properties to override in the 492 | mixpanel client config 493 | */ 494 | metrics.set_config = function (config) { 495 | if (config && config.logger !== undefined) { 496 | assert_logger(config.logger); 497 | } 498 | Object.assign(metrics.config, config); 499 | if (config.host) { 500 | // Split host into host and port 501 | const [host, port] = config.host.split(":"); 502 | metrics.config.host = host; 503 | if (port) { 504 | metrics.config.port = Number(port); 505 | } 506 | } 507 | }; 508 | 509 | if (config) { 510 | metrics.set_config(config); 511 | } 512 | 513 | // Initialize feature flags providers if configs are provided 514 | if (config && config.local_flags_config) { 515 | metrics.local_flags = new LocalFeatureFlagsProvider( 516 | token, 517 | config.local_flags_config, 518 | metrics.track.bind(metrics), 519 | config.logger, 520 | ); 521 | } 522 | 523 | if (config && config.remote_flags_config) { 524 | metrics.remote_flags = new RemoteFeatureFlagsProvider( 525 | token, 526 | config.remote_flags_config, 527 | metrics.track.bind(metrics), 528 | config.logger, 529 | ); 530 | } 531 | 532 | return metrics; 533 | }; 534 | 535 | // module exporting 536 | module.exports = { 537 | init: create_client, 538 | }; 539 | -------------------------------------------------------------------------------- /test/flags/local_flags.js: -------------------------------------------------------------------------------- 1 | const nock = require("nock"); 2 | const LocalFeatureFlagsProvider = require("../../lib/flags/local_flags"); 3 | 4 | const mockFlagDefinitionsResponse = (flags) => { 5 | const response = { 6 | code: 200, 7 | flags: flags, 8 | }; 9 | 10 | nock("https://localhost") 11 | .get("/flags/definitions") 12 | .query(true) 13 | .reply(200, response); 14 | }; 15 | 16 | const mockFailedFlagDefinitionsResponse = (statusCode) => { 17 | nock("https://localhost") 18 | .get("/flags/definitions") 19 | .query(true) 20 | .reply(statusCode); 21 | }; 22 | 23 | const createTestFlag = ({ 24 | flagKey = "test_flag", 25 | context = "distinct_id", 26 | variants = null, 27 | variantOverride = null, 28 | rolloutPercentage = 100.0, 29 | runtimeEvaluation = null, 30 | testUsers = null, 31 | experimentId = null, 32 | isExperimentActive = null, 33 | variantSplits = null, 34 | hashSalt = null, 35 | } = {}) => { 36 | const defaultVariants = [ 37 | { key: "control", value: "control", is_control: true, split: 50.0 }, 38 | { key: "treatment", value: "treatment", is_control: false, split: 50.0 }, 39 | ]; 40 | 41 | const rollout = [ 42 | { 43 | rollout_percentage: rolloutPercentage, 44 | runtime_evaluation_definition: runtimeEvaluation, 45 | variant_override: variantOverride, 46 | variant_splits: variantSplits, 47 | }, 48 | ]; 49 | 50 | const testConfig = testUsers ? { users: testUsers } : null; 51 | 52 | return { 53 | id: "test-id", 54 | name: "Test Flag", 55 | key: flagKey, 56 | status: "active", 57 | project_id: 123, 58 | context: context, 59 | experiment_id: experimentId, 60 | is_experiment_active: isExperimentActive, 61 | hash_salt: hashSalt, 62 | ruleset: { 63 | variants: variants || defaultVariants, 64 | rollout: rollout, 65 | test: testConfig, 66 | }, 67 | }; 68 | }; 69 | 70 | describe("LocalFeatureFlagsProvider", () => { 71 | const TEST_TOKEN = "test-token"; 72 | const TEST_CONTEXT = { 73 | distinct_id: "test-user", 74 | }; 75 | 76 | let mockTracker; 77 | let mockLogger; 78 | 79 | beforeEach(() => { 80 | mockTracker = vi.fn(); 81 | 82 | mockLogger = { 83 | error: vi.fn(), 84 | warn: vi.fn(), 85 | debug: vi.fn(), 86 | }; 87 | }); 88 | 89 | afterEach(() => { 90 | vi.restoreAllMocks(); 91 | nock.cleanAll(); 92 | }); 93 | 94 | describe("getVariant", () => { 95 | let provider; 96 | 97 | beforeEach(() => { 98 | const config = { 99 | api_host: "localhost", 100 | enable_polling: false, 101 | }; 102 | 103 | provider = new LocalFeatureFlagsProvider( 104 | TEST_TOKEN, 105 | config, 106 | mockTracker, 107 | mockLogger, 108 | ); 109 | }); 110 | 111 | afterEach(() => { 112 | provider.stopPollingForDefinitions(); 113 | }); 114 | 115 | it("should return fallback when no flag definitions", async () => { 116 | mockFlagDefinitionsResponse([]); 117 | await provider.startPollingForDefinitions(); 118 | 119 | const result = provider.getVariant( 120 | "nonexistent_flag", 121 | { variant_value: "control" }, 122 | TEST_CONTEXT, 123 | ); 124 | expect(result.variant_value).toBe("control"); 125 | expect(mockTracker).not.toHaveBeenCalled(); 126 | }); 127 | 128 | it("should return fallback if flag definition call fails", async () => { 129 | mockFailedFlagDefinitionsResponse(500); 130 | 131 | await provider.startPollingForDefinitions(); 132 | const result = provider.getVariant( 133 | "nonexistent_flag", 134 | { variant_value: "control" }, 135 | TEST_CONTEXT, 136 | ); 137 | expect(result.variant_value).toBe("control"); 138 | }); 139 | 140 | it("should return fallback when flag does not exist", async () => { 141 | const otherFlag = createTestFlag({ flagKey: "other_flag" }); 142 | mockFlagDefinitionsResponse([otherFlag]); 143 | await provider.startPollingForDefinitions(); 144 | 145 | const result = provider.getVariant( 146 | "nonexistent_flag", 147 | { variant_value: "control" }, 148 | TEST_CONTEXT, 149 | ); 150 | expect(result.variant_value).toBe("control"); 151 | }); 152 | 153 | it("should return fallback when no context", async () => { 154 | const flag = createTestFlag({ context: "distinct_id" }); 155 | mockFlagDefinitionsResponse([flag]); 156 | await provider.startPollingForDefinitions(); 157 | 158 | const result = provider.getVariant( 159 | "test_flag", 160 | { variant_value: "fallback" }, 161 | {}, 162 | ); 163 | expect(result.variant_value).toBe("fallback"); 164 | }); 165 | 166 | it("should return fallback when wrong context key", async () => { 167 | const flag = createTestFlag({ context: "user_id" }); 168 | mockFlagDefinitionsResponse([flag]); 169 | await provider.startPollingForDefinitions(); 170 | 171 | const result = provider.getVariant( 172 | "test_flag", 173 | { variant_value: "fallback" }, 174 | { distinct_id: "user123" }, 175 | ); 176 | expect(result.variant_value).toBe("fallback"); 177 | }); 178 | 179 | it("should return test user variant when configured", async () => { 180 | const variants = [ 181 | { key: "control", value: "false", is_control: true, split: 50.0 }, 182 | { key: "treatment", value: "true", is_control: false, split: 50.0 }, 183 | ]; 184 | const flag = createTestFlag({ 185 | variants: variants, 186 | testUsers: { test_user: "treatment" }, 187 | }); 188 | 189 | mockFlagDefinitionsResponse([flag]); 190 | await provider.startPollingForDefinitions(); 191 | 192 | const result = provider.getVariant( 193 | "test_flag", 194 | { variant_value: "control" }, 195 | { distinct_id: "test_user" }, 196 | ); 197 | expect(result.variant_value).toBe("true"); 198 | }); 199 | 200 | it("should return correct variant when test user variant not configured", async () => { 201 | const variants = [ 202 | { key: "control", value: "false", is_control: true, split: 50.0 }, 203 | { key: "treatment", value: "true", is_control: false, split: 50.0 }, 204 | ]; 205 | const flag = createTestFlag({ 206 | variants: variants, 207 | testUsers: { test_user: "nonexistent_variant" }, 208 | }); 209 | 210 | mockFlagDefinitionsResponse([flag]); 211 | await provider.startPollingForDefinitions(); 212 | 213 | const result = provider.getVariant( 214 | "test_flag", 215 | { variant_value: "fallback" }, 216 | { distinct_id: "test_user" }, 217 | ); 218 | expect(["false", "true"]).toContain(result.variant_value); 219 | }); 220 | 221 | it("should return fallback when rollout percentage zero", async () => { 222 | const flag = createTestFlag({ rolloutPercentage: 0.0 }); 223 | mockFlagDefinitionsResponse([flag]); 224 | await provider.startPollingForDefinitions(); 225 | 226 | const result = provider.getVariant( 227 | "test_flag", 228 | { variant_value: "fallback" }, 229 | TEST_CONTEXT, 230 | ); 231 | expect(result.variant_value).toBe("fallback"); 232 | }); 233 | 234 | it("should return variant when rollout percentage hundred", async () => { 235 | const flag = createTestFlag({ rolloutPercentage: 100.0 }); 236 | mockFlagDefinitionsResponse([flag]); 237 | await provider.startPollingForDefinitions(); 238 | 239 | const result = provider.getVariant( 240 | "test_flag", 241 | { variant_value: "fallback" }, 242 | TEST_CONTEXT, 243 | ); 244 | expect(result.variant_value).not.toBe("fallback"); 245 | expect(["control", "treatment"]).toContain(result.variant_value); 246 | }); 247 | 248 | it("should respect runtime evaluation when satisfied", async () => { 249 | const runtimeEval = { plan: "premium", region: "US" }; 250 | const flag = createTestFlag({ runtimeEvaluation: runtimeEval }); 251 | 252 | mockFlagDefinitionsResponse([flag]); 253 | await provider.startPollingForDefinitions(); 254 | 255 | const context = { 256 | distinct_id: "user123", 257 | custom_properties: { 258 | plan: "premium", 259 | region: "US", 260 | }, 261 | }; 262 | 263 | const result = provider.getVariant( 264 | "test_flag", 265 | { variant_value: "fallback" }, 266 | context, 267 | ); 268 | expect(result.variant_value).not.toBe("fallback"); 269 | }); 270 | 271 | it("should return fallback when runtime evaluation not satisfied", async () => { 272 | const runtimeEval = { plan: "premium", region: "US" }; 273 | const flag = createTestFlag({ runtimeEvaluation: runtimeEval }); 274 | 275 | mockFlagDefinitionsResponse([flag]); 276 | await provider.startPollingForDefinitions(); 277 | 278 | const context = { 279 | distinct_id: "user123", 280 | custom_properties: { 281 | plan: "basic", 282 | region: "US", 283 | }, 284 | }; 285 | 286 | const result = provider.getVariant( 287 | "test_flag", 288 | { variant_value: "fallback" }, 289 | context, 290 | ); 291 | expect(result.variant_value).toBe("fallback"); 292 | }); 293 | 294 | it("should pick correct variant with hundred percent split", async () => { 295 | const variants = [ 296 | { key: "A", value: "variant_a", is_control: false, split: 100.0 }, 297 | { key: "B", value: "variant_b", is_control: false, split: 0.0 }, 298 | { key: "C", value: "variant_c", is_control: false, split: 0.0 }, 299 | ]; 300 | const flag = createTestFlag({ 301 | variants: variants, 302 | rolloutPercentage: 100.0, 303 | }); 304 | 305 | mockFlagDefinitionsResponse([flag]); 306 | await provider.startPollingForDefinitions(); 307 | 308 | const result = provider.getVariant( 309 | "test_flag", 310 | { variant_value: "fallback" }, 311 | TEST_CONTEXT, 312 | ); 313 | expect(result.variant_value).toBe("variant_a"); 314 | }); 315 | 316 | it("should pick correct variant with half migrated group splits", async () => { 317 | const variants = [ 318 | { key: "A", value: "variant_a", is_control: false, split: 100.0 }, 319 | { key: "B", value: "variant_b", is_control: false, split: 0.0 }, 320 | { key: "C", value: "variant_c", is_control: false, split: 0.0 }, 321 | ]; 322 | const variantSplits = { A: 0.0, B: 100.0, C: 0.0 }; 323 | const flag = createTestFlag({ 324 | variants: variants, 325 | rolloutPercentage: 100.0, 326 | variantSplits: variantSplits, 327 | }); 328 | 329 | mockFlagDefinitionsResponse([flag]); 330 | await provider.startPollingForDefinitions(); 331 | 332 | const result = provider.getVariant( 333 | "test_flag", 334 | { variant_value: "fallback" }, 335 | TEST_CONTEXT, 336 | ); 337 | expect(result.variant_value).toBe("variant_b"); 338 | }); 339 | 340 | it("should pick correct variant with full migrated group splits", async () => { 341 | const variants = [ 342 | { key: "A", value: "variant_a", is_control: false }, 343 | { key: "B", value: "variant_b", is_control: false }, 344 | { key: "C", value: "variant_c", is_control: false }, 345 | ]; 346 | const variantSplits = { A: 0.0, B: 0.0, C: 100.0 }; 347 | const flag = createTestFlag({ 348 | variants: variants, 349 | rolloutPercentage: 100.0, 350 | variantSplits: variantSplits, 351 | }); 352 | 353 | mockFlagDefinitionsResponse([flag]); 354 | await provider.startPollingForDefinitions(); 355 | 356 | const result = provider.getVariant( 357 | "test_flag", 358 | { variant_value: "fallback" }, 359 | TEST_CONTEXT, 360 | ); 361 | expect(result.variant_value).toBe("variant_c"); 362 | }); 363 | 364 | it("should pick overridden variant", async () => { 365 | const variants = [ 366 | { key: "A", value: "variant_a", is_control: false, split: 100.0 }, 367 | { key: "B", value: "variant_b", is_control: false, split: 0.0 }, 368 | ]; 369 | const flag = createTestFlag({ 370 | variants: variants, 371 | variantOverride: { key: "B" }, 372 | }); 373 | 374 | mockFlagDefinitionsResponse([flag]); 375 | await provider.startPollingForDefinitions(); 376 | 377 | const result = provider.getVariant( 378 | "test_flag", 379 | { variant_value: "control" }, 380 | TEST_CONTEXT, 381 | ); 382 | expect(result.variant_value).toBe("variant_b"); 383 | }); 384 | 385 | it("should track exposure when variant selected", async () => { 386 | const flag = createTestFlag(); 387 | mockFlagDefinitionsResponse([flag]); 388 | await provider.startPollingForDefinitions(); 389 | 390 | provider.getVariant( 391 | "test_flag", 392 | { variant_value: "fallback" }, 393 | TEST_CONTEXT, 394 | ); 395 | expect(mockTracker).toHaveBeenCalledTimes(1); 396 | }); 397 | 398 | it("should track exposure with correct properties", async () => { 399 | const flag = createTestFlag({ 400 | experimentId: "exp-123", 401 | isExperimentActive: true, 402 | testUsers: { qa_user: "treatment" }, 403 | }); 404 | 405 | mockFlagDefinitionsResponse([flag]); 406 | await provider.startPollingForDefinitions(); 407 | 408 | provider.getVariant( 409 | "test_flag", 410 | { variant_value: "fallback" }, 411 | { distinct_id: "qa_user" }, 412 | ); 413 | 414 | expect(mockTracker).toHaveBeenCalledTimes(1); 415 | 416 | const call = mockTracker.mock.calls[0]; 417 | const properties = call[1]; 418 | 419 | expect(properties["$experiment_id"]).toBe("exp-123"); 420 | expect(properties["$is_experiment_active"]).toBe(true); 421 | expect(properties["$is_qa_tester"]).toBe(true); 422 | }); 423 | 424 | it("should not track exposure on fallback", async () => { 425 | mockFlagDefinitionsResponse([]); 426 | await provider.startPollingForDefinitions(); 427 | 428 | provider.getVariant( 429 | "nonexistent_flag", 430 | { variant_value: "fallback" }, 431 | TEST_CONTEXT, 432 | ); 433 | expect(mockTracker).not.toHaveBeenCalled(); 434 | }); 435 | 436 | it("should not track exposure without distinct_id", async () => { 437 | const flag = createTestFlag({ context: "company" }); 438 | mockFlagDefinitionsResponse([flag]); 439 | await provider.startPollingForDefinitions(); 440 | 441 | provider.getVariant( 442 | "test_flag", 443 | { variant_value: "fallback" }, 444 | { company_id: "company123" }, 445 | ); 446 | expect(mockTracker).not.toHaveBeenCalled(); 447 | }); 448 | }); 449 | 450 | describe("getAllVariants", () => { 451 | let provider; 452 | 453 | beforeEach(() => { 454 | const config = { 455 | api_host: "localhost", 456 | enable_polling: false, 457 | }; 458 | 459 | provider = new LocalFeatureFlagsProvider( 460 | TEST_TOKEN, 461 | config, 462 | mockTracker, 463 | mockLogger, 464 | ); 465 | }); 466 | 467 | afterEach(() => { 468 | provider.stopPollingForDefinitions(); 469 | }); 470 | 471 | it("should return empty object when no flag definitions", async () => { 472 | mockFlagDefinitionsResponse([]); 473 | await provider.startPollingForDefinitions(); 474 | 475 | const result = provider.getAllVariants(TEST_CONTEXT); 476 | 477 | expect(result).toEqual({}); 478 | }); 479 | 480 | it("should return all variants when two flags have 100% rollout", async () => { 481 | const flag1 = createTestFlag({ 482 | flagKey: "flag1", 483 | rolloutPercentage: 100.0, 484 | }); 485 | const flag2 = createTestFlag({ 486 | flagKey: "flag2", 487 | rolloutPercentage: 100.0, 488 | }); 489 | 490 | mockFlagDefinitionsResponse([flag1, flag2]); 491 | await provider.startPollingForDefinitions(); 492 | 493 | const result = provider.getAllVariants(TEST_CONTEXT); 494 | 495 | expect(Object.keys(result).length).toBe(2); 496 | expect(result).toHaveProperty("flag1"); 497 | expect(result).toHaveProperty("flag2"); 498 | }); 499 | 500 | it("should return partial results when one flag has 0% rollout", async () => { 501 | const flag1 = createTestFlag({ 502 | flagKey: "flag1", 503 | rolloutPercentage: 100.0, 504 | }); 505 | const flag2 = createTestFlag({ 506 | flagKey: "flag2", 507 | rolloutPercentage: 0.0, 508 | }); 509 | 510 | mockFlagDefinitionsResponse([flag1, flag2]); 511 | await provider.startPollingForDefinitions(); 512 | 513 | const result = provider.getAllVariants(TEST_CONTEXT); 514 | 515 | expect(Object.keys(result).length).toBe(1); 516 | expect(result).toHaveProperty("flag1"); 517 | expect(result).not.toHaveProperty("flag2"); 518 | }); 519 | }); 520 | 521 | describe("getVariantValue", () => { 522 | let provider; 523 | 524 | beforeEach(() => { 525 | const config = { 526 | api_host: "localhost", 527 | enable_polling: false, 528 | }; 529 | 530 | provider = new LocalFeatureFlagsProvider( 531 | TEST_TOKEN, 532 | config, 533 | mockTracker, 534 | mockLogger, 535 | ); 536 | }); 537 | 538 | afterEach(() => { 539 | provider.stopPollingForDefinitions(); 540 | }); 541 | 542 | it("should return variant value when flag exists", async () => { 543 | const variants = [ 544 | { key: "treatment", value: "blue", is_control: false, split: 100.0 }, 545 | ]; 546 | const flag = createTestFlag({ 547 | variants: variants, 548 | rolloutPercentage: 100.0, 549 | }); 550 | 551 | mockFlagDefinitionsResponse([flag]); 552 | await provider.startPollingForDefinitions(); 553 | 554 | const result = provider.getVariantValue( 555 | "test_flag", 556 | "default", 557 | TEST_CONTEXT, 558 | ); 559 | 560 | expect(result).toBe("blue"); 561 | }); 562 | 563 | it("should return fallback value when flag doesn't exist", async () => { 564 | mockFlagDefinitionsResponse([]); 565 | await provider.startPollingForDefinitions(); 566 | 567 | const result = provider.getVariantValue( 568 | "nonexistent_flag", 569 | "default_value", 570 | TEST_CONTEXT, 571 | ); 572 | 573 | expect(result).toBe("default_value"); 574 | }); 575 | }); 576 | 577 | describe("isEnabled", () => { 578 | let provider; 579 | 580 | beforeEach(() => { 581 | const config = { 582 | api_host: "localhost", 583 | enable_polling: false, 584 | }; 585 | 586 | provider = new LocalFeatureFlagsProvider( 587 | TEST_TOKEN, 588 | config, 589 | mockTracker, 590 | mockLogger, 591 | ); 592 | }); 593 | 594 | afterEach(() => { 595 | provider.stopPollingForDefinitions(); 596 | }); 597 | 598 | it("should return true when variant value is boolean true", async () => { 599 | const variants = [ 600 | { key: "treatment", value: true, is_control: false, split: 100.0 }, 601 | ]; 602 | const flag = createTestFlag({ 603 | variants: variants, 604 | rolloutPercentage: 100.0, 605 | }); 606 | 607 | mockFlagDefinitionsResponse([flag]); 608 | await provider.startPollingForDefinitions(); 609 | 610 | const result = provider.isEnabled("test_flag", TEST_CONTEXT); 611 | 612 | expect(result).toBe(true); 613 | }); 614 | 615 | it("should return false when variant value is boolean false", async () => { 616 | const variants = [ 617 | { key: "control", value: false, is_control: true, split: 100.0 }, 618 | ]; 619 | const flag = createTestFlag({ 620 | variants: variants, 621 | rolloutPercentage: 100.0, 622 | }); 623 | 624 | mockFlagDefinitionsResponse([flag]); 625 | await provider.startPollingForDefinitions(); 626 | 627 | const result = provider.isEnabled("test_flag", TEST_CONTEXT); 628 | 629 | expect(result).toBe(false); 630 | }); 631 | }); 632 | }); 633 | --------------------------------------------------------------------------------