Initial SDK implementation

- Core client with exception capture and message logging
- Journey and step tracking for user flows
- Breadcrumb management
- Offline queue with localStorage persistence
- HTTP transport with retry support
- Full TypeScript types
- ESM + CommonJS dual publishing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
David Friedel 2025-12-25 10:04:46 +00:00
parent 9118db4aed
commit f2bed60e85
15 changed files with 1803 additions and 2 deletions

29
.gitignore vendored Normal file
View File

@ -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

199
README.md
View File

@ -1,2 +1,197 @@
# irontelemetry-js # IronTelemetry SDK for JavaScript/TypeScript
IronTelemetry SDK for JavaScript/TypeScript - Error monitoring and crash reporting
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.

57
package.json Normal file
View File

@ -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"
}
}

75
src/breadcrumbs.ts Normal file
View File

@ -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<string, unknown>
): 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<Breadcrumb, 'timestamp'> & { 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;
}
}

335
src/client.ts Normal file
View File

@ -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<typeof resolveOptions>;
private readonly transport: Transport;
private readonly queue: OfflineQueue | null;
private readonly breadcrumbs: BreadcrumbManager;
private tags: Record<string, string> = {};
private extra: Record<string, unknown> = {};
private user?: User;
private currentJourney?: Journey;
private flushInterval?: ReturnType<typeof setInterval>;
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<string, unknown>): Promise<SendResult> {
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<SendResult> {
const event = this.createEvent(level, message);
return this.sendEvent(event);
}
/**
* Add a breadcrumb
*/
addBreadcrumb(
message: string,
category: BreadcrumbCategory = 'custom',
level?: SeverityLevel,
data?: Record<string, unknown>
): void;
addBreadcrumb(breadcrumb: Omit<Breadcrumb, 'timestamp'>): void;
addBreadcrumb(
messageOrBreadcrumb: string | Omit<Breadcrumb, 'timestamp'>,
category?: BreadcrumbCategory,
level?: SeverityLevel,
data?: Record<string, unknown>
): 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<string, unknown>): 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<void> {
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<SendResult> {
// 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<void> {
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] || '<anonymous>',
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] || '<anonymous>',
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',
};
}
}

72
src/config.ts Normal file
View File

@ -0,0 +1,72 @@
import type { TelemetryOptions, ParsedDsn } from './types';
/**
* Default configuration values
*/
export const DEFAULT_OPTIONS: Partial<TelemetryOptions> = {
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<TelemetryOptions> & { 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}`;
}

293
src/index.ts Normal file
View File

@ -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<string, unknown>
): Promise<SendResult> {
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<SendResult> {
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<string, unknown>
): void;
export function addBreadcrumb(breadcrumb: Omit<Breadcrumb, 'timestamp'>): void;
export function addBreadcrumb(
messageOrBreadcrumb: string | Omit<Breadcrumb, 'timestamp'>,
category?: BreadcrumbCategory,
level?: SeverityLevel,
data?: Record<string, unknown>
): 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<string, unknown>): 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<void> {
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<T>(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<T>(
name: string,
fn: () => Promise<T>,
category?: string
): Promise<T> {
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;
}
}

235
src/journey.ts Normal file
View File

@ -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<string, unknown> = {};
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<string, unknown>): 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();
}
}
}

129
src/queue.ts Normal file
View File

@ -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<string, unknown>) => 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<string, unknown>): TelemetryEvent {
return {
...data,
timestamp: new Date(data.timestamp as string),
breadcrumbs: ((data.breadcrumbs as Record<string, unknown>[]) ?? []).map((b) => ({
...b,
timestamp: new Date(b.timestamp as string),
})),
journey: data.journey
? {
...(data.journey as Record<string, unknown>),
startedAt: new Date((data.journey as Record<string, unknown>).startedAt as string),
}
: undefined,
} as TelemetryEvent;
}
}

111
src/transport.ts Normal file
View File

@ -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<SendResult> {
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<boolean> {
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<string, unknown> {
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,
};
}
}

224
src/types.ts Normal file
View File

@ -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<string, unknown>;
}
/**
* 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<string, unknown>;
}
/**
* 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<string, string>;
/** Extra contextual data */
extra: Record<string, unknown>;
/** 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<string, unknown>;
}
/**
* 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<string, unknown>;
}
/**
* 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;
}

9
tsconfig.cjs.json Normal file
View File

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"outDir": "./dist/cjs",
"declaration": false,
"declarationMap": false
}
}

9
tsconfig.esm.json Normal file
View File

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"outDir": "./dist/esm",
"declaration": false,
"declarationMap": false
}
}

19
tsconfig.json Normal file
View File

@ -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"]
}

9
tsconfig.types.json Normal file
View File

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist/types",
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true
}
}