UNPKG

posthog-node

Version:
1,366 lines (1,350 loc) 146 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var path = require('path'); var node_fs = require('node:fs'); var node_readline = require('node:readline'); 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); }); } // Portions of this file are derived from getsentry/sentry-javascript by Software, Inc. dba Sentry /** Creates a function that gets the module name from a filename */ function createGetModuleFromFilename(basePath = process.argv[1] ? path.dirname(process.argv[1]) : process.cwd(), isWindows = path.sep === '\\') { const normalizedBase = isWindows ? normalizeWindowsPath(basePath) : basePath; return filename => { if (!filename) { return; } const normalizedFilename = isWindows ? normalizeWindowsPath(filename) : filename; // eslint-disable-next-line prefer-const let { dir, base: file, ext } = path.posix.parse(normalizedFilename); if (ext === '.js' || ext === '.mjs' || ext === '.cjs') { file = file.slice(0, ext.length * -1); } // The file name might be URI-encoded which we want to decode to // the original file name. const decodedFile = decodeURIComponent(file); if (!dir) { // No dirname whatsoever dir = '.'; } const n = dir.lastIndexOf('/node_modules'); if (n > -1) { return `${dir.slice(n + 14).replace(/\//g, '.')}:${decodedFile}`; } // Let's see if it's a part of the main module // To be a part of main module, it has to share the same base if (dir.startsWith(normalizedBase)) { const moduleName = dir.slice(normalizedBase.length + 1).replace(/\//g, '.'); return moduleName ? `${moduleName}:${decodedFile}` : decodedFile; } return decodedFile; }; } /** normalizes Windows paths */ function normalizeWindowsPath(path) { return path.replace(/^[A-Z]:/, '') // remove Windows-style prefix .replace(/\\/g, '/'); // replace all `\` instances with `/` } // Portions of this file are derived from getsentry/sentry-javascript by Software, Inc. dba Sentry // Licensed under the MIT License /** A simple Least Recently Used map */ class ReduceableCache { constructor(_maxSize) { this._maxSize = _maxSize; this._cache = new Map(); } /** Get an entry or undefined if it was not in the cache. Re-inserts to update the recently used order */ get(key) { const value = this._cache.get(key); if (value === undefined) { return undefined; } // Remove and re-insert to update the order this._cache.delete(key); this._cache.set(key, value); return value; } /** Insert an entry and evict an older entry if we've reached maxSize */ set(key, value) { this._cache.set(key, value); } /** Remove an entry and return the entry if it was in the cache */ reduce() { while (this._cache.size >= this._maxSize) { const value = this._cache.keys().next().value; if (value) { // keys() returns an iterator in insertion order so keys().next() gives us the oldest key this._cache.delete(value); } } } } // Portions of this file are derived from getsentry/sentry-javascript by Software, Inc. dba Sentry const LRU_FILE_CONTENTS_CACHE = new ReduceableCache(25); const LRU_FILE_CONTENTS_FS_READ_FAILED = new ReduceableCache(20); const DEFAULT_LINES_OF_CONTEXT = 7; // Determines the upper bound of lineno/colno that we will attempt to read. Large colno values are likely to be // minified code while large lineno values are likely to be bundled code. // Exported for testing purposes. const MAX_CONTEXTLINES_COLNO = 1000; const MAX_CONTEXTLINES_LINENO = 10000; async function addSourceContext(frames) { // keep a lookup map of which files we've already enqueued to read, // so we don't enqueue the same file multiple times which would cause multiple i/o reads const filesToLines = {}; // Maps preserve insertion order, so we iterate in reverse, starting at the // outermost frame and closer to where the exception has occurred (poor mans priority) for (let i = frames.length - 1; i >= 0; i--) { const frame = frames[i]; const filename = frame?.filename; if (!frame || typeof filename !== 'string' || typeof frame.lineno !== 'number' || shouldSkipContextLinesForFile(filename) || shouldSkipContextLinesForFrame(frame)) { continue; } const filesToLinesOutput = filesToLines[filename]; if (!filesToLinesOutput) { filesToLines[filename] = []; } filesToLines[filename].push(frame.lineno); } const files = Object.keys(filesToLines); if (files.length == 0) { return frames; } const readlinePromises = []; for (const file of files) { // If we failed to read this before, dont try reading it again. if (LRU_FILE_CONTENTS_FS_READ_FAILED.get(file)) { continue; } const filesToLineRanges = filesToLines[file]; if (!filesToLineRanges) { continue; } // Sort ranges so that they are sorted by line increasing order and match how the file is read. filesToLineRanges.sort((a, b) => a - b); // Check if the contents are already in the cache and if we can avoid reading the file again. const ranges = makeLineReaderRanges(filesToLineRanges); if (ranges.every(r => rangeExistsInContentCache(file, r))) { continue; } const cache = emplace(LRU_FILE_CONTENTS_CACHE, file, {}); readlinePromises.push(getContextLinesFromFile(file, ranges, cache)); } // The promise rejections are caught in order to prevent them from short circuiting Promise.all await Promise.all(readlinePromises).catch(() => {}); // Perform the same loop as above, but this time we can assume all files are in the cache // and attempt to add source context to frames. if (frames && frames.length > 0) { addSourceContextToFrames(frames, LRU_FILE_CONTENTS_CACHE); } // Once we're finished processing an exception reduce the files held in the cache // so that we don't indefinetly increase the size of this map LRU_FILE_CONTENTS_CACHE.reduce(); return frames; } /** * Extracts lines from a file and stores them in a cache. */ function getContextLinesFromFile(path, ranges, output) { return new Promise(resolve => { // It is important *not* to have any async code between createInterface and the 'line' event listener // as it will cause the 'line' event to // be emitted before the listener is attached. const stream = node_fs.createReadStream(path); const lineReaded = node_readline.createInterface({ input: stream }); // We need to explicitly destroy the stream to prevent memory leaks, // removing the listeners on the readline interface is not enough. // See: https://github.com/nodejs/node/issues/9002 and https://github.com/getsentry/sentry-javascript/issues/14892 function destroyStreamAndResolve() { stream.destroy(); resolve(); } // Init at zero and increment at the start of the loop because lines are 1 indexed. let lineNumber = 0; let currentRangeIndex = 0; const range = ranges[currentRangeIndex]; if (range === undefined) { // We should never reach this point, but if we do, we should resolve the promise to prevent it from hanging. destroyStreamAndResolve(); return; } let rangeStart = range[0]; let rangeEnd = range[1]; // We use this inside Promise.all, so we need to resolve the promise even if there is an error // to prevent Promise.all from short circuiting the rest. function onStreamError() { // Mark file path as failed to read and prevent multiple read attempts. LRU_FILE_CONTENTS_FS_READ_FAILED.set(path, 1); lineReaded.close(); lineReaded.removeAllListeners(); destroyStreamAndResolve(); } // We need to handle the error event to prevent the process from crashing in < Node 16 // https://github.com/nodejs/node/pull/31603 stream.on('error', onStreamError); lineReaded.on('error', onStreamError); lineReaded.on('close', destroyStreamAndResolve); lineReaded.on('line', line => { lineNumber++; if (lineNumber < rangeStart) { return; } // !Warning: This mutates the cache by storing the snipped line into the cache. output[lineNumber] = snipLine(line, 0); if (lineNumber >= rangeEnd) { if (currentRangeIndex === ranges.length - 1) { // We need to close the file stream and remove listeners, else the reader will continue to run our listener; lineReaded.close(); lineReaded.removeAllListeners(); return; } currentRangeIndex++; const range = ranges[currentRangeIndex]; if (range === undefined) { // This should never happen as it means we have a bug in the context. lineReaded.close(); lineReaded.removeAllListeners(); return; } rangeStart = range[0]; rangeEnd = range[1]; } }); }); } /** Adds context lines to frames */ function addSourceContextToFrames(frames, cache) { for (const frame of frames) { // Only add context if we have a filename and it hasn't already been added if (frame.filename && frame.context_line === undefined && typeof frame.lineno === 'number') { const contents = cache.get(frame.filename); if (contents === undefined) { continue; } addContextToFrame(frame.lineno, frame, contents); } } } /** * Resolves context lines before and after the given line number and appends them to the frame; */ function addContextToFrame(lineno, frame, contents) { // When there is no line number in the frame, attaching context is nonsensical and will even break grouping. // We already check for lineno before calling this, but since StackFrame lineno is optional, we check it again. if (frame.lineno === undefined || contents === undefined) { return; } frame.pre_context = []; for (let i = makeRangeStart(lineno); i < lineno; i++) { // We always expect the start context as line numbers cannot be negative. If we dont find a line, then // something went wrong somewhere. Clear the context and return without adding any linecontext. const line = contents[i]; if (line === undefined) { clearLineContext(frame); return; } frame.pre_context.push(line); } // We should always have the context line. If we dont, something went wrong, so we clear the context and return // without adding any linecontext. if (contents[lineno] === undefined) { clearLineContext(frame); return; } frame.context_line = contents[lineno]; const end = makeRangeEnd(lineno); frame.post_context = []; for (let i = lineno + 1; i <= end; i++) { // Since we dont track when the file ends, we cant clear the context if we dont find a line as it could // just be that we reached the end of the file. const line = contents[i]; if (line === undefined) { break; } frame.post_context.push(line); } } /** * Clears the context lines from a frame, used to reset a frame to its original state * if we fail to resolve all context lines for it. */ function clearLineContext(frame) { delete frame.pre_context; delete frame.context_line; delete frame.post_context; } /** * Determines if context lines should be skipped for a file. * - .min.(mjs|cjs|js) files are and not useful since they dont point to the original source * - node: prefixed modules are part of the runtime and cannot be resolved to a file * - data: skip json, wasm and inline js https://nodejs.org/api/esm.html#data-imports */ function shouldSkipContextLinesForFile(path) { // Test the most common prefix and extension first. These are the ones we // are most likely to see in user applications and are the ones we can break out of first. return path.startsWith('node:') || path.endsWith('.min.js') || path.endsWith('.min.cjs') || path.endsWith('.min.mjs') || path.startsWith('data:'); } /** * Determines if we should skip contextlines based off the max lineno and colno values. */ function shouldSkipContextLinesForFrame(frame) { if (frame.lineno !== undefined && frame.lineno > MAX_CONTEXTLINES_LINENO) { return true; } if (frame.colno !== undefined && frame.colno > MAX_CONTEXTLINES_COLNO) { return true; } return false; } /** * Checks if we have all the contents that we need in the cache. */ function rangeExistsInContentCache(file, range) { const contents = LRU_FILE_CONTENTS_CACHE.get(file); if (contents === undefined) { return false; } for (let i = range[0]; i <= range[1]; i++) { if (contents[i] === undefined) { return false; } } return true; } /** * Creates contiguous ranges of lines to read from a file. In the case where context lines overlap, * the ranges are merged to create a single range. */ function makeLineReaderRanges(lines) { if (!lines.length) { return []; } let i = 0; const line = lines[0]; if (typeof line !== 'number') { return []; } let current = makeContextRange(line); const out = []; while (true) { if (i === lines.length - 1) { out.push(current); break; } // If the next line falls into the current range, extend the current range to lineno + linecontext. const next = lines[i + 1]; if (typeof next !== 'number') { break; } if (next <= current[1]) { current[1] = next + DEFAULT_LINES_OF_CONTEXT; } else { out.push(current); current = makeContextRange(next); } i++; } return out; } // Determine start and end indices for context range (inclusive); function makeContextRange(line) { return [makeRangeStart(line), makeRangeEnd(line)]; } // Compute inclusive end context range function makeRangeStart(line) { return Math.max(1, line - DEFAULT_LINES_OF_CONTEXT); } // Compute inclusive start context range function makeRangeEnd(line) { return line + DEFAULT_LINES_OF_CONTEXT; } /** * Get or init map value */ function emplace(map, key, contents) { const value = map.get(key); if (value === undefined) { map.set(key, contents); return contents; } return value; } function snipLine(line, colno) { let newLine = line; const lineLength = newLine.length; if (lineLength <= 150) { return newLine; } if (colno > lineLength) { colno = lineLength; } let start = Math.max(colno - 60, 0); if (start < 5) { start = 0; } let end = Math.min(start + 140, lineLength); if (end > lineLength - 5) { end = lineLength; } if (end === lineLength) { start = Math.max(end - 140, 0); } newLine = newLine.slice(start, end); if (start > 0) { newLine = `...${newLine}`; } if (end < lineLength) { newLine += '...'; } return newLine; } 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"; PostHogPersisted