diff --git a/README.md b/README.md
index 454b498..23c9719 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@ Then import `@hawk.so/javascript` module to your code.
```js
import HawkCatcher from '@hawk.so/javascript';
-````
+```
### Load from CDN
@@ -74,6 +74,7 @@ Initialization settings:
| `disableGlobalErrorsHandling` | boolean | optional | Do not initialize global errors handling |
| `disableVueErrorHandler` | boolean | optional | Do not initialize Vue errors handling |
| `beforeSend` | function(event) => event | optional | This Method allows you to filter any data you don't want sending to Hawk |
+| `performance` | boolean\|object | optional | Performance monitoring settings. When object, accepts: - `sampleRate`: Sample rate (0.0 to 1.0, default: 1.0) - `thresholdMs`: Minimum duration threshold in ms (default: 20) - `criticalDurationThresholdMs`: Duration threshold for critical transactions in ms (default: 500) - `batchInterval`: Batch send interval in ms (default: 3000) |
Other available [initial settings](types/hawk-initial-settings.d.ts) are described at the type definition.
@@ -151,7 +152,6 @@ const hawk = new HawkCatcher({
or pass it any moment after Hawk Catcher was instantiated:
-
```js
import Vue from 'vue';
@@ -161,3 +161,154 @@ const hawk = new HawkCatcher({
hawk.connectVue(Vue)
```
+
+## Performance Monitoring
+
+The SDK can monitor performance of your application by tracking transactions and spans.
+
+### Transaction Batching and Aggregation
+
+Transactions are collected, aggregated, and sent in batches to reduce network overhead and provide statistical insights:
+
+- Transactions with the same name are grouped together
+- Statistical metrics are calculated (p50, p95, max durations)
+- Spans are aggregated across transactions
+- Failure rates are tracked for both transactions and spans
+
+You can configure the batch interval using the `batchInterval` option:
+
+```js
+const hawk = new HawkCatcher({
+ token: 'INTEGRATION_TOKEN',
+ performance: {
+ batchInterval: 5000 // Send batches every 5 seconds
+ }
+});
+```
+
+### Sampling and Filtering
+
+You can configure what percentage of transactions should be sent to Hawk using the `sampleRate` option:
+
+```typescript
+const hawk = new HawkCatcher({
+ token: 'INTEGRATION_TOKEN',
+ performance: {
+ sampleRate: 0.2, // Sample 20% of transactions
+ thresholdMs: 50 // Only send transactions longer than 50ms
+ }
+});
+```
+
+Transactions are automatically filtered based on:
+- Duration threshold (transactions shorter than `thresholdMs` are ignored)
+- Critical duration threshold (transactions longer than `criticalDurationThresholdMs` are always sent)
+- Sample rate (random sampling based on `sampleRate`)
+- Severity (critical transactions are always sent regardless of sampling)
+- Status (failed transactions are always sent regardless of sampling)
+
+### API Reference
+
+#### startTransaction(name: string, severity?: 'default' | 'critical'): Transaction
+
+Starts a new transaction. A transaction represents a high-level operation like a page load or an API call.
+
+- `name`: Name of the transaction
+- `severity`: Optional severity level. 'critical' transactions are always sent regardless of sampling.
+
+#### Transaction Methods
+
+```typescript
+interface Transaction {
+ // Start a new span within this transaction
+ startSpan(name: string): Span;
+
+ // Finish the transaction with optional status
+ finish(status?: 'success' | 'failure'): void;
+}
+```
+
+#### Span Methods
+
+```typescript
+interface Span {
+ // Finish the span with optional status
+ finish(status?: 'success' | 'failure'): void;
+}
+```
+
+### Examples
+
+#### Measuring Route Changes in Vue.js
+```javascript
+import { HawkCatcher } from '@hawk.so/javascript';
+import Vue from 'vue';
+import Router from 'vue-router';
+
+const hawk = new HawkCatcher({
+ token: 'INTEGRATION_TOKEN',
+ performance: true
+});
+
+router.beforeEach((to, from, next) => {
+ const transaction = hawk.startTransaction('route-change');
+
+ next();
+
+ // After route change is complete
+ Vue.nextTick(() => {
+ transaction.finish();
+ });
+});
+```
+
+#### Measuring API Calls with Error Handling
+```javascript
+async function fetchUsers() {
+ const transaction = hawk.startTransaction('fetch-users');
+
+ const apiSpan = transaction.startSpan('api-call');
+
+ try {
+ const response = await fetch('/api/users');
+
+ if (!response.ok) {
+ apiSpan.finish('failure');
+ transaction.finish('failure');
+ return null;
+ }
+
+ const data = await response.json();
+ apiSpan.finish('success');
+
+ const processSpan = transaction.startSpan('process-data');
+ // Process data...
+ processSpan.finish();
+
+ transaction.finish('success');
+ return data;
+ } catch (error) {
+ apiSpan.finish('failure');
+ transaction.finish('failure');
+ throw error;
+ }
+}
+```
+
+#### Critical Transactions
+```javascript
+function processPayment(paymentDetails) {
+ // Mark as critical to ensure it's always sent regardless of sampling
+ const transaction = hawk.startTransaction('payment-processing', 'critical');
+
+ try {
+ // Payment processing logic...
+
+ transaction.finish('success');
+ return true;
+ } catch (error) {
+ transaction.finish('failure');
+ throw error;
+ }
+}
+```
diff --git a/docs/performance-monitoring.md b/docs/performance-monitoring.md
new file mode 100644
index 0000000..ea65b4a
--- /dev/null
+++ b/docs/performance-monitoring.md
@@ -0,0 +1,169 @@
+# Performance Monitoring
+
+## Optimizations
+
+Sending all transactions without filtering and aggregation would create an heavy load on the service. We must carefully balance data completeness with efficient storage and processing to ensure optimal performance monitoring.
+
+### 1. Transaction Selection Criteria
+
+#### Problem: Too Much Data in case of full sending
+
+ Sending every transaction without filtering creates several critical issues:
+
+ - Significantly increased server load, particularly during high traffic periods
+ - Large amounts of redundant data that provide minimal analytical value
+ - "Infinite loops" in client code may generate endless transactions.
+
+#### Solution: Smart Sampling and Grouping
+
+ Instead of collecting every transaction, we focus on gathering a representative sample that provides meaningful insights while minimizing data volume.
+
+### 2. Data Flow Optimization Strategies
+#### Optimization #1: Sampling (Sending Part of the Data Randomly)
+
+ To ensure we capture important but infrequent transactions:
+
+ – Slow transactions are always sent. Transaction is considered slow if its duration is greater than criticalDurationThresholdMs parameter.
+ - Errors (`status` == 'failure') are always sent.
+
+ See [Sampling](#sampling) for details.
+
+#### Optimization #2: Aggregation of Identical Transactions Before Sending
+
+ Throttling + transaction batches → instead of 1000 separate messages, send 1 `AggregatedTransaction`.
+
+##### Combine transactions with the same name (e.g., GET /api/users) and time window (e.g., 3 seconds).
+
+Instead of 1000 transactions, send one with count = 1000 and average metrics.
+
+##### How to choose the time?
+
+ - Store P50, P95, P100 (percentiles).
+ - Save min(startTime) and max(endTime) to see the interval boundaries and calculate Transactions Per Minute.
+
+ **What do we lose ?**
+ - The detail of each specific transaction.
+ - Exact startTime and endTime for each transaction.
+
+ **What do we gain?**
+ - A sharp reduction in load on the Collector and DB (10-100 times fewer records).
+ - All necessary metrics (P50, P95, P100, avg) remain.
+ - You can continue to build graphs and calculate metrics, but with less load.
+
+ See [Transaction Aggregation](#transaction-aggregation) for details on how transactions are aggregated.
+
+#### Optimization #3: Filtering "Garbage"
+
+Transactions with duration < `thresholdMs` will not be sent, as they are not critical.
+
+
+## Data types
+
+### Transaction
+| Field | Type | Description |
+|-------|------|-------------|
+| id | string | Unique identifier of the transaction |
+| severity | string | Type of transaction sampling ('default' or 'critical'). See [Sampling](#sampling) for details |
+| name | string | Name of the transaction |
+| startTime | number | Timestamp when transaction started |
+| endTime | number | Timestamp when transaction ended |
+| duration | number | Total duration of transaction in milliseconds |
+| status | string | Status when transaction finished. 'success' (default) or 'failure'. See [Transaction Completion](#2-transaction-completion) |
+| spans | Span[] | Array of [spans](#span) associated with this transaction |
+
+### AggregatedTransaction
+| Field | Type | Description |
+|-------|------|-------------|
+| aggregationId | string | Identifier of the aggregation |
+| name | string | Name of the transaction |
+| avgStartTime | number | Average timestamp when transaction started |
+| minStartTime | number | Minimum timestamp when transaction started |
+| maxEndTime | number | Maximum timestamp when transaction ended |
+| p50duration | number | 50th percentile (median) duration of transaction in milliseconds |
+| p95duration | number | 95th percentile duration of transaction in milliseconds |
+| maxDuration | number | Maximum duration of transaction in milliseconds |
+| count | number | how many transactions aggregated |
+| failureRate | number | percentage of transactions with status 'failure' |
+| aggregatedSpans | AggregatedSpan[] | List of spans in transactions |
+
+
+### Span
+| Field | Type | Description |
+|-------|------|-------------|
+| id | string | Unique identifier of the span |
+| name | string | Name of the span |
+| startTime | number | Timestamp when span started |
+| endTime | number | Timestamp when span ended |
+| duration | number | Total duration of span in milliseconds |
+| status | string | Status when span finished. 'success' (default) or 'failure' |
+
+### AggregatedSpan
+See [Transaction Aggregation](#transaction-aggregation) for details on how spans are aggregated.
+
+| Field | Type | Description |
+|-------|------|-------------|
+| aggregationId | string | Unique identifier of the span aggregation |
+| name | string | Name of the span |
+| minStartTime | number | Minimum timestamp when span started |
+| maxEndTime | number | Maximum timestamp when span ended |
+| p50duration | number | 50th percentile (median) duration of span in milliseconds |
+| p95duration | number | 95th percentile duration of span in milliseconds |
+| maxDuration | number | Maximum duration of span in milliseconds |
+| failureRate | number | percentage of spans with status 'failure' |
+
+## Transaction Lifecycle
+
+### 1. Transaction Creation
+
+When creating a transaction, you can specify its type:
+
+- 'critical' - important transactions that are always sent to the server
+- 'default' - regular transactions that go through the [sampling process](#sampling)
+
+### 2. Transaction Completion
+
+When completing a transaction:
+
+1. A finish status is specified (`status`):
+ - 'success' (default) - successful completion
+ - 'failure' - completion with error (such transactions are always sent to the server)
+
+2. The transaction duration is checked:
+ - If `thresholdMs` parameter is specified and the transaction duration is less than this value, the transaction is discarded
+ - Default `thresholdMs` is 20ms
+ - `status` "failure" has a priority over `thresholdMs`
+ - Otherwise, the transaction goes through the [sampling process](#sampling)
+
+3. After successful sampling, the transaction is added to the list for sending
+
+### 3. Sending Transactions
+
+- When the first transaction is added to the list, a timer starts
+- When the timer expires:
+ 1. All collected transactions are [aggregated](#transaction-aggregation)
+ 2. Aggregated data is sent to the server
+ 3. The transaction list is cleared
+
+## Sampling
+
+- The probability of sending transactions is configured through the `performance.sampleRate` parameter (value from 0 to 1)
+- Only transactions of type 'default' with finish status 'success' are subject to sampling
+- Sampling process:
+ 1. A random number between 0 and 1 is generated for each transaction
+ 2. If the number is less than or equal to sampleRate, the transaction is sent
+
+## Transaction Aggregation
+
+1. [Transactions](#transaction) are grouped by name (name field)
+2. For each group, statistical indicators are calculated:
+ - minStartTime - earliest start time
+ - maxEndTime - latest end time
+ - p50duration - median duration (50th percentile)
+ - p95duration - 95th percentile duration
+ - maxDuration - maximum duration
+
+3. Based on this data, [AggregatedTransaction](#aggregatedtransaction) objects are created
+4. For each aggregated transaction:
+ - [Spans](#span) are grouped by name
+ - Their own statistical indicators (see [AggregatedSpan](#aggregatedspan)) are calculated for each span group
+ - [AggregatedSpan](#aggregatedspan) objects are created
diff --git a/example/monitoring.html b/example/monitoring.html
new file mode 100644
index 0000000..faac9da
--- /dev/null
+++ b/example/monitoring.html
@@ -0,0 +1,200 @@
+
+
+
+
+
+ Hawk.js Demo
+
+
+
+
Hawk.js Demo
+
+
Performance Monitoring Settings Demo
+
+
Configuration
+
+
+
+
+
+
+
+
+
+
Generate Transactions
+
+
+
+
+
+
+
+
+
Aggregation Testing
+
+
+
+
+
+
Results
+
+
+
+
+
diff --git a/src/addons/consoleCatcher.ts b/src/addons/consoleCatcher.ts
index fc8fa13..650e9ff 100644
--- a/src/addons/consoleCatcher.ts
+++ b/src/addons/consoleCatcher.ts
@@ -63,7 +63,8 @@ const createConsoleCatcher = (): {
const oldFunction = window.console[method].bind(window.console);
window.console[method] = function (...args: unknown[]): void {
- const stack = new Error().stack?.split('\n').slice(2).join('\n') || '';
+ const stack = new Error().stack?.split('\n').slice(2)
+ .join('\n') || '';
const logEvent: ConsoleLogEvent = {
method,
@@ -93,4 +94,5 @@ const createConsoleCatcher = (): {
};
const consoleCatcher = createConsoleCatcher();
+
export const { initConsoleCatcher, getConsoleLogStack, addErrorEvent } = consoleCatcher;
diff --git a/src/catcher.ts b/src/catcher.ts
index 0cd4326..2d34e01 100644
--- a/src/catcher.ts
+++ b/src/catcher.ts
@@ -16,6 +16,8 @@ import type { JavaScriptCatcherIntegrations } from './types/integrations';
import { EventRejectedError } from './errors';
import type { HawkJavaScriptEvent } from './types';
import { isErrorProcessed, markErrorAsProcessed } from './utils/event';
+import type { Transaction } from './modules/performance/transaction';
+import PerformanceMonitoring from './modules/performance/index';
import { addErrorEvent, getConsoleLogStack, initConsoleCatcher } from './addons/consoleCatcher';
/**
@@ -23,6 +25,7 @@ import { addErrorEvent, getConsoleLogStack, initConsoleCatcher } from './addons/
*/
declare const VERSION: string;
+
/**
* Hawk JavaScript Catcher
* Module for errors and exceptions tracking
@@ -92,6 +95,12 @@ export default class Catcher {
*/
private readonly disableVueErrorHandler: boolean = false;
+
+ /**
+ * Performance monitoring instance
+ */
+ private readonly performance: PerformanceMonitoring | null = null;
+
/**
* Catcher constructor
*
@@ -148,6 +157,29 @@ export default class Catcher {
if (settings.vue) {
this.connectVue(settings.vue);
}
+
+ if (settings.performance) {
+ const sampleRate = typeof settings.performance === 'object' && typeof settings.performance.sampleRate === 'number' ?
+ settings.performance.sampleRate :
+ undefined;
+
+ const batchInterval = typeof settings.performance === 'object' && typeof settings.performance.batchInterval === 'number' ?
+ settings.performance.batchInterval :
+ undefined;
+
+ const thresholdMs = typeof settings.performance === 'object' && typeof settings.performance.thresholdMs === 'number' ?
+ settings.performance.thresholdMs :
+ undefined;
+
+ this.performance = new PerformanceMonitoring(
+ this.transport,
+ this.token,
+ this.debug,
+ sampleRate,
+ batchInterval,
+ thresholdMs
+ );
+ }
}
/**
@@ -219,6 +251,27 @@ export default class Catcher {
});
}
+
+ /**
+ * Starts a new transaction
+ *
+ * @param name - Name of the transaction (e.g., 'page-load', 'api-request')
+ */
+ public startTransaction(name: string): Transaction | undefined {
+ if (this.performance === null) {
+ console.error('Hawk: can not start transaction. Performance monitoring is not enabled. Please enable it by setting performance: true in the HawkCatcher constructor.');
+ }
+
+ return this.performance?.startTransaction(name);
+ }
+
+ /**
+ * Clean up resources
+ */
+ public destroy(): void {
+ this.performance?.destroy();
+ }
+
/**
* Init global errors handler
*/
diff --git a/src/modules/performance/index.ts b/src/modules/performance/index.ts
new file mode 100644
index 0000000..2ea7289
--- /dev/null
+++ b/src/modules/performance/index.ts
@@ -0,0 +1,311 @@
+import type { PerformanceMessage } from '../../types/performance-message';
+import log from '../../utils/log';
+import type Socket from '../socket';
+import { Transaction } from './transaction';
+import type { AggregatedTransaction, AggregatedSpan } from '../../types/transaction';
+import type { Span } from './span';
+
+/**
+ * Default interval between batch sends in milliseconds
+ */
+const DEFAULT_BATCH_INTERVAL = 3000;
+
+/**
+ * Default sample rate for performance monitoring
+ * Value of 1.0 means all transactions will be sampled
+ */
+const DEFAULT_SAMPLE_RATE = 1.0;
+
+/**
+ * Default threshold in milliseconds for filtering out short transactions
+ * Transactions shorter than this duration will not be sent
+ */
+const DEFAULT_THRESHOLD_MS = 20;
+
+/**
+ * Default threshold in milliseconds for critical transactions
+ * Transactions longer than this duration will always be sent regardless of sampling
+ */
+const DEFAULT_CRITICAL_DURATION_THRESHOLD_MS = 500;
+
+/**
+ * Maximum number of retries for sending performance data
+ */
+const MAX_SEND_RETRIES = 3;
+
+/**
+ * Class for managing performance monitoring
+ */
+export default class PerformanceMonitoring {
+ /**
+ * Timer for batch sending
+ */
+ private batchTimeout: ReturnType | null = null;
+
+ /**
+ * Queue for transactions waiting to be sent
+ */
+ private sendQueue: Transaction[] = [];
+
+ /**
+ * Sample rate for performance monitoring
+ */
+ private readonly sampleRate: number;
+
+ /**
+ * Retry counter for failed send attempts
+ */
+ private sendRetries: Map = new Map();
+
+ /**
+ * @param transport - Transport instance for sending data
+ * @param token - Integration token
+ * @param debug - Debug mode flag
+ * @param sampleRate - Sample rate for performance data (0.0 to 1.0). Must be between 0 and 1.
+ * @param batchInterval - Interval between batch sends in milliseconds. Defaults to 3000ms.
+ * @param thresholdMs - Minimum duration threshold in milliseconds. Transactions shorter than this will be filtered out. Defaults to 20ms.
+ * @param criticalDurationThresholdMs - Duration threshold for critical transactions. Transactions longer than this will always be sent. Defaults to 500ms.
+ */
+ constructor(
+ private readonly transport: Socket,
+ private readonly token: string,
+ private readonly debug: boolean = false,
+ sampleRate: number = DEFAULT_SAMPLE_RATE,
+ private readonly batchInterval: number = DEFAULT_BATCH_INTERVAL,
+ private readonly thresholdMs: number = DEFAULT_THRESHOLD_MS,
+ private readonly criticalDurationThresholdMs: number = DEFAULT_CRITICAL_DURATION_THRESHOLD_MS
+ ) {
+ if (sampleRate < 0 || sampleRate > 1) {
+ console.error('Performance monitoring sample rate must be between 0 and 1');
+ sampleRate = 1;
+ }
+
+ this.sampleRate = Math.max(0, Math.min(1, sampleRate));
+ }
+
+ /**
+ * Queue transaction for sending
+ *
+ * @param transaction - Transaction to queue
+ */
+ public queueTransaction(transaction: Transaction): void {
+ this.sendQueue.push(transaction);
+
+ if (this.sendQueue.length === 1) {
+ this.scheduleBatchSend();
+ }
+ }
+
+ /**
+ * Starts a new transaction
+ *
+ * @param name - Transaction name
+ * @param severity - Severity of the transaction
+ * @returns Transaction object
+ */
+ public startTransaction(name: string, severity: 'default' | 'critical' = 'default'): Transaction {
+ const data = {
+ name,
+ severity,
+ };
+
+ return new Transaction(data, this, {
+ sampleRate: this.sampleRate,
+ thresholdMs: this.thresholdMs,
+ criticalDurationThresholdMs: this.criticalDurationThresholdMs,
+ });
+ }
+
+ /**
+ * Clean up resources and ensure all data is sent
+ */
+ public destroy(): void {
+ // Clear batch sending timer
+ if (this.batchTimeout !== null) {
+ clearTimeout(this.batchTimeout);
+ this.batchTimeout = null;
+ }
+
+ // Force send any remaining queued data
+ if (this.sendQueue.length > 0) {
+ void this.processSendQueue();
+ }
+ }
+
+ /**
+ * Schedule periodic batch sending of transactions
+ */
+ private scheduleBatchSend(): void {
+ this.batchTimeout = setTimeout(() => {
+ void this.processSendQueue();
+ }, this.batchInterval);
+ }
+
+ /**
+ * Process queued transactions in batch
+ */
+ private async processSendQueue(): Promise {
+ if (this.sendQueue.length === 0) {
+ return;
+ }
+
+ const transactions = [ ...this.sendQueue ];
+
+ this.sendQueue = [];
+
+ try {
+ const aggregatedTransactions = this.aggregateTransactions(transactions);
+
+ await this.sendPerformanceData(aggregatedTransactions);
+
+ // Clear retry counters for successful transactions
+ transactions.forEach(tx => this.sendRetries.delete(tx.id));
+ } catch (error) {
+ // Add transactions back to queue with retry limit
+ const retriedTransactions = transactions.filter(tx => {
+ const retryCount = (this.sendRetries.get(tx.id) || 0) + 1;
+
+ this.sendRetries.set(tx.id, retryCount);
+
+ const shouldRetry = retryCount <= MAX_SEND_RETRIES;
+
+ if (!shouldRetry && this.debug) {
+ log(`Performance Monitoring: Transaction ${tx.name} (${tx.id}) exceeded retry limit and will be dropped`, 'warn');
+ }
+
+ return shouldRetry;
+ });
+
+ this.sendQueue.push(...retriedTransactions);
+
+ if (this.debug) {
+ log('Failed to send performance data', 'error', error);
+ }
+ }
+ }
+
+ /**
+ * Aggregates transactions into statistical summaries grouped by name
+ *
+ * @param transactions
+ */
+ private aggregateTransactions(transactions: Transaction[]): AggregatedTransaction[] {
+ const transactionsByName = new Map();
+
+ // Group transactions by name
+ transactions.forEach(transaction => {
+ const group = transactionsByName.get(transaction.name) || [];
+
+ group.push(transaction);
+ transactionsByName.set(transaction.name, group);
+ });
+
+ // Aggregate each group
+ return Array.from(transactionsByName.entries()).map(([name, group]) => {
+ const durations = group.map(t => t.duration ?? 0).sort((a, b) => a - b);
+ const startTimes = group.map(t => t.startTime ?? 0);
+ const endTimes = group.map((transaction, index) => transaction.endTime ?? startTimes[index]);
+
+ // Calculate failure rate
+ const failureCount = group.filter(t => t.finishStatus === 'failure').length;
+ const failureRate = (failureCount / group.length) * 100;
+
+ return {
+ aggregationId: `${name}-${Date.now()}`,
+ name,
+ avgStartTime: this.average(startTimes),
+ minStartTime: Math.min(...startTimes),
+ maxEndTime: Math.max(...endTimes),
+ p50duration: this.percentile(durations, 50),
+ p95duration: this.percentile(durations, 95),
+ maxDuration: Math.max(...durations),
+ count: group.length,
+ failureRate,
+ aggregatedSpans: this.aggregateSpans(group),
+ };
+ });
+ }
+
+ /**
+ * Aggregates spans from multiple transactions into statistical summaries
+ * Groups spans by name across all transactions in the group
+ *
+ * @param transactions - Transactions containing spans to aggregate
+ * @returns Array of aggregated spans with statistical metrics
+ */
+ private aggregateSpans(transactions: Transaction[]): AggregatedSpan[] {
+ const spansByName = new Map();
+
+ // Group spans by name across all transactions
+ transactions.forEach(transaction => {
+ transaction.spans.forEach(span => {
+ const spans = spansByName.get(span.name) || [];
+
+ spans.push(span);
+ spansByName.set(span.name, spans);
+ });
+ });
+
+ // Aggregate each group of spans
+ return Array.from(spansByName.entries()).map(([name, spans]) => {
+ const durations = spans.map(s => s.duration ?? 0).sort((a, b) => a - b);
+ const startTimes = spans.map(s => s.startTime ?? 0);
+ const endTimes = spans.map((s, index) => s.endTime ?? startTimes[index]);
+
+ // Calculate failure rate for spans
+ const failureCount = spans.filter(s => s.status === 'failure').length;
+ const failureRate = (failureCount / spans.length) * 100;
+
+ return {
+ aggregationId: `${name}-${Date.now()}`,
+ name,
+ minStartTime: Math.min(...startTimes),
+ maxEndTime: Math.max(...endTimes),
+ p50duration: this.percentile(durations, 50),
+ p95duration: this.percentile(durations, 95),
+ maxDuration: Math.max(...durations),
+ failureRate,
+ };
+ });
+ }
+
+ /**
+ * Calculates the percentile value from a sorted array of numbers
+ *
+ * @param sortedValues - Sorted array of numbers
+ * @param p - Percentile to calculate (e.g., 50 for median, 95 for 95th percentile)
+ * @returns Percentile value
+ */
+ private percentile(sortedValues: number[], p: number): number {
+ const index = Math.ceil((p / 100) * sortedValues.length) - 1;
+
+ return sortedValues[index];
+ }
+
+ /**
+ * Calculates the average value from an array of numbers
+ *
+ * @param values - Array of numbers
+ * @returns Average value
+ */
+ private average(values: number[]): number {
+ return values.reduce((a, b) => a + b, 0) / values.length;
+ }
+
+ /**
+ * Sends performance data to Hawk collector
+ *
+ * @param transactions - Array of aggregated transactions to send
+ */
+ private async sendPerformanceData(transactions: AggregatedTransaction[]): Promise {
+ const performanceMessage: PerformanceMessage = {
+ token: this.token,
+ catcherType: 'performance',
+ payload: {
+ transactions,
+ },
+ };
+
+ await this.transport.send(performanceMessage);
+ }
+}
diff --git a/src/modules/performance/span.ts b/src/modules/performance/span.ts
new file mode 100644
index 0000000..3d8b88a
--- /dev/null
+++ b/src/modules/performance/span.ts
@@ -0,0 +1,77 @@
+import { id } from '../../utils/id';
+import { getTimestamp } from '../../utils/get-timestamp';
+/**
+ * Interface for data required to construct a Span
+ */
+interface SpanConstructionData {
+ /**
+ * ID of the transaction this span belongs to
+ */
+ transactionId: string;
+
+ /**
+ * Name of the span
+ */
+ name: string;
+}
+
+/**
+ * Class representing a span of work within a transaction
+ */
+export class Span {
+ /**
+ * Unique identifier for this span
+ */
+ public readonly id: string = id();
+
+ /**
+ * ID of the transaction this span belongs to
+ */
+ public readonly transactionId: string;
+
+ /**
+ * Name of the span
+ */
+ public readonly name: string;
+
+ /**
+ * Timestamp when the span started
+ */
+ public readonly startTime: number = getTimestamp();
+
+ /**
+ * Timestamp when the span ended
+ */
+ public endTime?: number;
+
+ /**
+ * Duration of the span in milliseconds
+ */
+ public duration?: number;
+
+ /**
+ * Status indicating whether the span completed successfully or failed
+ */
+ public status: 'success' | 'failure' = 'success';
+
+ /**
+ * Constructor for Span
+ *
+ * @param data - Data to initialize the span with. Contains id, transactionId, name, startTime, metadata
+ */
+ constructor(data: SpanConstructionData) {
+ this.transactionId = data.transactionId;
+ this.name = data.name;
+ }
+
+ /**
+ * Finishes the span and calculates its duration
+ *
+ * @param status - Status of the span ('success' or 'failure'). Defaults to 'success'
+ */
+ public finish(status: 'success' | 'failure' = 'success'): void {
+ this.endTime = getTimestamp();
+ this.duration = this.endTime - this.startTime;
+ this.status = status;
+ }
+}
diff --git a/src/modules/performance/transaction.ts b/src/modules/performance/transaction.ts
new file mode 100644
index 0000000..70c29a4
--- /dev/null
+++ b/src/modules/performance/transaction.ts
@@ -0,0 +1,169 @@
+import { getTimestamp } from '../../utils/get-timestamp';
+import type PerformanceMonitoring from '.';
+import { Span } from './span';
+import { id } from '../../utils/id';
+import type { PerformanceMonitoringConfig } from 'src/types/hawk-initial-settings';
+
+/**
+ * Interface representing data needed to construct a Transaction
+ */
+export interface TransactionConstructionData {
+ /**
+ * Name of the transaction (e.g. 'page-load', 'api-call')
+ */
+ name: string;
+
+ /**
+ * Severity level of the transaction
+ * - 'default': Normal transaction that might be sampled out
+ * - 'critical': High priority transaction that will be sent regardless of sampling rate
+ */
+ severity: 'default' | 'critical';
+}
+
+/**
+ * Class representing a transaction that can contain multiple spans
+ */
+/**
+ * Class representing a transaction that can contain multiple spans
+ */
+export class Transaction {
+ /**
+ * Unique identifier for this transaction
+ */
+ public readonly id: string = id();
+
+ /**
+ * Name of the transaction
+ */
+ public readonly name: string;
+
+ /**
+ * Timestamp when the transaction started
+ */
+ public readonly startTime: number = getTimestamp();
+
+ /**
+ * Timestamp when the transaction ended
+ */
+ public endTime?: number;
+
+ /**
+ * Duration of the transaction in milliseconds
+ */
+ public duration?: number;
+
+ /**
+ * Array of spans contained within this transaction
+ */
+ public readonly spans: Span[] = [];
+
+ /**
+ * Status indicating whether the transaction completed successfully or failed
+ */
+ public finishStatus: 'success' | 'failure' = 'success';
+
+ /**
+ * Severity level of the transaction
+ * - 'default': Normal transaction that might be sampled out
+ * - 'critical': High priority transaction that will be sent regardless of sampling rate
+ */
+ private severity: 'default' | 'critical' = 'default';
+
+ /**
+ * Constructor for Transaction
+ *
+ * @param data - Data to initialize the transaction with. Contains id, name, startTime, tags
+ * @param performance - Reference to the PerformanceMonitoring instance that created this transaction
+ * @param config - Configuration for this transaction
+ */
+ constructor(
+ data: TransactionConstructionData,
+ private readonly performance: PerformanceMonitoring,
+ private readonly config: Omit
+ ) {
+ this.name = data.name;
+ this.severity = data.severity;
+ }
+
+ /**
+ * Starts a new span within this transaction
+ *
+ * @param name - Name of the span
+ * @param metadata - Optional metadata to attach to the span
+ * @returns New span instance
+ */
+ public startSpan(name: string): Span {
+ const data = {
+ transactionId: this.id,
+ name,
+ };
+
+ const span = new Span(data);
+
+ this.spans.push(span);
+
+ return span;
+ }
+
+ /**
+ * Finishes the transaction and queues it for sending if:
+ * - It's a failure or has critical severity
+ * - Its duration is above the critical threshold
+ * - Its duration is above the threshold and passes sampling
+ *
+ * @param status - Status of the transaction ('success' or 'failure'). Defaults to 'success'
+ */
+ public finish(status: 'success' | 'failure' = 'success'): void {
+ this.endTime = getTimestamp();
+ this.duration = this.endTime - this.startTime;
+ this.finishStatus = status;
+
+ // Always send if it's a failure or critical severity
+ if (status === 'failure' || this.severity === 'critical') {
+ this.queueForSending();
+
+ return;
+ }
+
+ // Always send if duration exceeds critical threshold
+ if (this.duration >= this.config.criticalDurationThresholdMs) {
+ this.queueForSending();
+
+ return;
+ }
+
+ // Filter out short transactions
+ if (this.duration < this.config.thresholdMs) {
+ return;
+ }
+
+ // Apply sampling
+ if (this.shouldSample()) {
+ this.queueForSending();
+ }
+ }
+
+ /**
+ * Determines if this transaction should be sampled based on configured sample rate
+ *
+ * @returns True if transaction should be sampled, false otherwise
+ */
+ private shouldSample(): boolean {
+ return Math.random() <= this.config.sampleRate;
+ }
+
+ /**
+ * Queues this transaction for sending to the server
+ */
+ private queueForSending(): void {
+ this.performance.queueTransaction(this);
+ }
+
+ /**
+ *
+ */
+ public get status(): 'success' | 'failure' {
+ return this.finishStatus;
+ }
+}
diff --git a/src/modules/socket.ts b/src/modules/socket.ts
index c07af1d..ceda58a 100644
--- a/src/modules/socket.ts
+++ b/src/modules/socket.ts
@@ -1,6 +1,12 @@
+import type { PerformanceMessage } from 'src/types/performance-message';
import log from '../utils/log';
import type { CatcherMessage } from '@/types';
+/**
+ * Supported message types
+ */
+export type MessageType = 'errors/javascript' | 'performance';
+
/**
* Custom WebSocket wrapper class
*
@@ -31,7 +37,7 @@ export default class Socket {
* Queue of events collected while socket is not connected
* They will be sent when connection will be established
*/
- private eventsQueue: CatcherMessage[];
+ private eventsQueue: (CatcherMessage | PerformanceMessage)[];
/**
* Websocket instance
@@ -96,7 +102,7 @@ export default class Socket {
*
* @param message - event data in Hawk Format
*/
- public async send(message: CatcherMessage): Promise {
+ public async send(message: CatcherMessage | PerformanceMessage): Promise {
if (this.ws === null) {
this.eventsQueue.push(message);
diff --git a/src/modules/stackParser.ts b/src/modules/stackParser.ts
index 4352288..729ef8c 100644
--- a/src/modules/stackParser.ts
+++ b/src/modules/stackParser.ts
@@ -47,7 +47,7 @@ export default class StackParser {
try {
if (!frame.fileName) {
return null;
- };
+ }
if (!this.isValidUrl(frame.fileName)) {
return null;
diff --git a/src/types/hawk-initial-settings.ts b/src/types/hawk-initial-settings.ts
index 0cd7a59..70d410a 100644
--- a/src/types/hawk-initial-settings.ts
+++ b/src/types/hawk-initial-settings.ts
@@ -73,4 +73,44 @@ export interface HawkInitialSettings {
* Used by @hawk.so/nuxt since Nuxt has own error hook.
*/
disableVueErrorHandler?: boolean;
+
+ /**
+ * Performance monitoring settings
+ * - true to enable with default settings
+ * - {sampleRate: number} to enable with custom sample rate
+ * - false or undefined to disable
+ */
+ performance?: boolean | PerformanceMonitoringConfig;
+}
+
+export interface PerformanceMonitoringConfig {
+ /**
+ * Sample rate for performance data (0.0 to 1.0)
+ *
+ * @default 1.0
+ */
+ sampleRate: number;
+
+ /**
+ * Interval between batch sends in milliseconds
+ *
+ * @default 3000
+ */
+ batchInterval: number;
+
+ /**
+ * Minimum duration threshold in milliseconds.
+ * Transactions shorter than this will be filtered out.
+ *
+ * @default 100
+ */
+ thresholdMs: number;
+
+ /**
+ * Maximum duration threshold in milliseconds.
+ * Transactions with duration greather than this will not be samples out.
+ *
+ * @default 500
+ */
+ criticalDurationThresholdMs: number;
}
diff --git a/src/types/performance-message.ts b/src/types/performance-message.ts
new file mode 100644
index 0000000..d9c5aed
--- /dev/null
+++ b/src/types/performance-message.ts
@@ -0,0 +1,24 @@
+import type { EncodedIntegrationToken } from '@hawk.so/types';
+import type { AggregatedTransaction } from './transaction';
+
+/**
+ * Interface for performance monitoring message
+ */
+export interface PerformanceMessage {
+ /**
+ * Integration token
+ */
+ token: EncodedIntegrationToken;
+
+ /**
+ * Type of the catcher that sent this message
+ */
+ catcherType: 'performance';
+
+ /**
+ * Performance monitoring data
+ */
+ payload: {
+ transactions: AggregatedTransaction[];
+ };
+}
diff --git a/src/types/transaction.ts b/src/types/transaction.ts
new file mode 100644
index 0000000..b6414bb
--- /dev/null
+++ b/src/types/transaction.ts
@@ -0,0 +1,104 @@
+/**
+ * Interface representing aggregated statistics for a group of transactions with the same name
+ */
+export interface AggregatedTransaction {
+ /**
+ * Unique identifier for this aggregation, combining name and timestamp
+ */
+ aggregationId: string;
+
+ /**
+ * Name of the transactions being aggregated
+ */
+ name: string;
+
+ /**
+ * Average start time across all transactions in the group
+ */
+ avgStartTime: number;
+
+ /**
+ * Earliest start time among all transactions in the group
+ */
+ minStartTime: number;
+
+ /**
+ * Latest end time among all transactions in the group
+ */
+ maxEndTime: number;
+
+ /**
+ * 50th percentile (median) duration across all transactions
+ */
+ p50duration: number;
+
+ /**
+ * 95th percentile duration across all transactions
+ */
+ p95duration: number;
+
+ /**
+ * Maximum duration among all transactions
+ */
+ maxDuration: number;
+
+ /**
+ * Total number of transactions in this group
+ */
+ count: number;
+
+ /**
+ * Percentage of transactions that failed
+ */
+ failureRate: number;
+
+ /**
+ * Array of aggregated statistics for spans within these transactions
+ */
+ aggregatedSpans: AggregatedSpan[];
+}
+
+/**
+ * Interface representing aggregated statistics for a group of spans with the same name
+ */
+export interface AggregatedSpan {
+ /**
+ * Unique identifier for this span aggregation
+ */
+ aggregationId: string;
+
+ /**
+ * Name of the spans being aggregated
+ */
+ name: string;
+
+ /**
+ * Earliest start time among all spans in the group
+ */
+ minStartTime: number;
+
+ /**
+ * Latest end time among all spans in the group
+ */
+ maxEndTime: number;
+
+ /**
+ * 50th percentile (median) duration across all spans
+ */
+ p50duration: number;
+
+ /**
+ * 95th percentile duration across all spans
+ */
+ p95duration: number;
+
+ /**
+ * Maximum duration among all spans
+ */
+ maxDuration: number;
+
+ /**
+ * Percentage of spans that failed
+ */
+ failureRate: number;
+}
diff --git a/src/utils/get-timestamp.ts b/src/utils/get-timestamp.ts
new file mode 100644
index 0000000..6f64487
--- /dev/null
+++ b/src/utils/get-timestamp.ts
@@ -0,0 +1,16 @@
+import { isBrowser } from './is-browser';
+
+/**
+ * Get high-resolution timestamp in milliseconds
+ */
+export function getTimestamp(): number {
+ if (isBrowser) {
+ return performance.now();
+ }
+
+ /**
+ * process.hrtime.bigint() returns nanoseconds
+ * Convert to milliseconds for consistency with browser
+ */
+ return Number(process.hrtime.bigint() / BigInt(1_000_000));
+};
diff --git a/src/utils/is-browser.ts b/src/utils/is-browser.ts
new file mode 100644
index 0000000..b0b5638
--- /dev/null
+++ b/src/utils/is-browser.ts
@@ -0,0 +1,4 @@
+/**
+ * Check if code is running in browser environment
+ */
+export const isBrowser = typeof window !== 'undefined';
diff --git a/tsconfig.json b/tsconfig.json
index 9624c27..f91e093 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions" : {
+ "module": "NodeNext",
"strict": false,
"strictNullChecks": true,
"sourceMap": true,
@@ -7,7 +8,6 @@
"declaration": true,
"outDir": "dist",
"rootDir": "src",
- "module": "NodeNext",
"moduleResolution": "nodenext",
"lib": ["dom", "es2017", "es2018"],
"baseUrl": ".",