diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..54ea4d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Test coverage +coverage/ + +# Logs +*.log +npm-debug.log* + +# Environment +.env +.env.local + +# Package lock (use package-lock.json or yarn.lock, not both) +yarn.lock diff --git a/README.md b/README.md index 489acb2..6a467b3 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,197 @@ -# irontelemetry-js -IronTelemetry SDK for JavaScript/TypeScript - Error monitoring and crash reporting +# IronTelemetry SDK for JavaScript/TypeScript + +Error monitoring and crash reporting SDK for JavaScript and TypeScript applications. Capture exceptions, track user journeys, and get insights to fix issues faster. + +[![npm](https://img.shields.io/npm/v/@ironservices/telemetry.svg)](https://www.npmjs.com/package/@ironservices/telemetry) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +## Installation + +```bash +npm install @ironservices/telemetry +# or +yarn add @ironservices/telemetry +# or +pnpm add @ironservices/telemetry +``` + +## Quick Start + +### Basic Exception Capture + +```typescript +import IronTelemetry from '@ironservices/telemetry'; + +// Initialize with your DSN +IronTelemetry.init('https://pk_live_xxx@irontelemetry.com'); + +// Capture exceptions +try { + doSomething(); +} catch (error) { + IronTelemetry.captureException(error); + throw error; +} + +// Or use the extension method +try { + doSomething(); +} catch (error) { + throw error.capture(); +} +``` + +### Journey Tracking + +Track user journeys to understand the context of errors: + +```typescript +import IronTelemetry from '@ironservices/telemetry'; + +// Track a complete user journey +{ + using journey = IronTelemetry.startJourney('Checkout Flow'); + + IronTelemetry.setUser('user-123', 'user@example.com'); + + { + using step = IronTelemetry.startStep('Validate Cart', 'business'); + validateCart(); + } + + { + using step = IronTelemetry.startStep('Process Payment', 'business'); + processPayment(); + } + + { + using step = IronTelemetry.startStep('Send Confirmation', 'notification'); + sendConfirmationEmail(); + } +} +``` + +Any exceptions captured during the journey are automatically correlated. + +## Configuration + +```typescript +import IronTelemetry from '@ironservices/telemetry'; + +IronTelemetry.init({ + dsn: 'https://pk_live_xxx@irontelemetry.com', + environment: 'production', + appVersion: '1.2.3', + sampleRate: 1.0, // 100% of events + debug: false, + beforeSend: (event) => !event.message?.includes('expected error'), +}); +``` + +### Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `dsn` | string | required | Your Data Source Name | +| `environment` | string | 'production' | Environment name | +| `appVersion` | string | '0.0.0' | Application version | +| `sampleRate` | number | 1.0 | Sample rate (0.0 to 1.0) | +| `maxBreadcrumbs` | number | 100 | Max breadcrumbs to keep | +| `debug` | boolean | false | Enable debug logging | +| `beforeSend` | function | - | Hook to filter/modify events | +| `enableOfflineQueue` | boolean | true | Enable offline queue | +| `maxOfflineQueueSize` | number | 500 | Max offline queue size | + +## Features + +- **Automatic Exception Capture**: Capture and report exceptions with full stack traces +- **Journey Tracking**: Track user flows and correlate errors with context +- **Breadcrumbs**: Leave a trail of events leading up to an error +- **User Context**: Associate errors with specific users +- **Tags & Extras**: Add custom metadata to your events +- **Offline Queue**: Events are queued when offline and sent when connectivity returns +- **Sample Rate**: Control the volume of events sent +- **Before Send Hook**: Filter or modify events before sending + +## Breadcrumbs + +```typescript +// Add breadcrumbs to understand what happened before an error +IronTelemetry.addBreadcrumb('User clicked checkout button', 'ui'); +IronTelemetry.addBreadcrumb('Payment API called', 'http'); + +// Or with full control +IronTelemetry.addBreadcrumb({ + category: 'auth', + message: 'User logged in', + level: 'info', + data: { userId: '123' }, +}); +``` + +## Global Exception Handling + +```typescript +import IronTelemetry, { useUnhandledExceptionHandler } from '@ironservices/telemetry'; + +IronTelemetry.init('your-dsn'); +useUnhandledExceptionHandler(); +``` + +This sets up handlers for: +- Browser: `window.onerror` and `unhandledrejection` +- Node.js: `uncaughtException` and `unhandledRejection` + +## Helper Methods + +```typescript +import { trackStep, trackStepAsync } from '@ironservices/telemetry'; + +// Track a step with automatic error handling +trackStep('Process Order', () => { + processOrder(); +}); + +// Async version +await trackStepAsync('Fetch Data', async () => { + await fetchData(); +}); + +// With return value +const result = trackStep('Calculate Total', () => { + return calculateTotal(); +}); +``` + +## Flushing + +```typescript +// Flush pending events before app shutdown +await IronTelemetry.flush(); +``` + +## TypeScript Support + +This package is written in TypeScript and includes full type definitions: + +```typescript +import IronTelemetry, { + TelemetryOptions, + TelemetryEvent, + Breadcrumb, + SeverityLevel +} from '@ironservices/telemetry'; +``` + +## Browser Support + +Works in all modern browsers (Chrome, Firefox, Safari, Edge) and Node.js 16+. + +## Links + +- [Documentation](https://www.irontelemetry.com/docs) +- [Dashboard](https://www.irontelemetry.com) + +## License + +MIT License - see [LICENSE](LICENSE) for details. diff --git a/package.json b/package.json new file mode 100644 index 0000000..a2e8e5b --- /dev/null +++ b/package.json @@ -0,0 +1,57 @@ +{ + "name": "@ironservices/telemetry", + "version": "0.1.0", + "description": "Error monitoring and crash reporting SDK for JavaScript/TypeScript applications", + "main": "dist/cjs/index.js", + "module": "dist/esm/index.js", + "types": "dist/types/index.d.ts", + "exports": { + ".": { + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js", + "types": "./dist/types/index.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "npm run build:esm && npm run build:cjs && npm run build:types", + "build:esm": "tsc -p tsconfig.esm.json", + "build:cjs": "tsc -p tsconfig.cjs.json", + "build:types": "tsc -p tsconfig.types.json", + "test": "vitest run", + "test:watch": "vitest", + "lint": "eslint src --ext .ts", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "error-monitoring", + "crash-reporting", + "telemetry", + "exception-tracking", + "irontelemetry", + "ironservices" + ], + "author": "IronServices", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/IronServices/irontelemetry-js.git" + }, + "bugs": { + "url": "https://github.com/IronServices/irontelemetry-js/issues" + }, + "homepage": "https://www.irontelemetry.com", + "devDependencies": { + "@types/node": "^20.10.0", + "typescript": "^5.3.0", + "vitest": "^1.0.0", + "eslint": "^8.55.0", + "@typescript-eslint/eslint-plugin": "^6.13.0", + "@typescript-eslint/parser": "^6.13.0" + }, + "engines": { + "node": ">=16.0.0" + } +} diff --git a/src/breadcrumbs.ts b/src/breadcrumbs.ts new file mode 100644 index 0000000..2808118 --- /dev/null +++ b/src/breadcrumbs.ts @@ -0,0 +1,75 @@ +import type { Breadcrumb, BreadcrumbCategory, SeverityLevel } from './types'; + +/** + * Manages breadcrumbs for an SDK instance + */ +export class BreadcrumbManager { + private readonly maxBreadcrumbs: number; + private breadcrumbs: Breadcrumb[] = []; + + constructor(maxBreadcrumbs: number = 100) { + this.maxBreadcrumbs = maxBreadcrumbs; + } + + /** + * Add a breadcrumb + */ + add( + message: string, + category: BreadcrumbCategory = 'custom', + level: SeverityLevel = 'info', + data?: Record + ): void { + const breadcrumb: Breadcrumb = { + timestamp: new Date(), + category, + message, + level, + data, + }; + + this.breadcrumbs.push(breadcrumb); + + // Trim to max size + if (this.breadcrumbs.length > this.maxBreadcrumbs) { + this.breadcrumbs = this.breadcrumbs.slice(-this.maxBreadcrumbs); + } + } + + /** + * Add a breadcrumb from a full Breadcrumb object + */ + addBreadcrumb(breadcrumb: Omit & { timestamp?: Date }): void { + const fullBreadcrumb: Breadcrumb = { + ...breadcrumb, + timestamp: breadcrumb.timestamp ?? new Date(), + }; + + this.breadcrumbs.push(fullBreadcrumb); + + if (this.breadcrumbs.length > this.maxBreadcrumbs) { + this.breadcrumbs = this.breadcrumbs.slice(-this.maxBreadcrumbs); + } + } + + /** + * Get all breadcrumbs + */ + getAll(): Breadcrumb[] { + return [...this.breadcrumbs]; + } + + /** + * Clear all breadcrumbs + */ + clear(): void { + this.breadcrumbs = []; + } + + /** + * Get the number of breadcrumbs + */ + get count(): number { + return this.breadcrumbs.length; + } +} diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..97661ad --- /dev/null +++ b/src/client.ts @@ -0,0 +1,335 @@ +import type { + TelemetryOptions, + TelemetryEvent, + SeverityLevel, + Breadcrumb, + BreadcrumbCategory, + User, + PlatformInfo, + ExceptionInfo, + StackFrame, + SendResult, +} from './types'; +import { resolveOptions, generateEventId } from './config'; +import { Transport } from './transport'; +import { OfflineQueue } from './queue'; +import { BreadcrumbManager } from './breadcrumbs'; +import { Journey, JourneyScope, Step, StepScope } from './journey'; + +/** + * Main IronTelemetry client class + */ +export class TelemetryClient { + private readonly options: ReturnType; + private readonly transport: Transport; + private readonly queue: OfflineQueue | null; + private readonly breadcrumbs: BreadcrumbManager; + + private tags: Record = {}; + private extra: Record = {}; + private user?: User; + private currentJourney?: Journey; + private flushInterval?: ReturnType; + private isInitialized: boolean = false; + + constructor(options: TelemetryOptions) { + this.options = resolveOptions(options); + this.transport = new Transport(this.options.parsedDsn, this.options.apiBaseUrl, this.options.debug); + this.queue = this.options.enableOfflineQueue + ? new OfflineQueue(this.options.maxOfflineQueueSize, this.options.debug) + : null; + this.breadcrumbs = new BreadcrumbManager(this.options.maxBreadcrumbs); + this.isInitialized = true; + + // Start flush interval for offline queue + if (this.queue) { + this.flushInterval = setInterval(() => this.processQueue(), 30000); + } + + if (this.options.debug) { + console.log('[IronTelemetry] Initialized with DSN:', this.options.dsn); + } + } + + /** + * Capture an exception + */ + async captureException(error: Error | unknown, extra?: Record): Promise { + const exception = this.parseException(error); + const event = this.createEvent('error', exception.message, exception); + + if (extra) { + event.extra = { ...event.extra, ...extra }; + } + + return this.sendEvent(event); + } + + /** + * Capture a message + */ + async captureMessage(message: string, level: SeverityLevel = 'info'): Promise { + const event = this.createEvent(level, message); + return this.sendEvent(event); + } + + /** + * Add a breadcrumb + */ + addBreadcrumb( + message: string, + category: BreadcrumbCategory = 'custom', + level?: SeverityLevel, + data?: Record + ): void; + addBreadcrumb(breadcrumb: Omit): void; + addBreadcrumb( + messageOrBreadcrumb: string | Omit, + category?: BreadcrumbCategory, + level?: SeverityLevel, + data?: Record + ): void { + if (typeof messageOrBreadcrumb === 'string') { + this.breadcrumbs.add(messageOrBreadcrumb, category, level, data); + } else { + this.breadcrumbs.addBreadcrumb(messageOrBreadcrumb); + } + } + + /** + * Set user context + */ + setUser(id: string, email?: string, data?: Record): void { + this.user = { id, email, data }; + } + + /** + * Clear user context + */ + clearUser(): void { + this.user = undefined; + } + + /** + * Set a tag + */ + setTag(key: string, value: string): void { + this.tags[key] = value; + } + + /** + * Set extra context + */ + setExtra(key: string, value: unknown): void { + this.extra[key] = value; + } + + /** + * Start a new journey + */ + startJourney(name: string): JourneyScope { + this.currentJourney = new Journey(name); + + // Copy user context to journey + if (this.user) { + this.currentJourney.setUser(this.user.id, this.user.email, this.user.data); + } + + return new JourneyScope(this.currentJourney, () => { + this.currentJourney = undefined; + }); + } + + /** + * Start a step in the current journey + */ + startStep(name: string, category?: string): StepScope { + if (!this.currentJourney) { + throw new Error('No active journey. Call startJourney() first.'); + } + + const step = this.currentJourney.startStep(name, category); + return new StepScope(step); + } + + /** + * Flush pending events + */ + async flush(): Promise { + await this.processQueue(); + } + + /** + * Close the client + */ + close(): void { + if (this.flushInterval) { + clearInterval(this.flushInterval); + } + } + + /** + * Create a telemetry event + */ + private createEvent( + level: SeverityLevel, + message?: string, + exception?: ExceptionInfo + ): TelemetryEvent { + const event: TelemetryEvent = { + eventId: generateEventId(), + timestamp: new Date(), + level, + message, + exception, + user: this.currentJourney?.getUser() ?? this.user, + tags: { ...this.tags }, + extra: { ...this.extra }, + breadcrumbs: this.breadcrumbs.getAll(), + journey: this.currentJourney?.getContext(), + environment: this.options.environment, + appVersion: this.options.appVersion, + platform: this.getPlatformInfo(), + }; + + return event; + } + + /** + * Send an event + */ + private async sendEvent(event: TelemetryEvent): Promise { + // Check sample rate + if (Math.random() > this.options.sampleRate) { + if (this.options.debug) { + console.log('[IronTelemetry] Event dropped due to sample rate'); + } + return { success: true, eventId: event.eventId }; + } + + // Apply beforeSend hook + const beforeSendResult = this.options.beforeSend(event); + if (beforeSendResult === false) { + if (this.options.debug) { + console.log('[IronTelemetry] Event dropped by beforeSend hook'); + } + return { success: true, eventId: event.eventId }; + } + + const eventToSend = beforeSendResult === true ? event : beforeSendResult; + + // Try to send + const result = await this.transport.send(eventToSend); + + if (!result.success && this.queue) { + this.queue.enqueue(eventToSend); + return { ...result, queued: true }; + } + + return result; + } + + /** + * Process offline queue + */ + private async processQueue(): Promise { + if (!this.queue || this.queue.isEmpty) { + return; + } + + const isOnline = await this.transport.isOnline(); + if (!isOnline) { + return; + } + + const events = this.queue.getAll(); + + for (const event of events) { + const result = await this.transport.send(event); + if (result.success) { + this.queue.remove(event.eventId); + } + } + } + + /** + * Parse an error into exception info + */ + private parseException(error: Error | unknown): ExceptionInfo { + if (error instanceof Error) { + return { + type: error.name || 'Error', + message: error.message, + stacktrace: this.parseStackTrace(error.stack), + }; + } + + return { + type: 'Error', + message: String(error), + }; + } + + /** + * Parse a stack trace string into frames + */ + private parseStackTrace(stack?: string): StackFrame[] | undefined { + if (!stack) return undefined; + + const frames: StackFrame[] = []; + const lines = stack.split('\n'); + + for (const line of lines) { + // Chrome/Node format: " at functionName (filename:line:column)" + const chromeMatch = line.match(/^\s*at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?$/); + if (chromeMatch) { + frames.push({ + function: chromeMatch[1] || '', + filename: chromeMatch[2], + lineno: parseInt(chromeMatch[3], 10), + colno: parseInt(chromeMatch[4], 10), + }); + continue; + } + + // Firefox format: "functionName@filename:line:column" + const firefoxMatch = line.match(/^(.*)@(.+?):(\d+):(\d+)$/); + if (firefoxMatch) { + frames.push({ + function: firefoxMatch[1] || '', + filename: firefoxMatch[2], + lineno: parseInt(firefoxMatch[3], 10), + colno: parseInt(firefoxMatch[4], 10), + }); + } + } + + return frames.length > 0 ? frames : undefined; + } + + /** + * Get platform information + */ + private getPlatformInfo(): PlatformInfo { + // Check for browser environment + if (typeof window !== 'undefined' && typeof navigator !== 'undefined') { + return { + name: 'browser', + userAgent: navigator.userAgent, + }; + } + + // Check for Node.js environment + if (typeof process !== 'undefined' && process.versions?.node) { + return { + name: 'node', + version: process.versions.node, + os: process.platform, + }; + } + + return { + name: 'unknown', + }; + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..fc85a89 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,72 @@ +import type { TelemetryOptions, ParsedDsn } from './types'; + +/** + * Default configuration values + */ +export const DEFAULT_OPTIONS: Partial = { + sampleRate: 1.0, + maxBreadcrumbs: 100, + debug: false, + enableOfflineQueue: true, + maxOfflineQueueSize: 500, +}; + +/** + * Parse a DSN string into its components + * Format: https://pk_live_xxx@irontelemetry.com + */ +export function parseDsn(dsn: string): ParsedDsn { + try { + const url = new URL(dsn); + const publicKey = url.username; + + if (!publicKey || !publicKey.startsWith('pk_')) { + throw new Error('DSN must contain a valid public key starting with pk_'); + } + + const protocol = url.protocol.replace(':', ''); + const host = url.host; + + return { + publicKey, + host, + protocol, + apiBaseUrl: `${protocol}://${host}`, + }; + } catch (error) { + if (error instanceof Error && error.message.includes('pk_')) { + throw error; + } + throw new Error(`Invalid DSN format: ${dsn}`); + } +} + +/** + * Validate and merge options with defaults + */ +export function resolveOptions(options: TelemetryOptions): Required & { parsedDsn: ParsedDsn } { + const parsedDsn = parseDsn(options.dsn); + + return { + dsn: options.dsn, + environment: options.environment ?? 'production', + appVersion: options.appVersion ?? '0.0.0', + sampleRate: Math.max(0, Math.min(1, options.sampleRate ?? DEFAULT_OPTIONS.sampleRate!)), + maxBreadcrumbs: options.maxBreadcrumbs ?? DEFAULT_OPTIONS.maxBreadcrumbs!, + debug: options.debug ?? DEFAULT_OPTIONS.debug!, + beforeSend: options.beforeSend ?? (() => true), + enableOfflineQueue: options.enableOfflineQueue ?? DEFAULT_OPTIONS.enableOfflineQueue!, + maxOfflineQueueSize: options.maxOfflineQueueSize ?? DEFAULT_OPTIONS.maxOfflineQueueSize!, + apiBaseUrl: options.apiBaseUrl ?? parsedDsn.apiBaseUrl, + parsedDsn, + }; +} + +/** + * Generate a unique event ID + */ +export function generateEventId(): string { + const timestamp = Date.now().toString(36); + const random = Math.random().toString(36).substring(2, 15); + return `${timestamp}-${random}`; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..0731300 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,293 @@ +import { TelemetryClient } from './client'; +import type { + TelemetryOptions, + TelemetryEvent, + SeverityLevel, + Breadcrumb, + BreadcrumbCategory, + User, + SendResult, +} from './types'; +import { JourneyScope, StepScope } from './journey'; + +// Export types +export type { + TelemetryOptions, + TelemetryEvent, + SeverityLevel, + Breadcrumb, + BreadcrumbCategory, + User, + SendResult, + ExceptionInfo, + StackFrame, + PlatformInfo, + JourneyContext, + JourneyStep, + ParsedDsn, +} from './types'; + +// Export classes +export { TelemetryClient } from './client'; +export { Journey, JourneyScope, Step, StepScope } from './journey'; + +// Global client instance +let globalClient: TelemetryClient | null = null; + +/** + * Initialize the global IronTelemetry client + */ +export function init(optionsOrDsn: TelemetryOptions | string): TelemetryClient { + const options = typeof optionsOrDsn === 'string' ? { dsn: optionsOrDsn } : optionsOrDsn; + globalClient = new TelemetryClient(options); + return globalClient; +} + +/** + * Get the global client instance + */ +export function getClient(): TelemetryClient | null { + return globalClient; +} + +/** + * Capture an exception using the global client + */ +export async function captureException( + error: Error | unknown, + extra?: Record +): Promise { + if (!globalClient) { + console.warn('[IronTelemetry] Client not initialized. Call init() first.'); + return { success: false, error: 'Client not initialized' }; + } + return globalClient.captureException(error, extra); +} + +/** + * Capture a message using the global client + */ +export async function captureMessage( + message: string, + level: SeverityLevel = 'info' +): Promise { + if (!globalClient) { + console.warn('[IronTelemetry] Client not initialized. Call init() first.'); + return { success: false, error: 'Client not initialized' }; + } + return globalClient.captureMessage(message, level); +} + +/** + * Add a breadcrumb using the global client + */ +export function addBreadcrumb( + message: string, + category?: BreadcrumbCategory, + level?: SeverityLevel, + data?: Record +): void; +export function addBreadcrumb(breadcrumb: Omit): void; +export function addBreadcrumb( + messageOrBreadcrumb: string | Omit, + category?: BreadcrumbCategory, + level?: SeverityLevel, + data?: Record +): void { + if (!globalClient) { + console.warn('[IronTelemetry] Client not initialized. Call init() first.'); + return; + } + + if (typeof messageOrBreadcrumb === 'string') { + globalClient.addBreadcrumb(messageOrBreadcrumb, category, level, data); + } else { + globalClient.addBreadcrumb(messageOrBreadcrumb); + } +} + +/** + * Set user context using the global client + */ +export function setUser(id: string, email?: string, data?: Record): void { + if (!globalClient) { + console.warn('[IronTelemetry] Client not initialized. Call init() first.'); + return; + } + globalClient.setUser(id, email, data); +} + +/** + * Clear user context using the global client + */ +export function clearUser(): void { + if (!globalClient) { + return; + } + globalClient.clearUser(); +} + +/** + * Set a tag using the global client + */ +export function setTag(key: string, value: string): void { + if (!globalClient) { + console.warn('[IronTelemetry] Client not initialized. Call init() first.'); + return; + } + globalClient.setTag(key, value); +} + +/** + * Set extra context using the global client + */ +export function setExtra(key: string, value: unknown): void { + if (!globalClient) { + console.warn('[IronTelemetry] Client not initialized. Call init() first.'); + return; + } + globalClient.setExtra(key, value); +} + +/** + * Start a journey using the global client + */ +export function startJourney(name: string): JourneyScope { + if (!globalClient) { + throw new Error('[IronTelemetry] Client not initialized. Call init() first.'); + } + return globalClient.startJourney(name); +} + +/** + * Start a step in the current journey using the global client + */ +export function startStep(name: string, category?: string): StepScope { + if (!globalClient) { + throw new Error('[IronTelemetry] Client not initialized. Call init() first.'); + } + return globalClient.startStep(name, category); +} + +/** + * Flush pending events using the global client + */ +export async function flush(): Promise { + if (!globalClient) { + return; + } + await globalClient.flush(); +} + +/** + * Close the global client + */ +export function close(): void { + if (globalClient) { + globalClient.close(); + globalClient = null; + } +} + +// Default export for convenience +const IronTelemetry = { + init, + getClient, + captureException, + captureMessage, + addBreadcrumb, + setUser, + clearUser, + setTag, + setExtra, + startJourney, + startStep, + flush, + close, +}; + +export default IronTelemetry; + +// Extension for Error prototype +declare global { + interface Error { + capture(): Error; + } +} + +/** + * Capture this error and return it for re-throwing + */ +Error.prototype.capture = function (): Error { + captureException(this); + return this; +}; + +/** + * Set up global unhandled exception handler + */ +export function useUnhandledExceptionHandler(): void { + if (typeof window !== 'undefined') { + // Browser + window.addEventListener('error', (event) => { + captureException(event.error ?? new Error(event.message)); + }); + + window.addEventListener('unhandledrejection', (event) => { + captureException(event.reason ?? new Error('Unhandled Promise rejection')); + }); + } else if (typeof process !== 'undefined') { + // Node.js + process.on('uncaughtException', (error) => { + captureException(error); + }); + + process.on('unhandledRejection', (reason) => { + captureException(reason ?? new Error('Unhandled Promise rejection')); + }); + } +} + +/** + * Track a step with automatic error handling + */ +export function trackStep(name: string, fn: () => T, category?: string): T { + if (!globalClient) { + return fn(); + } + + const step = startStep(name, category); + + try { + const result = fn(); + step[Symbol.dispose](); + return result; + } catch (error) { + step.getStep().fail(); + throw error; + } +} + +/** + * Track an async step with automatic error handling + */ +export async function trackStepAsync( + name: string, + fn: () => Promise, + category?: string +): Promise { + if (!globalClient) { + return fn(); + } + + const step = startStep(name, category); + + try { + const result = await fn(); + step[Symbol.dispose](); + return result; + } catch (error) { + step.getStep().fail(); + throw error; + } +} diff --git a/src/journey.ts b/src/journey.ts new file mode 100644 index 0000000..8f21b9d --- /dev/null +++ b/src/journey.ts @@ -0,0 +1,235 @@ +import type { JourneyContext, JourneyStep, User } from './types'; +import { generateEventId } from './config'; + +/** + * Represents an active journey tracking session + */ +export class Journey { + private readonly id: string; + private readonly name: string; + private readonly startedAt: Date; + private metadata: Record = {}; + private user?: User; + private steps: JourneyStep[] = []; + private currentStep?: JourneyStep; + private _completed: boolean = false; + private _failed: boolean = false; + + constructor(name: string) { + this.id = generateEventId(); + this.name = name; + this.startedAt = new Date(); + } + + /** + * Set user context for this journey + */ + setUser(id: string, email?: string, data?: Record): this { + this.user = { id, email, data }; + return this; + } + + /** + * Set metadata for this journey + */ + setMetadata(key: string, value: unknown): this { + this.metadata[key] = value; + return this; + } + + /** + * Start a new step in this journey + */ + startStep(name: string, category?: string): Step { + // Complete any existing step + if (this.currentStep && this.currentStep.status === 'in_progress') { + this.currentStep.status = 'completed'; + this.currentStep.endedAt = new Date(); + } + + const step: JourneyStep = { + name, + category, + startedAt: new Date(), + status: 'in_progress', + data: {}, + }; + + this.steps.push(step); + this.currentStep = step; + + return new Step(step, this); + } + + /** + * Mark the journey as completed + */ + complete(): void { + if (this.currentStep && this.currentStep.status === 'in_progress') { + this.currentStep.status = 'completed'; + this.currentStep.endedAt = new Date(); + } + this._completed = true; + } + + /** + * Mark the journey as failed + */ + fail(): void { + if (this.currentStep && this.currentStep.status === 'in_progress') { + this.currentStep.status = 'failed'; + this.currentStep.endedAt = new Date(); + } + this._failed = true; + } + + /** + * Get the journey context for an event + */ + getContext(): JourneyContext { + return { + journeyId: this.id, + name: this.name, + currentStep: this.currentStep?.name, + startedAt: this.startedAt, + metadata: this.metadata, + }; + } + + /** + * Get the user context for this journey + */ + getUser(): User | undefined { + return this.user; + } + + /** + * Check if the journey is complete + */ + get isComplete(): boolean { + return this._completed || this._failed; + } + + /** + * Get journey ID + */ + get journeyId(): string { + return this.id; + } +} + +/** + * Represents a step within a journey + */ +export class Step { + private readonly step: JourneyStep; + private readonly journey: Journey; + + constructor(step: JourneyStep, journey: Journey) { + this.step = step; + this.journey = journey; + } + + /** + * Set data for this step + */ + setData(key: string, value: unknown): this { + this.step.data[key] = value; + return this; + } + + /** + * Mark the step as completed + */ + complete(): void { + this.step.status = 'completed'; + this.step.endedAt = new Date(); + } + + /** + * Mark the step as failed + */ + fail(): void { + this.step.status = 'failed'; + this.step.endedAt = new Date(); + } + + /** + * Get the step name + */ + get name(): string { + return this.step.name; + } + + /** + * Get the parent journey + */ + getJourney(): Journey { + return this.journey; + } +} + +/** + * Journey scope that auto-completes on disposal + */ +export class JourneyScope { + private readonly journey: Journey; + private readonly onComplete?: () => void; + + constructor(journey: Journey, onComplete?: () => void) { + this.journey = journey; + this.onComplete = onComplete; + } + + /** + * Get the underlying journey + */ + getJourney(): Journey { + return this.journey; + } + + /** + * Dispose of the journey scope + */ + [Symbol.dispose](): void { + if (!this.journey.isComplete) { + this.journey.complete(); + } + this.onComplete?.(); + } +} + +/** + * Step scope that auto-completes on disposal + */ +export class StepScope { + private readonly step: Step; + + constructor(step: Step) { + this.step = step; + } + + /** + * Get the underlying step + */ + getStep(): Step { + return this.step; + } + + /** + * Set data on the step + */ + setData(key: string, value: unknown): this { + this.step.setData(key, value); + return this; + } + + /** + * Dispose of the step scope + */ + [Symbol.dispose](): void { + if (this.step['step'].status === 'in_progress') { + this.step.complete(); + } + } +} diff --git a/src/queue.ts b/src/queue.ts new file mode 100644 index 0000000..86564f5 --- /dev/null +++ b/src/queue.ts @@ -0,0 +1,129 @@ +import type { TelemetryEvent } from './types'; + +const STORAGE_KEY = 'irontelemetry_queue'; + +/** + * Offline queue for storing events when the network is unavailable + */ +export class OfflineQueue { + private readonly maxSize: number; + private readonly debug: boolean; + private queue: TelemetryEvent[] = []; + + constructor(maxSize: number = 500, debug: boolean = false) { + this.maxSize = maxSize; + this.debug = debug; + this.load(); + } + + /** + * Add an event to the queue + */ + enqueue(event: TelemetryEvent): void { + if (this.queue.length >= this.maxSize) { + // Remove oldest events to make room + this.queue.shift(); + if (this.debug) { + console.log('[IronTelemetry] Queue full, dropping oldest event'); + } + } + + this.queue.push(event); + this.save(); + + if (this.debug) { + console.log('[IronTelemetry] Event queued, queue size:', this.queue.length); + } + } + + /** + * Get all queued events + */ + getAll(): TelemetryEvent[] { + return [...this.queue]; + } + + /** + * Remove an event from the queue + */ + remove(eventId: string): void { + this.queue = this.queue.filter((e) => e.eventId !== eventId); + this.save(); + } + + /** + * Clear all queued events + */ + clear(): void { + this.queue = []; + this.save(); + } + + /** + * Get the number of queued events + */ + get size(): number { + return this.queue.length; + } + + /** + * Check if the queue is empty + */ + get isEmpty(): boolean { + return this.queue.length === 0; + } + + /** + * Load queue from persistent storage + */ + private load(): void { + try { + if (typeof localStorage !== 'undefined') { + const data = localStorage.getItem(STORAGE_KEY); + if (data) { + const parsed = JSON.parse(data); + this.queue = parsed.map((e: Record) => this.deserializeEvent(e)); + } + } + } catch (error) { + if (this.debug) { + console.error('[IronTelemetry] Failed to load queue from storage:', error); + } + } + } + + /** + * Save queue to persistent storage + */ + private save(): void { + try { + if (typeof localStorage !== 'undefined') { + localStorage.setItem(STORAGE_KEY, JSON.stringify(this.queue)); + } + } catch (error) { + if (this.debug) { + console.error('[IronTelemetry] Failed to save queue to storage:', error); + } + } + } + + /** + * Deserialize an event from storage + */ + private deserializeEvent(data: Record): TelemetryEvent { + return { + ...data, + timestamp: new Date(data.timestamp as string), + breadcrumbs: ((data.breadcrumbs as Record[]) ?? []).map((b) => ({ + ...b, + timestamp: new Date(b.timestamp as string), + })), + journey: data.journey + ? { + ...(data.journey as Record), + startedAt: new Date((data.journey as Record).startedAt as string), + } + : undefined, + } as TelemetryEvent; + } +} diff --git a/src/transport.ts b/src/transport.ts new file mode 100644 index 0000000..a9b44ba --- /dev/null +++ b/src/transport.ts @@ -0,0 +1,111 @@ +import type { TelemetryEvent, SendResult, ParsedDsn } from './types'; + +/** + * HTTP transport for sending events to the server + */ +export class Transport { + private readonly apiBaseUrl: string; + private readonly publicKey: string; + private readonly debug: boolean; + + constructor(parsedDsn: ParsedDsn, apiBaseUrl: string, debug: boolean = false) { + this.apiBaseUrl = apiBaseUrl; + this.publicKey = parsedDsn.publicKey; + this.debug = debug; + } + + /** + * Send an event to the server + */ + async send(event: TelemetryEvent): Promise { + const url = `${this.apiBaseUrl}/api/v1/events`; + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Public-Key': this.publicKey, + }, + body: JSON.stringify(this.serializeEvent(event)), + }); + + if (!response.ok) { + const errorText = await response.text(); + if (this.debug) { + console.error('[IronTelemetry] Failed to send event:', response.status, errorText); + } + return { + success: false, + error: `HTTP ${response.status}: ${errorText}`, + }; + } + + const result = await response.json(); + + if (this.debug) { + console.log('[IronTelemetry] Event sent successfully:', event.eventId); + } + + return { + success: true, + eventId: result.eventId ?? event.eventId, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + if (this.debug) { + console.error('[IronTelemetry] Failed to send event:', errorMessage); + } + return { + success: false, + error: errorMessage, + }; + } + } + + /** + * Check if the server is reachable + */ + async isOnline(): Promise { + try { + const response = await fetch(`${this.apiBaseUrl}/api/v1/health`, { + method: 'GET', + headers: { + 'X-Public-Key': this.publicKey, + }, + }); + return response.ok; + } catch { + return false; + } + } + + /** + * Serialize an event for sending + */ + private serializeEvent(event: TelemetryEvent): Record { + return { + eventId: event.eventId, + timestamp: event.timestamp.toISOString(), + level: event.level, + message: event.message, + exception: event.exception, + user: event.user, + tags: event.tags, + extra: event.extra, + breadcrumbs: event.breadcrumbs.map((b) => ({ + ...b, + timestamp: b.timestamp.toISOString(), + })), + journey: event.journey + ? { + ...event.journey, + startedAt: event.journey.startedAt.toISOString(), + } + : undefined, + environment: event.environment, + appVersion: event.appVersion, + platform: event.platform, + }; + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..b906f9b --- /dev/null +++ b/src/types.ts @@ -0,0 +1,224 @@ +/** + * Severity levels for events + */ +export type SeverityLevel = 'debug' | 'info' | 'warning' | 'error' | 'fatal'; + +/** + * Breadcrumb categories + */ +export type BreadcrumbCategory = + | 'ui' + | 'http' + | 'navigation' + | 'console' + | 'auth' + | 'business' + | 'notification' + | 'custom'; + +/** + * A breadcrumb representing an event leading up to an error + */ +export interface Breadcrumb { + /** Timestamp when the breadcrumb was created */ + timestamp: Date; + /** Category of the breadcrumb */ + category: BreadcrumbCategory; + /** Human-readable message */ + message: string; + /** Severity level */ + level?: SeverityLevel; + /** Additional data */ + data?: Record; +} + +/** + * User information for context + */ +export interface User { + /** Unique identifier for the user */ + id: string; + /** Email address */ + email?: string; + /** Display name */ + name?: string; + /** Additional user data */ + data?: Record; +} + +/** + * Exception/error information + */ +export interface ExceptionInfo { + /** Exception type/class name */ + type: string; + /** Error message */ + message: string; + /** Stack trace */ + stacktrace?: StackFrame[]; +} + +/** + * Stack frame information + */ +export interface StackFrame { + /** Function name */ + function?: string; + /** File name */ + filename?: string; + /** Line number */ + lineno?: number; + /** Column number */ + colno?: number; + /** Source context around the line */ + context?: string[]; +} + +/** + * Event payload sent to the server + */ +export interface TelemetryEvent { + /** Unique event ID */ + eventId: string; + /** Event timestamp */ + timestamp: Date; + /** Severity level */ + level: SeverityLevel; + /** Message */ + message?: string; + /** Exception information */ + exception?: ExceptionInfo; + /** User context */ + user?: User; + /** Tags for categorization */ + tags: Record; + /** Extra contextual data */ + extra: Record; + /** Breadcrumbs leading up to this event */ + breadcrumbs: Breadcrumb[]; + /** Journey context if in a journey */ + journey?: JourneyContext; + /** Environment name */ + environment?: string; + /** Application version */ + appVersion?: string; + /** Platform information */ + platform: PlatformInfo; +} + +/** + * Platform/runtime information + */ +export interface PlatformInfo { + /** Platform name (browser, node, etc.) */ + name: string; + /** Platform version */ + version?: string; + /** Operating system */ + os?: string; + /** Browser/runtime user agent */ + userAgent?: string; +} + +/** + * Journey context for tracking user flows + */ +export interface JourneyContext { + /** Journey ID */ + journeyId: string; + /** Journey name */ + name: string; + /** Current step name */ + currentStep?: string; + /** Journey start time */ + startedAt: Date; + /** Journey metadata */ + metadata: Record; +} + +/** + * Step within a journey + */ +export interface JourneyStep { + /** Step name */ + name: string; + /** Step category */ + category?: string; + /** Step start time */ + startedAt: Date; + /** Step end time */ + endedAt?: Date; + /** Step status */ + status: 'in_progress' | 'completed' | 'failed'; + /** Step data */ + data: Record; +} + +/** + * Options for initializing the SDK + */ +export interface TelemetryOptions { + /** + * Data Source Name (DSN) containing the public key + * Format: https://pk_live_xxx@irontelemetry.com + */ + dsn: string; + + /** Environment name (e.g., 'production', 'staging') */ + environment?: string; + + /** Application version */ + appVersion?: string; + + /** Sample rate for events (0.0 to 1.0) */ + sampleRate?: number; + + /** Maximum number of breadcrumbs to keep */ + maxBreadcrumbs?: number; + + /** Enable debug logging */ + debug?: boolean; + + /** + * Hook called before sending an event + * Return false to drop the event + */ + beforeSend?: (event: TelemetryEvent) => boolean | TelemetryEvent; + + /** Enable offline queue for failed events */ + enableOfflineQueue?: boolean; + + /** Maximum size of the offline queue */ + maxOfflineQueueSize?: number; + + /** API base URL (defaults to parsed from DSN) */ + apiBaseUrl?: string; +} + +/** + * Parsed DSN components + */ +export interface ParsedDsn { + /** Public key */ + publicKey: string; + /** Host */ + host: string; + /** Protocol (http or https) */ + protocol: string; + /** Full API base URL */ + apiBaseUrl: string; +} + +/** + * Result of sending an event + */ +export interface SendResult { + /** Whether the send was successful */ + success: boolean; + /** Event ID if successful */ + eventId?: string; + /** Error message if failed */ + error?: string; + /** Whether the event was queued for retry */ + queued?: boolean; +} diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json new file mode 100644 index 0000000..adc7335 --- /dev/null +++ b/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "outDir": "./dist/cjs", + "declaration": false, + "declarationMap": false + } +} diff --git a/tsconfig.esm.json b/tsconfig.esm.json new file mode 100644 index 0000000..970f073 --- /dev/null +++ b/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "outDir": "./dist/esm", + "declaration": false, + "declarationMap": false + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..63089f7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "lib": ["ES2020", "DOM"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/tsconfig.types.json b/tsconfig.types.json new file mode 100644 index 0000000..355fb40 --- /dev/null +++ b/tsconfig.types.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist/types", + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true + } +}