diff --git a/packages/core/package.json b/packages/core/package.json index 1c4cc2f..afcdc5f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@hawk.so/core", "version": "1.0.0", - "description": "Base implementation for all Hawk.so SDKs", + "description": "Core error tracking logic for Hawk.so catchers", "files": [ "dist" ], @@ -34,6 +34,7 @@ }, "homepage": "https://github.com/codex-team/hawk.javascript#readme", "devDependencies": { + "@hawk.so/types": "0.5.8", "vite": "^7.3.1", "vite-plugin-dts": "^4.2.4" } diff --git a/packages/core/src/catcher.ts b/packages/core/src/catcher.ts new file mode 100644 index 0000000..905f20a --- /dev/null +++ b/packages/core/src/catcher.ts @@ -0,0 +1,243 @@ +import { AffectedUser, EventContext, Json } from "@hawk.so/types"; +import { validateContext, validateUser } from "./utils/validation"; +import { Logger, LogType } from "./utils/log"; +import { HawkInitialSettings } from "./types/base-hawk-settings"; +import { HawkJavaScriptEvent } from "./types/event"; +import Sanitizer from "./modules/sanitizer"; +import { Transport } from "./types/transport"; +import { UserManager } from "./types/user-manager"; +import { isErrorProcessed, markErrorAsProcessed } from "./utils/event"; +import { CatcherMessage } from "./types/catcher-message"; +import log from "@hawk.so/javascript/dist/utils/log"; +import { EventRejectedError } from "./errors"; + +export abstract class BaseCatcher { + + /** + * Catcher Type + */ + protected readonly type: string; + + /** + * Current bundle version + */ + private readonly release: string | undefined; + + /** + * Any additional data passed by user for sending with all messages + */ + private context: EventContext | undefined; + + /** + * Transport for dialog between Catcher and Collector + * (WebSocket decorator by default, or custom via settings.transport) + */ + private readonly transport: Transport; + + /** + * Current authenticated user manager + */ + private readonly userManager: UserManager; + + /** + * Logger function + */ + private readonly log: Logger; + + constructor( + settings: HawkInitialSettings, + ) { + this.release = settings.release; + this.userManager = settings.userManager; + this.log = (settings.logger ?? (() => { + })) as Logger; + } + + /** + * Send test event from client + */ + public test(): void { + const fakeEvent = new Error('Hawk JavaScript Catcher test message.'); + + this.send(fakeEvent); + } + + /** + * Public method for manual sending messages to the Hawk + * Can be called in user's try-catch blocks or by other custom logic + * + * @param message - what to send + * @param [context] - any additional data to send + */ + public send(message: Error | string, context?: EventContext): void { + void this.formatAndSend(message, context); + } + + /** + * Format and send an error + * + * @param error - error to send + * @param integrationAddons - addons spoiled by Integration + * @param context - any additional data passed by user + */ + protected async formatAndSend( + error: Error | string, + context?: EventContext + ): Promise { + try { + const isAlreadySentError = isErrorProcessed(error); + + if (isAlreadySentError) { + /** + * @todo add debug build and log this case + */ + return; + } else { + markErrorAsProcessed(error, this.log); + } + + const errorFormatted = await this.prepareErrorFormatted(error, context); + + this.sendErrorFormatted(errorFormatted); + } catch (e) { + if (e instanceof EventRejectedError) { + /** + * Event was rejected by user + */ + return; + } + + log('Unable to send error. Seems like it is Hawk internal bug. Please, report it here: https://github.com/codex-team/hawk.javascript/issues/new', 'warn', e); + } + } + + /** + * Sends formatted HawkEvent to the Collector + * + * @param errorFormatted - formatted error to send + */ + private sendErrorFormatted(errorFormatted: CatcherMessage): void { + this.transport.send(errorFormatted) + .catch((sendingError) => { + log('Transport sending error', 'error', sendingError); + }); + } + + /** + * Formats the event + * + * @param error - error to format + * @param context - any additional data passed by user + */ + abstract async prepareErrorFormatted(error: Error | string, context?: EventContext): Promise + + /** + * Update the current user information + * + * @param user - New user information + */ + public setUser(user: AffectedUser): void { + if (!validateUser(user, this.log)) { + return; + } + + this.userManager.setUser(user); + } + + /** + * Update the context data that will be sent with all events + * + * @param context - New context data + */ + public setContext(context: EventContext | undefined): void { + if (!validateContext(context, this.log)) { + return; + } + + this.context = context; + } + + /** + * Return event type: TypeError, ReferenceError etc + * + * @param error - caught error + */ + protected getType(error: Error | string): HawkJavaScriptEvent['type'] { + if (!(error instanceof Error)) { + return null; + } + + return error.name; + } + + /** + * Release version + */ + protected getRelease(): HawkJavaScriptEvent['release'] { + return this.release !== undefined ? String(this.release) : null; + } + + /** + * Current authenticated user + */ + protected getUser(): HawkJavaScriptEvent['user'] { + return this.userManager.getUser(); + } + + /** + * Collects additional information + * + * @param context - any additional data passed by user + */ + protected getContext(context?: EventContext): HawkJavaScriptEvent['context'] { + const contextMerged = {}; + + if (this.context !== undefined) { + Object.assign(contextMerged, this.context); + } + + if (context !== undefined) { + Object.assign(contextMerged, context); + } + + return Sanitizer.sanitize(contextMerged); + } + + /** + * Compose raw data object + * + * @param {Error|string} error — caught error + */ + protected getRawData(error: Error | string): Json | undefined { + if (!(error instanceof Error)) { + return; + } + + const stack = error.stack !== undefined ? error.stack : ''; + + return { + name: error.name, + message: error.message, + stack, + }; + } + + /** + * Return event title + * + * @param error - event from which to get the title + */ + protected getTitle(error: Error | string): string { + const notAnError = !(error instanceof Error); + + /** + * Case when error is 'reason' of PromiseRejectionEvent + * and reject() provided with text reason instead of Error() + */ + if (notAnError) { + return error.toString() as string; + } + + return (error as Error).message; + } +} diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts new file mode 100644 index 0000000..8fcc582 --- /dev/null +++ b/packages/core/src/errors.ts @@ -0,0 +1,12 @@ +/** + * Error triggered when event was rejected by beforeSend method + */ +export class EventRejectedError extends Error { + /** + * @param message - error message + */ + constructor(message: string) { + super(message); + this.name = 'EventRejectedError'; + } +} diff --git a/packages/core/src/modules/sanitizer.ts b/packages/core/src/modules/sanitizer.ts new file mode 100644 index 0000000..a02172d --- /dev/null +++ b/packages/core/src/modules/sanitizer.ts @@ -0,0 +1,297 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * This class provides methods for preparing data to sending to Hawk + * - trim long strings + * - represent html elements like
as "
" instead of "{}" + * - represent big objects as "" + * - represent class as or + */ +export default class Sanitizer { + /** + * Maximum string length + */ + private static readonly maxStringLen: number = 200; + + /** + * If object in stringified JSON has more keys than this value, + * it will be represented as "" + */ + private static readonly maxObjectKeysCount: number = 20; + + /** + * Maximum depth of context object + */ + private static readonly maxDepth: number = 5; + + /** + * Maximum length of context arrays + */ + private static readonly maxArrayLength: number = 10; + + /** + * Check if passed variable is an object + * + * @param target - variable to check + */ + public static isObject(target: any): boolean { + return Sanitizer.typeOf(target) === 'object'; + } + + /** + * Apply sanitizing for array/object/primitives + * + * @param data - any object to sanitize + * @param depth - current depth of recursion + * @param seen - Set of already seen objects to prevent circular references + */ + public static sanitize(data: any, depth = 0, seen = new WeakSet()): any { + /** + * Check for circular references on objects and arrays + */ + if (data !== null && typeof data === 'object') { + if (seen.has(data)) { + return ''; + } + seen.add(data); + } + + /** + * If value is an Array, apply sanitizing for each element + */ + if (Sanitizer.isArray(data)) { + return this.sanitizeArray(data, depth + 1, seen); + + /** + * If value is an Element, format it as string with outer HTML + * HTMLDivElement -> "
" + */ + } else if (Sanitizer.isElement(data)) { + return Sanitizer.formatElement(data); + + /** + * If values is a not-constructed class, it will be formatted as "" + * class Editor {...} -> + */ + } else if (Sanitizer.isClassPrototype(data)) { + return Sanitizer.formatClassPrototype(data); + + /** + * If values is a some class instance, it will be formatted as "" + * new Editor() -> + */ + } else if (Sanitizer.isClassInstance(data)) { + return Sanitizer.formatClassInstance(data); + + /** + * If values is an object, do recursive call + */ + } else if (Sanitizer.isObject(data)) { + return Sanitizer.sanitizeObject(data, depth + 1, seen); + + /** + * If values is a string, trim it for max-length + */ + } else if (Sanitizer.isString(data)) { + return Sanitizer.trimString(data); + } + + /** + * If values is a number, boolean and other primitive, leave as is + */ + return data; + } + + /** + * Apply sanitizing for each element of the array + * + * @param arr - array to sanitize + * @param depth - current depth of recursion + * @param seen - Set of already seen objects to prevent circular references + */ + private static sanitizeArray(arr: any[], depth: number, seen: WeakSet): any[] { + /** + * If the maximum length is reached, slice array to max length and add a placeholder + */ + const length = arr.length; + + if (length > Sanitizer.maxArrayLength) { + arr = arr.slice(0, Sanitizer.maxArrayLength); + arr.push(`<${length - Sanitizer.maxArrayLength} more items...>`); + } + + return arr.map((item: any) => { + return Sanitizer.sanitize(item, depth, seen); + }); + } + + /** + * Process object values recursive + * + * @param data - object to beautify + * @param depth - current depth of recursion + * @param seen - Set of already seen objects to prevent circular references + */ + private static sanitizeObject(data: { [key: string]: any }, depth: number, seen: WeakSet): Record | '' | '' { + /** + * If the maximum depth is reached, return a placeholder + */ + if (depth > Sanitizer.maxDepth) { + return ''; + } + + /** + * If the object has more keys than the limit, return a placeholder + */ + if (Object.keys(data).length > Sanitizer.maxObjectKeysCount) { + return ''; + } + + const result: any = {}; + + for (const key in data) { + if (Object.prototype.hasOwnProperty.call(data, key)) { + result[key] = Sanitizer.sanitize(data[key], depth, seen); + } + } + + return result; + } + + /** + * Check if passed variable is an array + * + * @param target - variable to check + */ + private static isArray(target: any): boolean { + return Array.isArray(target); + } + + /** + * Check if passed variable is a not-constructed class + * + * @param target - variable to check + */ + private static isClassPrototype(target: any): boolean { + if (!target || !target.constructor) { + return false; + } + + /** + * like + * "function Function { + * [native code] + * }" + */ + const constructorStr = target.constructor.toString(); + + return constructorStr.includes('[native code]') && constructorStr.includes('Function'); + } + + /** + * Check if passed variable is a constructed class instance + * + * @param target - variable to check + */ + private static isClassInstance(target: any): boolean { + return target && target.constructor && (/^class \S+ {/).test(target.constructor.toString()); + } + + /** + * Check if passed variable is a string + * + * @param target - variable to check + */ + private static isString(target: any): boolean { + return typeof target === 'string'; + } + + /** + * Return string representation of the object type + * + * @param object - object to get type + */ + private static typeOf(object: any): string { + return Object.prototype.toString.call(object).match(/\s([a-zA-Z]+)/)[1].toLowerCase(); + } + + /** + * Check if passed variable is an HTML Element + * + * @param target - variable to check + */ + private static isElement(target: any): boolean { + return target instanceof Element; + } + + /** + * Return name of a passed class + * + * @param target - not-constructed class + */ + private static getClassNameByPrototype(target: any): string { + return target.name; + } + + /** + * Return name of a class by an instance + * + * @param target - instance of some class + */ + private static getClassNameByInstance(target: any): string { + return Sanitizer.getClassNameByPrototype(target.constructor); + } + + /** + * Trim string if it reaches max length + * + * @param target - string to check + */ + private static trimString(target: string): string { + if (target.length > Sanitizer.maxStringLen) { + return target.substr(0, Sanitizer.maxStringLen) + '…'; + } + + return target; + } + + /** + * Represent HTML Element as string with it outer-html + * HTMLDivElement -> "
" + * + * @param target - variable to format + */ + private static formatElement(target: Element): string { + /** + * Also, remove inner HTML because it can be BIG + */ + const innerHTML = target.innerHTML; + + if (innerHTML) { + return target.outerHTML.replace(target.innerHTML, '…'); + } + + return target.outerHTML; + } + + /** + * Represent not-constructed class as "" + * + * @param target - class to format + */ + private static formatClassPrototype(target: any): string { + const className = Sanitizer.getClassNameByPrototype(target); + + return ``; + } + + /** + * Represent a some class instance as a "" + * + * @param target - class instance to format + */ + private static formatClassInstance(target: any): string { + const className = Sanitizer.getClassNameByInstance(target); + + return ``; + } +} diff --git a/packages/core/src/types/base-hawk-settings.ts b/packages/core/src/types/base-hawk-settings.ts new file mode 100644 index 0000000..86bb203 --- /dev/null +++ b/packages/core/src/types/base-hawk-settings.ts @@ -0,0 +1,40 @@ +import {AffectedUser, EventContext} from "@hawk.so/types"; +import {Transport} from "./transport"; +import {Logger} from "vite"; +import {UserManager} from "./user-manager"; + +/** + * JS Catcher initial settings + */ +export interface HawkInitialSettings { + /** + * Enable debug mode + * Send raw event's data additionally in addons field by key 'RAW_EVENT_DATA' + */ + debug?: boolean; + + /** + * Current release and bundle version + */ + release?: string; + + /** + * Current user information + */ + user?: AffectedUser; + + /** + * Any additional data you want to send with every event + */ + context?: EventContext; + + /** + * Custom transport for sending events. + * If not provided, default WebSocket transport is used. + */ + transport: Transport; + + userManager: UserManager; + + logger?: Logger; +} diff --git a/packages/core/src/types/catcher-message.ts b/packages/core/src/types/catcher-message.ts new file mode 100644 index 0000000..d892e22 --- /dev/null +++ b/packages/core/src/types/catcher-message.ts @@ -0,0 +1,21 @@ +import type { HawkJavaScriptEvent } from './event'; + +/** + * Structure describing a message sending by Catcher + */ +export interface CatcherMessage { + /** + * User project's Integration Token + */ + token: string; + + /** + * Hawk Catcher name + */ + catcherType: string; + + /** + * All information about the event + */ + payload: HawkJavaScriptEvent; +} diff --git a/packages/core/src/types/event.ts b/packages/core/src/types/event.ts new file mode 100644 index 0000000..82dec49 --- /dev/null +++ b/packages/core/src/types/event.ts @@ -0,0 +1,55 @@ +import type { AffectedUser, BacktraceFrame, EventContext, EventData, JavaScriptAddons, Breadcrumb } from '@hawk.so/types'; + +/** + * Event data with JS specific addons + */ +type JSEventData = EventData; + +/** + * Event will be sent to Hawk by Hawk JavaScript SDK + * + * The listed EventData properties will always be sent, so we define them as required in the type + */ +export type HawkJavaScriptEvent = Omit & { + /** + * Event type: TypeError, ReferenceError etc + * null for non-error events + */ + type: string | null; + + /** + * Current release (aka version, revision) of an application + */ + release: string | null; + + /** + * Breadcrumbs - chronological trail of events before the error + */ + breadcrumbs: Breadcrumb[] | null; + + /** + * Current authenticated user + */ + user: AffectedUser | null; + + /** + * Any other information collected and passed by user + */ + context: EventContext; + + /** + * Catcher-specific information + */ + addons: JavaScriptAddons; + + /** + * Stack + * From the latest call to the earliest + */ + backtrace: BacktraceFrame[] | null; + + /** + * Catcher version + */ + catcherVersion: string; +}; diff --git a/packages/core/src/types/transport.ts b/packages/core/src/types/transport.ts new file mode 100644 index 0000000..f2237dc --- /dev/null +++ b/packages/core/src/types/transport.ts @@ -0,0 +1,8 @@ +import type { CatcherMessage } from './catcher-message'; + +/** + * Transport interface — anything that can send a CatcherMessage + */ +export interface Transport { + send(message: CatcherMessage): Promise; +} diff --git a/packages/core/src/utils/event.ts b/packages/core/src/utils/event.ts new file mode 100644 index 0000000..25d4b87 --- /dev/null +++ b/packages/core/src/utils/event.ts @@ -0,0 +1,47 @@ +import {Logger} from "./log"; + +/** + * Symbol to mark error as processed by Hawk + */ +const errorSentShadowProperty = Symbol('__hawk_processed__'); + +/** + * Check if the error has already been sent to Hawk. + * + * Motivation: + * Some integrations may catch errors on their own side and then normally re-throw them down. + * In this case, Hawk will catch the error again. + * We need to prevent this from happening. + * + * @param error - error object + */ +export function isErrorProcessed(error: unknown): boolean { + if (typeof error !== 'object' || error === null) { + return false; + } + + return error[errorSentShadowProperty] === true; +} + +/** + * Add non-enumerable property to the error object to mark it as processed. + * + * @param error - error object + * @param log - logger function + */ +export function markErrorAsProcessed(error: unknown, log: Logger): void { + try { + if (typeof error !== 'object' || error === null) { + return; + } + + Object.defineProperty(error, errorSentShadowProperty, { + enumerable: false, // Prevent from being collected by Hawk + value: true, + writable: true, + configurable: true, + }); + } catch (e) { + log('Failed to mark error as processed', 'error', e); + } +} diff --git a/packages/core/src/utils/validation.ts b/packages/core/src/utils/validation.ts new file mode 100644 index 0000000..ca35b99 --- /dev/null +++ b/packages/core/src/utils/validation.ts @@ -0,0 +1,48 @@ +import {AffectedUser, EventContext} from "@hawk.so/types"; +import {Logger} from "./log"; +import Sanitizer from "../modules/sanitizer"; + +/** + * Validates user data - basic security checks + * + * @param user - user data to validate + * @param log - logger function + */ +export function validateUser( + user: AffectedUser, + log: Logger, +): boolean { + if (!user || !Sanitizer.isObject(user)) { + log('validateUser: User must be an object', 'warn'); + + return false; + } + + // Validate required ID + if (!user.id || user.id.trim() === '') { + log('validateUser: User ID is required and must be a non-empty string', 'warn'); + + return false; + } + + return true; +} + +/** + * Validates context data - basic security checks + * + * @param context - context data to validate + * @param log - logger function + */ +export function validateContext( + context: EventContext | undefined, + log: Logger, +): boolean { + if (context && !Sanitizer.isObject(context)) { + log('validateContext: Context must be an object', 'warn'); + + return false; + } + + return true; +} diff --git a/packages/javascript/vite.config.ts b/packages/javascript/vite.config.ts index 65b622e..1792199 100644 --- a/packages/javascript/vite.config.ts +++ b/packages/javascript/vite.config.ts @@ -37,8 +37,8 @@ export default defineConfig(() => { return false; } - // Allow MIT and Apache-2.0 licenses. - return ['MIT', 'Apache-2.0'].includes(dependency.license); + // Allow MIT, Apache-2.0 and AGPL-3.0-only (own packages) licenses. + return ['MIT', 'Apache-2.0', 'AGPL-3.0-only'].includes(dependency.license); }, failOnUnlicensed: true, failOnViolation: true, diff --git a/yarn.lock b/yarn.lock index fed6aff..3189014 100644 --- a/yarn.lock +++ b/yarn.lock @@ -580,6 +580,7 @@ __metadata: version: 0.0.0-use.local resolution: "@hawk.so/core@workspace:packages/core" dependencies: + "@hawk.so/types": "npm:0.5.8" vite: "npm:^7.3.1" vite-plugin-dts: "npm:^4.2.4" languageName: unknown