posthog-node
Version:
PostHog Node.js integration
1,353 lines (1,340 loc) • 145 kB
JavaScript
import { posix, dirname, sep } from 'path';
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
/**
* @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] ? dirname(process.argv[1]) : process.cwd(), isWindows = 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
} = 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 = createReadStream(path);
const lineReaded = 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";
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;
(func