posthog-node
Version:
PostHog Node.js integration
1,350 lines (1,332 loc) • 131 kB
JavaScript
'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();