posthog-node
Version:
PostHog Node.js integration
250 lines (210 loc) • 7.34 kB
text/typescript
// Portions of this file are derived from getsentry/sentry-javascript by Software, Inc. dba Sentry
// Licensed under the MIT License
import { isError, isErrorEvent, isEvent, isPlainObject } from './type-checking'
import { ErrorProperties, EventHint, Exception, Mechanism, StackFrame, StackParser } from './types'
import { addSourceContext } from './context-lines'
export async function propertiesFromUnknownInput(
stackParser: StackParser,
input: unknown,
hint?: EventHint
): Promise<ErrorProperties> {
const providedMechanism = hint && hint.mechanism
const mechanism = providedMechanism || {
handled: true,
type: 'generic',
}
const error = getError(mechanism, input, hint)
const exception = await exceptionFromError(stackParser, error)
exception.value = exception.value || ''
exception.type = exception.type || 'Error'
exception.mechanism = mechanism
const properties = { $exception_list: [exception] }
return properties
}
function getError(mechanism: Mechanism, exception: unknown, hint?: EventHint): Error {
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 as string)
ex.message = `${exception}`
return ex
}
/** If a plain object has a property that is an `Error`, return this error. */
function getErrorPropertyFromObject(obj: Record<string, unknown>): Error | undefined {
for (const prop in obj) {
if (Object.prototype.hasOwnProperty.call(obj, prop)) {
const value = obj[prop]
if (value instanceof Error) {
return value
}
}
}
return undefined
}
function getMessageForObject(exception: Record<string, unknown>): string {
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: unknown): string | undefined | void {
try {
const prototype: unknown | null = 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: Record<string, unknown>, maxLength: number = 40): string {
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: string, max: number = 0): string {
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<V>(value: V):
| {
[ownProps: string]: unknown
type: string
target: string
currentTarget: string
detail?: unknown
}
| {
[ownProps: string]: unknown
message: string
name: string
stack?: string
}
| V {
if (isError(value)) {
return {
message: value.message,
name: value.name,
stack: value.stack,
...getOwnProperties(value),
}
} else if (isEvent(value)) {
const newObj: {
[ownProps: string]: unknown
type: string
target: string
currentTarget: string
detail?: unknown
} = {
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: unknown): { [key: string]: unknown } {
if (typeof obj === 'object' && obj !== null) {
const extractedProps: { [key: string]: unknown } = {}
for (const property in obj) {
if (Object.prototype.hasOwnProperty.call(obj, property)) {
extractedProps[property] = (obj as Record<string, unknown>)[property]
}
}
return extractedProps
} else {
return {}
}
}
/** Creates a string representation of the target of an `Event` object */
function serializeEventTarget(target: unknown): string {
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: StackParser, error: Error): Promise<Exception> {
const exception: Exception = {
type: error.name || error.constructor.name,
value: error.message,
}
const frames = await addSourceContext(parseStackFrames(stackParser, error))
if (frames.length) {
exception.stacktrace = { frames, type: 'raw' }
}
return exception
}
/**
* Extracts stack frames from the error.stack string
*/
function parseStackFrames(stackParser: StackParser, error: Error): StackFrame[] {
return stackParser(error.stack || '', 1)
}