UNPKG

posthog-node

Version:
1,350 lines (1,332 loc) 131 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); function _interopNamespace(e) { if (e && e.__esModule) return e; var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n["default"] = e; return Object.freeze(n); } /** * @file Adapted from [posthog-js](https://github.com/PostHog/posthog-js/blob/8157df935a4d0e71d2fefef7127aa85ee51c82d1/src/extensions/sentry-integration.ts) with modifications for the Node SDK. */ /** * Integrate Sentry with PostHog. This will add a direct link to the person in Sentry, and an $exception event in PostHog. * * ### Usage * * Sentry.init({ * dsn: 'https://example', * integrations: [ * new PostHogSentryIntegration(posthog) * ] * }) * * Sentry.setTag(PostHogSentryIntegration.POSTHOG_ID_TAG, 'some distinct id'); * * @param {Object} [posthog] The posthog object * @param {string} [organization] Optional: The Sentry organization, used to send a direct link from PostHog to Sentry * @param {Number} [projectId] Optional: The Sentry project id, used to send a direct link from PostHog to Sentry * @param {string} [prefix] Optional: Url of a self-hosted sentry instance (default: https://sentry.io/organizations/) * @param {SeverityLevel[] | '*'} [severityAllowList] Optional: send events matching the provided levels. Use '*' to send all events (default: ['error']) */ const NAME = 'posthog-node'; function createEventProcessor(_posthog, { organization, projectId, prefix, severityAllowList = ['error'] } = {}) { return event => { const shouldProcessLevel = severityAllowList === '*' || severityAllowList.includes(event.level); if (!shouldProcessLevel) { return event; } if (!event.tags) { event.tags = {}; } // Get the PostHog user ID from a specific tag, which users can set on their Sentry scope as they need. const userId = event.tags[PostHogSentryIntegration.POSTHOG_ID_TAG]; if (userId === undefined) { // If we can't find a user ID, don't bother linking the event. We won't be able to send anything meaningful to PostHog without it. return event; } const uiHost = _posthog.options.host ?? 'https://us.i.posthog.com'; const personUrl = new URL(`/project/${_posthog.apiKey}/person/${userId}`, uiHost).toString(); event.tags['PostHog Person URL'] = personUrl; const exceptions = event.exception?.values || []; const exceptionList = exceptions.map(exception => ({ ...exception, stacktrace: exception.stacktrace ? { ...exception.stacktrace, type: 'raw', frames: (exception.stacktrace.frames || []).map(frame => { return { ...frame, platform: 'node:javascript' }; }) } : undefined })); const properties = { // PostHog Exception Properties, $exception_message: exceptions[0]?.value || event.message, $exception_type: exceptions[0]?.type, $exception_personURL: personUrl, $exception_level: event.level, $exception_list: exceptionList, // Sentry Exception Properties $sentry_event_id: event.event_id, $sentry_exception: event.exception, $sentry_exception_message: exceptions[0]?.value || event.message, $sentry_exception_type: exceptions[0]?.type, $sentry_tags: event.tags }; if (organization && projectId) { properties['$sentry_url'] = (prefix || 'https://sentry.io/organizations/') + organization + '/issues/?project=' + projectId + '&query=' + event.event_id; } _posthog.capture({ event: '$exception', distinctId: userId, properties }); return event; }; } // V8 integration - function based function sentryIntegration(_posthog, options) { const processor = createEventProcessor(_posthog, options); return { name: NAME, processEvent(event) { return processor(event); } }; } // V7 integration - class based class PostHogSentryIntegration { constructor(_posthog, organization, prefix, severityAllowList) { this.name = NAME; // setupOnce gets called by Sentry when it intializes the plugin this.name = NAME; this.setupOnce = function (addGlobalEventProcessor, getCurrentHub) { const projectId = getCurrentHub()?.getClient()?.getDsn()?.projectId; addGlobalEventProcessor(createEventProcessor(_posthog, { organization, projectId, prefix, severityAllowList })); }; } } PostHogSentryIntegration.POSTHOG_ID_TAG = 'posthog_distinct_id'; // vendor from: https://github.com/LiosK/uuidv7/blob/f30b7a7faff73afbce0b27a46c638310f96912ba/src/index.ts // https://github.com/LiosK/uuidv7#license /** * uuidv7: An experimental implementation of the proposed UUID Version 7 * * @license Apache-2.0 * @copyright 2021-2023 LiosK * @packageDocumentation */ const DIGITS = "0123456789abcdef"; /** Represents a UUID as a 16-byte byte array. */ class UUID { /** @param bytes - The 16-byte byte array representation. */ constructor(bytes) { this.bytes = bytes; } /** * Creates an object from the internal representation, a 16-byte byte array * containing the binary UUID representation in the big-endian byte order. * * This method does NOT shallow-copy the argument, and thus the created object * holds the reference to the underlying buffer. * * @throws TypeError if the length of the argument is not 16. */ static ofInner(bytes) { if (bytes.length !== 16) { throw new TypeError("not 128-bit length"); } else { return new UUID(bytes); } } /** * Builds a byte array from UUIDv7 field values. * * @param unixTsMs - A 48-bit `unix_ts_ms` field value. * @param randA - A 12-bit `rand_a` field value. * @param randBHi - The higher 30 bits of 62-bit `rand_b` field value. * @param randBLo - The lower 32 bits of 62-bit `rand_b` field value. * @throws RangeError if any field value is out of the specified range. */ static fromFieldsV7(unixTsMs, randA, randBHi, randBLo) { if (!Number.isInteger(unixTsMs) || !Number.isInteger(randA) || !Number.isInteger(randBHi) || !Number.isInteger(randBLo) || unixTsMs < 0 || randA < 0 || randBHi < 0 || randBLo < 0 || unixTsMs > 281474976710655 || randA > 0xfff || randBHi > 1073741823 || randBLo > 4294967295) { throw new RangeError("invalid field value"); } const bytes = new Uint8Array(16); bytes[0] = unixTsMs / 2 ** 40; bytes[1] = unixTsMs / 2 ** 32; bytes[2] = unixTsMs / 2 ** 24; bytes[3] = unixTsMs / 2 ** 16; bytes[4] = unixTsMs / 2 ** 8; bytes[5] = unixTsMs; bytes[6] = 0x70 | (randA >>> 8); bytes[7] = randA; bytes[8] = 0x80 | (randBHi >>> 24); bytes[9] = randBHi >>> 16; bytes[10] = randBHi >>> 8; bytes[11] = randBHi; bytes[12] = randBLo >>> 24; bytes[13] = randBLo >>> 16; bytes[14] = randBLo >>> 8; bytes[15] = randBLo; return new UUID(bytes); } /** * Builds a byte array from a string representation. * * This method accepts the following formats: * * - 32-digit hexadecimal format without hyphens: `0189dcd553117d408db09496a2eef37b` * - 8-4-4-4-12 hyphenated format: `0189dcd5-5311-7d40-8db0-9496a2eef37b` * - Hyphenated format with surrounding braces: `{0189dcd5-5311-7d40-8db0-9496a2eef37b}` * - RFC 4122 URN format: `urn:uuid:0189dcd5-5311-7d40-8db0-9496a2eef37b` * * Leading and trailing whitespaces represents an error. * * @throws SyntaxError if the argument could not parse as a valid UUID string. */ static parse(uuid) { let hex = undefined; switch (uuid.length) { case 32: hex = /^[0-9a-f]{32}$/i.exec(uuid)?.[0]; break; case 36: hex = /^([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})$/i .exec(uuid) ?.slice(1, 6) .join(""); break; case 38: hex = /^\{([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})\}$/i .exec(uuid) ?.slice(1, 6) .join(""); break; case 45: hex = /^urn:uuid:([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})$/i .exec(uuid) ?.slice(1, 6) .join(""); break; } if (hex) { const inner = new Uint8Array(16); for (let i = 0; i < 16; i += 4) { const n = parseInt(hex.substring(2 * i, 2 * i + 8), 16); inner[i + 0] = n >>> 24; inner[i + 1] = n >>> 16; inner[i + 2] = n >>> 8; inner[i + 3] = n; } return new UUID(inner); } else { throw new SyntaxError("could not parse UUID string"); } } /** * @returns The 8-4-4-4-12 canonical hexadecimal string representation * (`0189dcd5-5311-7d40-8db0-9496a2eef37b`). */ toString() { let text = ""; for (let i = 0; i < this.bytes.length; i++) { text += DIGITS.charAt(this.bytes[i] >>> 4); text += DIGITS.charAt(this.bytes[i] & 0xf); if (i === 3 || i === 5 || i === 7 || i === 9) { text += "-"; } } return text; } /** * @returns The 32-digit hexadecimal representation without hyphens * (`0189dcd553117d408db09496a2eef37b`). */ toHex() { let text = ""; for (let i = 0; i < this.bytes.length; i++) { text += DIGITS.charAt(this.bytes[i] >>> 4); text += DIGITS.charAt(this.bytes[i] & 0xf); } return text; } /** @returns The 8-4-4-4-12 canonical hexadecimal string representation. */ toJSON() { return this.toString(); } /** * Reports the variant field value of the UUID or, if appropriate, "NIL" or * "MAX". * * For convenience, this method reports "NIL" or "MAX" if `this` represents * the Nil or Max UUID, although the Nil and Max UUIDs are technically * subsumed under the variants `0b0` and `0b111`, respectively. */ getVariant() { const n = this.bytes[8] >>> 4; if (n < 0) { throw new Error("unreachable"); } else if (n <= 0b0111) { return this.bytes.every((e) => e === 0) ? "NIL" : "VAR_0"; } else if (n <= 0b1011) { return "VAR_10"; } else if (n <= 0b1101) { return "VAR_110"; } else if (n <= 0b1111) { return this.bytes.every((e) => e === 0xff) ? "MAX" : "VAR_RESERVED"; } else { throw new Error("unreachable"); } } /** * Returns the version field value of the UUID or `undefined` if the UUID does * not have the variant field value of `0b10`. */ getVersion() { return this.getVariant() === "VAR_10" ? this.bytes[6] >>> 4 : undefined; } /** Creates an object from `this`. */ clone() { return new UUID(this.bytes.slice(0)); } /** Returns true if `this` is equivalent to `other`. */ equals(other) { return this.compareTo(other) === 0; } /** * Returns a negative integer, zero, or positive integer if `this` is less * than, equal to, or greater than `other`, respectively. */ compareTo(other) { for (let i = 0; i < 16; i++) { const diff = this.bytes[i] - other.bytes[i]; if (diff !== 0) { return Math.sign(diff); } } return 0; } } /** * Encapsulates the monotonic counter state. * * This class provides APIs to utilize a separate counter state from that of the * global generator used by {@link uuidv7} and {@link uuidv7obj}. In addition to * the default {@link generate} method, this class has {@link generateOrAbort} * that is useful to absolutely guarantee the monotonically increasing order of * generated UUIDs. See their respective documentation for details. */ class V7Generator { /** * Creates a generator object with the default random number generator, or * with the specified one if passed as an argument. The specified random * number generator should be cryptographically strong and securely seeded. */ constructor(randomNumberGenerator) { this.timestamp = 0; this.counter = 0; this.random = randomNumberGenerator ?? getDefaultRandom(); } /** * Generates a new UUIDv7 object from the current timestamp, or resets the * generator upon significant timestamp rollback. * * This method returns a monotonically increasing UUID by reusing the previous * timestamp even if the up-to-date timestamp is smaller than the immediately * preceding UUID's. However, when such a clock rollback is considered * significant (i.e., by more than ten seconds), this method resets the * generator and returns a new UUID based on the given timestamp, breaking the * increasing order of UUIDs. * * See {@link generateOrAbort} for the other mode of generation and * {@link generateOrResetCore} for the low-level primitive. */ generate() { return this.generateOrResetCore(Date.now(), 10000); } /** * Generates a new UUIDv7 object from the current timestamp, or returns * `undefined` upon significant timestamp rollback. * * This method returns a monotonically increasing UUID by reusing the previous * timestamp even if the up-to-date timestamp is smaller than the immediately * preceding UUID's. However, when such a clock rollback is considered * significant (i.e., by more than ten seconds), this method aborts and * returns `undefined` immediately. * * See {@link generate} for the other mode of generation and * {@link generateOrAbortCore} for the low-level primitive. */ generateOrAbort() { return this.generateOrAbortCore(Date.now(), 10000); } /** * Generates a new UUIDv7 object from the `unixTsMs` passed, or resets the * generator upon significant timestamp rollback. * * This method is equivalent to {@link generate} except that it takes a custom * timestamp and clock rollback allowance. * * @param rollbackAllowance - The amount of `unixTsMs` rollback that is * considered significant. A suggested value is `10_000` (milliseconds). * @throws RangeError if `unixTsMs` is not a 48-bit positive integer. */ generateOrResetCore(unixTsMs, rollbackAllowance) { let value = this.generateOrAbortCore(unixTsMs, rollbackAllowance); if (value === undefined) { // reset state and resume this.timestamp = 0; value = this.generateOrAbortCore(unixTsMs, rollbackAllowance); } return value; } /** * Generates a new UUIDv7 object from the `unixTsMs` passed, or returns * `undefined` upon significant timestamp rollback. * * This method is equivalent to {@link generateOrAbort} except that it takes a * custom timestamp and clock rollback allowance. * * @param rollbackAllowance - The amount of `unixTsMs` rollback that is * considered significant. A suggested value is `10_000` (milliseconds). * @throws RangeError if `unixTsMs` is not a 48-bit positive integer. */ generateOrAbortCore(unixTsMs, rollbackAllowance) { const MAX_COUNTER = 4398046511103; if (!Number.isInteger(unixTsMs) || unixTsMs < 1 || unixTsMs > 281474976710655) { throw new RangeError("`unixTsMs` must be a 48-bit positive integer"); } else if (rollbackAllowance < 0 || rollbackAllowance > 281474976710655) { throw new RangeError("`rollbackAllowance` out of reasonable range"); } if (unixTsMs > this.timestamp) { this.timestamp = unixTsMs; this.resetCounter(); } else if (unixTsMs + rollbackAllowance >= this.timestamp) { // go on with previous timestamp if new one is not much smaller this.counter++; if (this.counter > MAX_COUNTER) { // increment timestamp at counter overflow this.timestamp++; this.resetCounter(); } } else { // abort if clock went backwards to unbearable extent return undefined; } return UUID.fromFieldsV7(this.timestamp, Math.trunc(this.counter / 2 ** 30), this.counter & (2 ** 30 - 1), this.random.nextUint32()); } /** Initializes the counter at a 42-bit random integer. */ resetCounter() { this.counter = this.random.nextUint32() * 0x400 + (this.random.nextUint32() & 0x3ff); } /** * Generates a new UUIDv4 object utilizing the random number generator inside. * * @internal */ generateV4() { const bytes = new Uint8Array(Uint32Array.of(this.random.nextUint32(), this.random.nextUint32(), this.random.nextUint32(), this.random.nextUint32()).buffer); bytes[6] = 0x40 | (bytes[6] >>> 4); bytes[8] = 0x80 | (bytes[8] >>> 2); return UUID.ofInner(bytes); } } /** A global flag to force use of cryptographically strong RNG. */ // declare const UUIDV7_DENY_WEAK_RNG: boolean; /** Returns the default random number generator available in the environment. */ const getDefaultRandom = () => { // fix: crypto isn't available in react-native, always use Math.random // // detect Web Crypto API // if ( // typeof crypto !== "undefined" && // typeof crypto.getRandomValues !== "undefined" // ) { // return new BufferedCryptoRandom(); // } else { // // fall back on Math.random() unless the flag is set to true // if (typeof UUIDV7_DENY_WEAK_RNG !== "undefined" && UUIDV7_DENY_WEAK_RNG) { // throw new Error("no cryptographically strong RNG available"); // } // return { // nextUint32: (): number => // Math.trunc(Math.random() * 0x1_0000) * 0x1_0000 + // Math.trunc(Math.random() * 0x1_0000), // }; // } return { nextUint32: () => Math.trunc(Math.random() * 65536) * 65536 + Math.trunc(Math.random() * 65536), }; }; // /** // * Wraps `crypto.getRandomValues()` to enable buffering; this uses a small // * buffer by default to avoid both unbearable throughput decline in some // * environments and the waste of time and space for unused values. // */ // class BufferedCryptoRandom { // private readonly buffer = new Uint32Array(8); // private cursor = 0xffff; // nextUint32(): number { // if (this.cursor >= this.buffer.length) { // crypto.getRandomValues(this.buffer); // this.cursor = 0; // } // return this.buffer[this.cursor++]; // } // } let defaultGenerator; /** * Generates a UUIDv7 string. * * @returns The 8-4-4-4-12 canonical hexadecimal string representation * ("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"). */ const uuidv7 = () => uuidv7obj().toString(); /** Generates a UUIDv7 object. */ const uuidv7obj = () => (defaultGenerator || (defaultGenerator = new V7Generator())).generate(); // Portions of this file are derived from getsentry/sentry-javascript by Software, Inc. dba Sentry // Licensed under the MIT License function makeUncaughtExceptionHandler(captureFn, onFatalFn) { let calledFatalError = false; return Object.assign(error => { // Attaching a listener to `uncaughtException` will prevent the node process from exiting. We generally do not // want to alter this behaviour so we check for other listeners that users may have attached themselves and adjust // exit behaviour of the SDK accordingly: // - If other listeners are attached, do not exit. // - If the only listener attached is ours, exit. const userProvidedListenersCount = global.process.listeners('uncaughtException').filter(listener => { // There are 2 listeners we ignore: return ( // as soon as we're using domains this listener is attached by node itself listener.name !== 'domainUncaughtExceptionClear' && // the handler we register in this integration listener._posthogErrorHandler !== true ); }).length; const processWouldExit = userProvidedListenersCount === 0; captureFn(error, { mechanism: { type: 'onuncaughtexception', handled: false } }); if (!calledFatalError && processWouldExit) { calledFatalError = true; onFatalFn(); } }, { _posthogErrorHandler: true }); } function addUncaughtExceptionListener(captureFn, onFatalFn) { global.process.on('uncaughtException', makeUncaughtExceptionHandler(captureFn, onFatalFn)); } function addUnhandledRejectionListener(captureFn) { global.process.on('unhandledRejection', reason => { captureFn(reason, { mechanism: { type: 'onunhandledrejection', handled: false } }); }); } // Portions of this file are derived from getsentry/sentry-javascript by Software, Inc. dba Sentry // Licensed under the MIT License let parsedStackResults; let lastKeysCount; let cachedFilenameChunkIds; function getFilenameToChunkIdMap(stackParser) { const chunkIdMap = globalThis._posthogChunkIds; if (!chunkIdMap) { console.error('No chunk id map found'); return {}; } const chunkIdKeys = Object.keys(chunkIdMap); if (cachedFilenameChunkIds && chunkIdKeys.length === lastKeysCount) { return cachedFilenameChunkIds; } lastKeysCount = chunkIdKeys.length; cachedFilenameChunkIds = chunkIdKeys.reduce((acc, stackKey) => { if (!parsedStackResults) { parsedStackResults = {}; } const result = parsedStackResults[stackKey]; if (result) { acc[result[0]] = result[1]; } else { const parsedStack = stackParser(stackKey); for (let i = parsedStack.length - 1; i >= 0; i--) { const stackFrame = parsedStack[i]; const filename = stackFrame?.filename; const chunkId = chunkIdMap[stackKey]; if (filename && chunkId) { acc[filename] = chunkId; parsedStackResults[stackKey] = [filename, chunkId]; break; } } } return acc; }, {}); return cachedFilenameChunkIds; } // Portions of this file are derived from getsentry/sentry-javascript by Software, Inc. dba Sentry // Licensed under the MIT License function isEvent(candidate) { return typeof Event !== 'undefined' && isInstanceOf(candidate, Event); } function isPlainObject(candidate) { return isBuiltin(candidate, 'Object'); } function isError(candidate) { switch (Object.prototype.toString.call(candidate)) { case '[object Error]': case '[object Exception]': case '[object DOMException]': case '[object WebAssembly.Exception]': return true; default: return isInstanceOf(candidate, Error); } } function isInstanceOf(candidate, base) { try { return candidate instanceof base; } catch { return false; } } function isErrorEvent(event) { return isBuiltin(event, 'ErrorEvent'); } function isBuiltin(candidate, className) { return Object.prototype.toString.call(candidate) === `[object ${className}]`; } // Portions of this file are derived from getsentry/sentry-javascript by Software, Inc. dba Sentry async function propertiesFromUnknownInput(stackParser, frameModifiers, input, hint) { const providedMechanism = hint && hint.mechanism; const mechanism = providedMechanism || { handled: true, type: 'generic' }; const errorList = getErrorList(mechanism, input, hint); const exceptionList = await Promise.all(errorList.map(async error => { const exception = await exceptionFromError(stackParser, frameModifiers, error); exception.value = exception.value || ''; exception.type = exception.type || 'Error'; exception.mechanism = mechanism; return exception; })); const properties = { $exception_list: exceptionList }; return properties; } // Flatten error causes into a list of errors // See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause function getErrorList(mechanism, input, hint) { const error = getError(mechanism, input, hint); if (error.cause) { return [error, ...getErrorList(mechanism, error.cause, hint)]; } return [error]; } function getError(mechanism, exception, hint) { if (isError(exception)) { return exception; } mechanism.synthetic = true; if (isPlainObject(exception)) { const errorFromProp = getErrorPropertyFromObject(exception); if (errorFromProp) { return errorFromProp; } const message = getMessageForObject(exception); const ex = hint?.syntheticException || new Error(message); ex.message = message; return ex; } // This handles when someone does: `throw "something awesome";` // We use synthesized Error here so we can extract a (rough) stack trace. const ex = hint?.syntheticException || new Error(exception); ex.message = `${exception}`; return ex; } /** If a plain object has a property that is an `Error`, return this error. */ function getErrorPropertyFromObject(obj) { for (const prop in obj) { if (Object.prototype.hasOwnProperty.call(obj, prop)) { const value = obj[prop]; if (isError(value)) { return value; } } } return undefined; } function getMessageForObject(exception) { if ('name' in exception && typeof exception.name === 'string') { let message = `'${exception.name}' captured as exception`; if ('message' in exception && typeof exception.message === 'string') { message += ` with message '${exception.message}'`; } return message; } else if ('message' in exception && typeof exception.message === 'string') { return exception.message; } const keys = extractExceptionKeysForMessage(exception); // Some ErrorEvent instances do not have an `error` property, which is why they are not handled before // We still want to try to get a decent message for these cases if (isErrorEvent(exception)) { return `Event \`ErrorEvent\` captured as exception with message \`${exception.message}\``; } const className = getObjectClassName(exception); return `${className && className !== 'Object' ? `'${className}'` : 'Object'} captured as exception with keys: ${keys}`; } function getObjectClassName(obj) { try { const prototype = Object.getPrototypeOf(obj); return prototype ? prototype.constructor.name : undefined; } catch (e) { // ignore errors here } } /** * Given any captured exception, extract its keys and create a sorted * and truncated list that will be used inside the event message. * eg. `Non-error exception captured with keys: foo, bar, baz` */ function extractExceptionKeysForMessage(exception, maxLength = 40) { const keys = Object.keys(convertToPlainObject(exception)); keys.sort(); const firstKey = keys[0]; if (!firstKey) { return '[object has no keys]'; } if (firstKey.length >= maxLength) { return truncate(firstKey, maxLength); } for (let includedKeys = keys.length; includedKeys > 0; includedKeys--) { const serialized = keys.slice(0, includedKeys).join(', '); if (serialized.length > maxLength) { continue; } if (includedKeys === keys.length) { return serialized; } return truncate(serialized, maxLength); } return ''; } function truncate(str, max = 0) { if (typeof str !== 'string' || max === 0) { return str; } return str.length <= max ? str : `${str.slice(0, max)}...`; } /** * Transforms any `Error` or `Event` into a plain object with all of their enumerable properties, and some of their * non-enumerable properties attached. * * @param value Initial source that we have to transform in order for it to be usable by the serializer * @returns An Event or Error turned into an object - or the value argument itself, when value is neither an Event nor * an Error. */ function convertToPlainObject(value) { if (isError(value)) { return { message: value.message, name: value.name, stack: value.stack, ...getOwnProperties(value) }; } else if (isEvent(value)) { const newObj = { type: value.type, target: serializeEventTarget(value.target), currentTarget: serializeEventTarget(value.currentTarget), ...getOwnProperties(value) }; // TODO: figure out why this fails typing (I think CustomEvent is only supported in Node 19 onwards) // if (typeof CustomEvent !== 'undefined' && isInstanceOf(value, CustomEvent)) { // newObj.detail = (value as unknown as CustomEvent).detail // } return newObj; } else { return value; } } /** Filters out all but an object's own properties */ function getOwnProperties(obj) { if (typeof obj === 'object' && obj !== null) { const extractedProps = {}; for (const property in obj) { if (Object.prototype.hasOwnProperty.call(obj, property)) { extractedProps[property] = obj[property]; } } return extractedProps; } else { return {}; } } /** Creates a string representation of the target of an `Event` object */ function serializeEventTarget(target) { try { return Object.prototype.toString.call(target); } catch (_oO) { return '<unknown>'; } } /** * Extracts stack frames from the error and builds an Exception */ async function exceptionFromError(stackParser, frameModifiers, error) { const exception = { type: error.name || error.constructor.name, value: error.message }; let frames = parseStackFrames(stackParser, error); for (const modifier of frameModifiers) { frames = await modifier(frames); } if (frames.length) { exception.stacktrace = { frames, type: 'raw' }; } return exception; } /** * Extracts stack frames from the error.stack string */ function parseStackFrames(stackParser, error) { return applyChunkIds(stackParser(error.stack || '', 1), stackParser); } function applyChunkIds(frames, parser) { const filenameChunkIdMap = getFilenameToChunkIdMap(parser); frames.forEach(frame => { if (frame.filename) { frame.chunk_id = filenameChunkIdMap[frame.filename]; } }); return frames; } const SHUTDOWN_TIMEOUT = 2000; class ErrorTracking { static async captureException(client, error, hint, distinctId, additionalProperties) { const properties = { ...additionalProperties }; // Given stateless nature of Node SDK we capture exceptions using personless processing when no // user can be determined because a distinct_id is not provided e.g. exception autocapture if (!distinctId) { properties.$process_person_profile = false; } const exceptionProperties = await propertiesFromUnknownInput(this.stackParser, this.frameModifiers, error, hint); client.capture({ event: '$exception', distinctId: distinctId || uuidv7(), properties: { ...exceptionProperties, ...properties } }); } constructor(client, options) { this.client = client; this._exceptionAutocaptureEnabled = options.enableExceptionAutocapture || false; this.startAutocaptureIfEnabled(); } startAutocaptureIfEnabled() { if (this.isEnabled()) { addUncaughtExceptionListener(this.onException.bind(this), this.onFatalError.bind(this)); addUnhandledRejectionListener(this.onException.bind(this)); } } onException(exception, hint) { ErrorTracking.captureException(this.client, exception, hint); } async onFatalError() { await this.client.shutdown(SHUTDOWN_TIMEOUT); } isEnabled() { return !this.client.isDisabled && this._exceptionAutocaptureEnabled; } } function setupExpressErrorHandler(_posthog, app) { app.use((error, _, __, next) => { const hint = { mechanism: { type: 'middleware', handled: false } }; // Given stateless nature of Node SDK we capture exceptions using personless processing // when no user can be determined e.g. in the case of exception autocapture ErrorTracking.captureException(_posthog, error, hint, uuidv7(), { $process_person_profile: false }); next(error); }); } var version = "5.1.1"; var PostHogPersistedProperty; (function (PostHogPersistedProperty) { PostHogPersistedProperty["AnonymousId"] = "anonymous_id"; PostHogPersistedProperty["DistinctId"] = "distinct_id"; PostHogPersistedProperty["Props"] = "props"; PostHogPersistedProperty["FeatureFlagDetails"] = "feature_flag_details"; PostHogPersistedProperty["FeatureFlags"] = "feature_flags"; PostHogPersistedProperty["FeatureFlagPayloads"] = "feature_flag_payloads"; PostHogPersistedProperty["BootstrapFeatureFlagDetails"] = "bootstrap_feature_flag_details"; PostHogPersistedProperty["BootstrapFeatureFlags"] = "bootstrap_feature_flags"; PostHogPersistedProperty["BootstrapFeatureFlagPayloads"] = "bootstrap_feature_flag_payloads"; PostHogPersistedProperty["OverrideFeatureFlags"] = "override_feature_flags"; PostHogPersistedProperty["Queue"] = "queue"; PostHogPersistedProperty["OptedOut"] = "opted_out"; PostHogPersistedProperty["SessionId"] = "session_id"; PostHogPersistedProperty["SessionStartTimestamp"] = "session_start_timestamp"; PostHogPersistedProperty["SessionLastTimestamp"] = "session_timestamp"; PostHogPersistedProperty["PersonProperties"] = "person_properties"; PostHogPersistedProperty["GroupProperties"] = "group_properties"; PostHogPersistedProperty["InstalledAppBuild"] = "installed_app_build"; PostHogPersistedProperty["InstalledAppVersion"] = "installed_app_version"; PostHogPersistedProperty["SessionReplay"] = "session_replay"; PostHogPersistedProperty["SurveyLastSeenDate"] = "survey_last_seen_date"; PostHogPersistedProperty["SurveysSeen"] = "surveys_seen"; PostHogPersistedProperty["Surveys"] = "surveys"; PostHogPersistedProperty["RemoteConfig"] = "remote_config"; PostHogPersistedProperty["FlagsEndpointWasHit"] = "flags_endpoint_was_hit"; })(PostHogPersistedProperty || (PostHogPersistedProperty = {})); // Any key prefixed with `attr__` can be added var Compression; (function (Compression) { Compression["GZipJS"] = "gzip-js"; Compression["Base64"] = "base64"; })(Compression || (Compression = {})); var SurveyPosition; (function (SurveyPosition) { SurveyPosition["Left"] = "left"; SurveyPosition["Right"] = "right"; SurveyPosition["Center"] = "center"; })(SurveyPosition || (SurveyPosition = {})); var SurveyWidgetType; (function (SurveyWidgetType) { SurveyWidgetType["Button"] = "button"; SurveyWidgetType["Tab"] = "tab"; SurveyWidgetType["Selector"] = "selector"; })(SurveyWidgetType || (SurveyWidgetType = {})); var SurveyType; (function (SurveyType) { SurveyType["Popover"] = "popover"; SurveyType["API"] = "api"; SurveyType["Widget"] = "widget"; })(SurveyType || (SurveyType = {})); var SurveyQuestionDescriptionContentType; (function (SurveyQuestionDescriptionContentType) { SurveyQuestionDescriptionContentType["Html"] = "html"; SurveyQuestionDescriptionContentType["Text"] = "text"; })(SurveyQuestionDescriptionContentType || (SurveyQuestionDescriptionContentType = {})); var SurveyRatingDisplay; (function (SurveyRatingDisplay) { SurveyRatingDisplay["Number"] = "number"; SurveyRatingDisplay["Emoji"] = "emoji"; })(SurveyRatingDisplay || (SurveyRatingDisplay = {})); var SurveyQuestionType; (function (SurveyQuestionType) { SurveyQuestionType["Open"] = "open"; SurveyQuestionType["MultipleChoice"] = "multiple_choice"; SurveyQuestionType["SingleChoice"] = "single_choice"; SurveyQuestionType["Rating"] = "rating"; SurveyQuestionType["Link"] = "link"; })(SurveyQuestionType || (SurveyQuestionType = {})); var SurveyQuestionBranchingType; (function (SurveyQuestionBranchingType) { SurveyQuestionBranchingType["NextQuestion"] = "next_question"; SurveyQuestionBranchingType["End"] = "end"; SurveyQuestionBranchingType["ResponseBased"] = "response_based"; SurveyQuestionBranchingType["SpecificQuestion"] = "specific_question"; })(SurveyQuestionBranchingType || (SurveyQuestionBranchingType = {})); var SurveyMatchType; (function (SurveyMatchType) { SurveyMatchType["Regex"] = "regex"; SurveyMatchType["NotRegex"] = "not_regex"; SurveyMatchType["Exact"] = "exact"; SurveyMatchType["IsNot"] = "is_not"; SurveyMatchType["Icontains"] = "icontains"; SurveyMatchType["NotIcontains"] = "not_icontains"; })(SurveyMatchType || (SurveyMatchType = {})); /** Sync with plugin-server/src/types.ts */ var ActionStepStringMatching; (function (ActionStepStringMatching) { ActionStepStringMatching["Contains"] = "contains"; ActionStepStringMatching["Exact"] = "exact"; ActionStepStringMatching["Regex"] = "regex"; })(ActionStepStringMatching || (ActionStepStringMatching = {})); const normalizeFlagsResponse = (flagsResponse) => { if ('flags' in flagsResponse) { // Convert v2 format to v1 format const featureFlags = getFlagValuesFromFlags(flagsResponse.flags); const featureFlagPayloads = getPayloadsFromFlags(flagsResponse.flags); return { ...flagsResponse, featureFlags, featureFlagPayloads, }; } else { // Convert v1 format to v2 format const featureFlags = flagsResponse.featureFlags ?? {}; const featureFlagPayloads = Object.fromEntries(Object.entries(flagsResponse.featureFlagPayloads || {}).map(([k, v]) => [k, parsePayload(v)])); const flags = Object.fromEntries(Object.entries(featureFlags).map(([key, value]) => [ key, getFlagDetailFromFlagAndPayload(key, value, featureFlagPayloads[key]), ])); return { ...flagsResponse, featureFlags, featureFlagPayloads, flags, }; } }; function getFlagDetailFromFlagAndPayload(key, value, payload) { return { key: key, enabled: typeof value === 'string' ? true : value, variant: typeof value === 'string' ? value : undefined, reason: undefined, metadata: { id: undefined, version: undefined, payload: payload ? JSON.stringify(payload) : undefined, description: undefined, }, }; } /** * Get the flag values from the flags v4 response. * @param flags - The flags * @returns The flag values */ const getFlagValuesFromFlags = (flags) => { return Object.fromEntries(Object.entries(flags ?? {}) .map(([key, detail]) => [key, getFeatureFlagValue(detail)]) .filter(([, value]) => value !== undefined)); }; /** * Get the payloads from the flags v4 response. * @param flags - The flags * @returns The payloads */ const getPayloadsFromFlags = (flags) => { const safeFlags = flags ?? {}; return Object.fromEntries(Object.keys(safeFlags) .filter((flag) => { const details = safeFlags[flag]; return details.enabled && details.metadata && details.metadata.payload !== undefined; }) .map((flag) => { const payload = safeFlags[flag].metadata?.payload; return [flag, payload ? parsePayload(payload) : undefined]; })); }; const getFeatureFlagValue = (detail) => { return detail === undefined ? undefined : detail.variant ?? detail.enabled; }; const parsePayload = (response) => { if (typeof response !== 'string') { return response; } try { return JSON.parse(response); } catch { return response; } }; const STRING_FORMAT = 'utf8'; function assert(truthyValue, message) { if (!truthyValue || typeof truthyValue !== 'string' || isEmpty(truthyValue)) { throw new Error(message); } } function isEmpty(truthyValue) { if (truthyValue.trim().length === 0) { return true; } return false; } function removeTrailingSlash(url) { return url?.replace(/\/+$/, ''); } async function retriable(fn, props) { let lastError = null; for (let i = 0; i < props.retryCount + 1; i++) { if (i > 0) { // don't wait when it's the last try await new Promise((r) => setTimeout(r, props.retryDelay)); } try { const res = await fn(); return res; } catch (e) { lastError = e; if (!props.retryCheck(e)) { throw e; } } } throw lastError; } function currentISOTime() { return new Date().toISOString(); } function safeSetTimeout(fn, timeout) { // NOTE: we use this so rarely that it is totally fine to do `safeSetTimeout(fn, 0)`` // rather than setImmediate. const t = setTimeout(fn, timeout); // We unref if available to prevent Node.js hanging on exit t?.unref && t?.unref(); return t; } function allSettled(promises) { return Promise.all(promises.map((p) => (p ?? Promise.resolve()).then((value) => ({ status: 'fulfilled', value }), (reason) => ({ status: 'rejected', reason })))); } /** * Older browsers and some runtimes don't support this yet * This API (as of 2025-05-07) is not available on React Native. */ function isGzipSupported() { return 'CompressionStream' in globalThis; } /** * Gzip a string using Compression Streams API if it's available */ async function gzipCompress(input, isDebug = true) { try { // Turn the string into a stream using a Blob, and then compress it const dataStream = new Blob([input], { type: 'text/plain', }).stream(); const compressedStream = dataStream.pipeThrough(new CompressionStream('gzip')); // Using a Response to easily extract the readablestream value. Decoding into a string for fetch return await new Response(compressedStream).blob(); } catch (error) { if (isDebug) { console.error('Failed to gzip compress data', error); } return null; } } class SimpleEventEmitter { constructor() { this.events = {}; this.events = {}; } on(event, listener) { if (!this.events[event]) { this.events[event] = []; } this.events[event].push(listener); return () => { this.events[event] = this.events[event].filter((x) => x !== listener); }; } emit(event, payload) { for (const listener of this.events[event] || []) { listener(payload); } for (const listener of this.events['*'] || []) { listener(event, payload); } } } class PostHogFetchHttpError extends Error { constructor(response, reqByteLength) { super('HTTP error while fetching PostHog: status=' + response.status + ', reqByteLength=' + reqByteLength); this.response = response; this.reqByteLength = reqByteLength; this.name = 'PostHogFetchHttpError'; } get status() { return this.response.status; } get text() { return this.response.text(); } get json() { return this.response.json(); } } class PostHogFetchNetworkError extends Error { constructor(error) { // TRICKY: "cause" is a newer property but is just ignored otherwise. Cast to any to ignore the type issue. // eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error // @ts-ignore super('Network error while fetching PostHog', error instanceof Error ? { cause: error } : {}); this.error = error; this.name = 'PostHogFetchNetworkError'; } } async function logFlushError(err) { if (err instanceof PostHogFetchHttpError) { let text = ''; try { text = await err.text; } catch { } console.error(`Error while flushing PostHog: message=${err.message}, response body=${text}`, err); } else { console.error('Error while flushing PostHog', err); } return Promise.resolve(); } function isPostHogFetchError(err) { return typeof err === 'object' && (err instanceof PostHogFetchHttpError || err instanceof PostHogFetchNetworkError); } function isPostHogFetchContentTooLargeError(err) { return typeof err === 'object' && err instanceof PostHogFetchHttpError && err.status === 413; } var QuotaLimitedFeature; (function (QuotaLimitedFeature) { QuotaLimitedFeature["FeatureFlags"] = "feature_flags"; QuotaLimitedFeature["Recordings"] = "recordings"; })(QuotaLimitedFeature || (QuotaLimitedFeature = {})); class PostHogCoreStateless { constructor(apiKey, options) { this.flushPromise = null; this.shutdownPromise = null; this.pendingPromises = {}; // internal this._events = new SimpleEventEmitter(); this._isInitialized = false; assert(apiKey, "You must pass your PostHog project's api key."); this.apiKey = apiKey; this.host = removeTrailingSlash(options?.host || 'https://us.i.posthog.com'); this.flushAt = options?.flushAt ? Math.max(options?.flushAt, 1) : 20; this.maxBatchSize = Math.max(this.flushAt, options?.maxBatchSize ?? 100); this.maxQueueSize = Math.max(this.flushAt, options?.maxQueueSize ?? 1000); this.flushInterval = options?.flushInterval ?? 10000; this.preloadFeatureFlags = options?.preloadFeatureFlags ?? true; // If enable is explicitly set to false we override the optout this.defaultOptIn = options?.defaultOptIn ?? true; this.disableSurveys = options?.disableSurveys ?? false; this._retryOptions = { retryCount: options?.fetchRetryCount ?? 3, retryDelay: options?.fetchRetryDelay ?? 3000, retryCheck: isPostHogFetchError, }; this.requestTimeout = options?.requestTimeout ?? 10000; // 10 seconds this.featureFlagsRequestTimeoutMs = options?.featureFlagsRequestTimeoutMs ?? 3000; // 3 seconds this.remoteConfigRequestTimeoutMs = options?.remoteConfigRequestTimeoutMs ?? 3000; // 3 seconds this.disableGeoip = options?.disableGeoip ?? true; this.disabled = options?.disabled ?? false; this.historicalMigration = options?.historicalMigration ?? false; // Init promise allows the derived class to block calls until it is ready this._initPromise = Promise.resolve(); this._isInitialized = true; this.disableCompression = !isGzipSupported() || (options?.disableCompression ?? false); } logMsgIfDebug(fn) { if (this.isDebug) { fn(); } } wrap(fn) { if (this.disabled) { this.logMsgIfDebug(() => console.warn('[PostHog] The client is disabled')); return; } if (this._isInitialized) { // NOTE: We could also check for the "opt in" status here... return fn(); } this._initPromise.then(() => fn()); } getCommonEventProperties() { return { $lib: this.getLibraryId(), $lib_version: this.getLibraryVersion(), }; } get optedOut() { return this.getPersistedProperty(PostHogPersistedProperty.OptedOut) ?? !this.defaultOptIn; } async optIn() { this.wrap(() => { this.setPersistedProperty(PostHogPersistedProperty.OptedOut, false); }); } async optOut() { this.wrap(() => { this.setPersistedProperty(PostHogPersistedProperty.OptedOut, true); }); } on(event, cb) { return this._events.on(event, cb); } debug(enabled = true) { this.removeDebugCallback?.(); if (enabled) { const removeDebugCallback = this.on('*', (event, payload) => console.log('PostHog Debug', event, payload)); this.removeDebugCallback = () => { removeDebugCallback();