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:
parent
9118db4aed
commit
f2bed60e85
|
|
@ -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
199
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.
|
||||
|
||||
[](https://www.npmjs.com/package/@ironservices/telemetry)
|
||||
[](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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"outDir": "./dist/cjs",
|
||||
"declaration": false,
|
||||
"declarationMap": false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"outDir": "./dist/esm",
|
||||
"declaration": false,
|
||||
"declarationMap": false
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/types",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"emitDeclarationOnly": true
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue