diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..db207d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +# Gradle +.gradle/ +build/ +!gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +# IDE +.idea/ +*.iml +*.ipr +*.iws +.project +.classpath +.settings/ +.vscode/ +*.swp +*.swo +out/ +bin/ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Package files +*.jar +*.war +*.ear +*.zip +*.tar.gz +*.rar diff --git a/README.md b/README.md index 337e4f7..44b708c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,277 @@ -# irontelemetry-java -IronTelemetry SDK for Java - Error monitoring and crash reporting +# IronTelemetry SDK for Java + +Error monitoring and crash reporting SDK for Java applications. Capture exceptions, track user journeys, and get insights to fix issues faster. + +[![Maven Central](https://img.shields.io/maven-central/v/com.ironservices/telemetry.svg)](https://search.maven.org/artifact/com.ironservices/telemetry) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +## Installation + +### Maven + +```xml + + com.ironservices + telemetry + 0.1.0 + +``` + +### Gradle + +```groovy +implementation 'com.ironservices:telemetry:0.1.0' +``` + +## Quick Start + +### Basic Exception Capture + +```java +import com.ironservices.telemetry.TelemetryClient; + +public class Main { + public static void main(String[] args) { + // Initialize with your DSN + try (TelemetryClient client = new TelemetryClient("https://pk_live_xxx@irontelemetry.com")) { + try { + doSomething(); + } catch (Exception e) { + client.captureException(e); + throw e; + } + } + } +} +``` + +### Journey Tracking + +Track user journeys to understand the context of errors: + +```java +import com.ironservices.telemetry.*; + +public class CheckoutService { + private final TelemetryClient client; + + public CheckoutService(TelemetryClient client) { + this.client = client; + } + + public void processCheckout() { + Journey journey = client.startJourney("Checkout Flow"); + journey.setUser("user-123", "user@example.com", "John Doe"); + + try { + journey.runStep("Validate Cart", BreadcrumbCategory.BUSINESS, () -> { + validateCart(); + }); + + journey.runStep("Process Payment", BreadcrumbCategory.BUSINESS, () -> { + processPayment(); + }); + + journey.runStep("Send Confirmation", BreadcrumbCategory.NOTIFICATION, () -> { + sendConfirmationEmail(); + }); + + journey.complete(); + } catch (Exception e) { + journey.fail(e); + client.captureException(e); + throw e; + } + } +} +``` + +## Configuration + +```java +import com.ironservices.telemetry.TelemetryClient; +import com.ironservices.telemetry.TelemetryOptions; + +TelemetryOptions options = new TelemetryOptions("https://pk_live_xxx@irontelemetry.com") + .setEnvironment("production") + .setAppVersion("1.2.3") + .setSampleRate(1.0) // 100% of events + .setDebug(false) + .setBeforeSend(event -> { + // Filter or modify events + if (event.getMessage() != null && event.getMessage().contains("expected")) { + return null; // Drop the event + } + return event; + }); + +TelemetryClient client = new TelemetryClient(options); +``` + +### 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` | double | 1.0 | Sample rate (0.0 to 1.0) | +| `maxBreadcrumbs` | int | 100 | Max breadcrumbs to keep | +| `debug` | boolean | false | Enable debug logging | +| `beforeSend` | Function | null | Hook to filter/modify events | +| `enableOfflineQueue` | boolean | true | Enable offline queue | +| `maxOfflineQueueSize` | int | 500 | Max offline queue size | + +## Features + +- **Automatic Stack Traces**: Full stack traces captured with every exception +- **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 +- **Async Support**: CompletableFuture support for async operations +- **Thread-Safe**: All operations are safe for concurrent use + +## Breadcrumbs + +```java +// Add simple breadcrumbs +client.addBreadcrumb("User clicked checkout button", BreadcrumbCategory.UI); +client.addBreadcrumb("Payment API called", BreadcrumbCategory.HTTP); + +// With level +client.addBreadcrumb("User logged in", BreadcrumbCategory.AUTH, SeverityLevel.INFO); + +// With data +Map data = new HashMap<>(); +data.put("url", "/api/checkout"); +data.put("statusCode", 200); +data.put("duration", 150); +client.addBreadcrumb("API request completed", BreadcrumbCategory.HTTP, SeverityLevel.INFO, data); + +// Using builder +client.addBreadcrumb(Breadcrumb.builder("Payment processed", BreadcrumbCategory.BUSINESS) + .level(SeverityLevel.INFO) + .addData("amount", 99.99) + .addData("currency", "USD") + .build()); +``` + +### Breadcrumb Categories + +```java +BreadcrumbCategory.UI // User interface interactions +BreadcrumbCategory.HTTP // HTTP requests +BreadcrumbCategory.NAVIGATION // Page/route navigation +BreadcrumbCategory.CONSOLE // Console output +BreadcrumbCategory.AUTH // Authentication events +BreadcrumbCategory.BUSINESS // Business logic events +BreadcrumbCategory.NOTIFICATION // Notification events +BreadcrumbCategory.CUSTOM // Custom events +``` + +## Severity Levels + +```java +SeverityLevel.DEBUG +SeverityLevel.INFO +SeverityLevel.WARNING +SeverityLevel.ERROR +SeverityLevel.FATAL +``` + +## User Context + +```java +// Simple user ID +client.setUser("user-123"); + +// With email +client.setUser("user-123", "user@example.com"); + +// Full user object +client.setUser(User.builder("user-123") + .email("user@example.com") + .name("John Doe") + .addData("plan", "premium") + .build()); +``` + +## Tags and Extra Data + +```java +// Set individual tags +client.setTag("release", "v1.2.3"); +client.setTag("server", "prod-1"); + +// Set multiple tags +Map tags = new HashMap<>(); +tags.put("release", "v1.2.3"); +tags.put("server", "prod-1"); +client.setTags(tags); + +// Set extra data +client.setExtra("request_id", "abc-123"); + +Map extras = new HashMap<>(); +extras.put("request_id", "abc-123"); +extras.put("user_agent", "Mozilla/5.0..."); +client.setExtras(extras); +``` + +## Async Operations + +```java +// Async exception capture +CompletableFuture future = client.captureExceptionAsync(exception); +future.thenAccept(result -> { + if (result.isSuccess()) { + System.out.println("Event sent: " + result.getEventId()); + } +}); + +// Async message capture +client.captureMessageAsync("Something happened", SeverityLevel.WARNING) + .thenAccept(result -> System.out.println("Sent: " + result.isSuccess())); +``` + +## Spring Integration Example + +```java +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@ControllerAdvice +public class GlobalExceptionHandler { + private final TelemetryClient telemetryClient; + + public GlobalExceptionHandler(TelemetryClient telemetryClient) { + this.telemetryClient = telemetryClient; + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception e, HttpServletRequest request) { + telemetryClient.addBreadcrumb( + "HTTP Request: " + request.getMethod() + " " + request.getRequestURI(), + BreadcrumbCategory.HTTP + ); + telemetryClient.captureException(e); + return ResponseEntity.status(500).body("Internal Server Error"); + } +} +``` + +## Requirements + +- Java 11+ +- OkHttp 4.x +- Gson 2.x + +## Links + +- [Documentation](https://www.irontelemetry.com/docs) +- [Dashboard](https://www.irontelemetry.com) + +## License + +MIT License - see [LICENSE](LICENSE) for details. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..6b6dcea --- /dev/null +++ b/pom.xml @@ -0,0 +1,106 @@ + + + 4.0.0 + + com.ironservices + telemetry + 0.1.0 + jar + + IronTelemetry + Error monitoring and crash reporting SDK for Java applications + https://github.com/IronServices/irontelemetry-java + + + + MIT License + https://opensource.org/licenses/MIT + + + + + + IronServices + support@ironservices.com + IronServices + https://www.ironservices.com + + + + + scm:git:git://github.com/IronServices/irontelemetry-java.git + scm:git:ssh://github.com:IronServices/irontelemetry-java.git + https://github.com/IronServices/irontelemetry-java + + + + 11 + 11 + UTF-8 + 2.10.1 + 4.12.0 + + + + + com.google.code.gson + gson + ${gson.version} + + + com.squareup.okhttp3 + okhttp + ${okhttp.version} + + + + + org.junit.jupiter + junit-jupiter + 5.10.1 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 11 + 11 + + + + org.apache.maven.plugins + maven-source-plugin + 3.3.0 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.6.3 + + + attach-javadocs + + jar + + + + + + + diff --git a/src/main/java/com/ironservices/telemetry/Breadcrumb.java b/src/main/java/com/ironservices/telemetry/Breadcrumb.java new file mode 100644 index 0000000..b76b8a6 --- /dev/null +++ b/src/main/java/com/ironservices/telemetry/Breadcrumb.java @@ -0,0 +1,85 @@ +package com.ironservices.telemetry; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +/** + * Represents a breadcrumb event leading up to an error. + */ +public class Breadcrumb { + private final Instant timestamp; + private final BreadcrumbCategory category; + private final String message; + private final SeverityLevel level; + private final Map data; + + private Breadcrumb(Builder builder) { + this.timestamp = builder.timestamp != null ? builder.timestamp : Instant.now(); + this.category = builder.category; + this.message = builder.message; + this.level = builder.level != null ? builder.level : SeverityLevel.INFO; + this.data = builder.data; + } + + public Instant getTimestamp() { + return timestamp; + } + + public BreadcrumbCategory getCategory() { + return category; + } + + public String getMessage() { + return message; + } + + public SeverityLevel getLevel() { + return level; + } + + public Map getData() { + return data; + } + + public static Builder builder(String message, BreadcrumbCategory category) { + return new Builder(message, category); + } + + public static class Builder { + private Instant timestamp; + private final BreadcrumbCategory category; + private final String message; + private SeverityLevel level; + private Map data = new HashMap<>(); + + public Builder(String message, BreadcrumbCategory category) { + this.message = message; + this.category = category; + } + + public Builder timestamp(Instant timestamp) { + this.timestamp = timestamp; + return this; + } + + public Builder level(SeverityLevel level) { + this.level = level; + return this; + } + + public Builder data(Map data) { + this.data = data; + return this; + } + + public Builder addData(String key, Object value) { + this.data.put(key, value); + return this; + } + + public Breadcrumb build() { + return new Breadcrumb(this); + } + } +} diff --git a/src/main/java/com/ironservices/telemetry/BreadcrumbCategory.java b/src/main/java/com/ironservices/telemetry/BreadcrumbCategory.java new file mode 100644 index 0000000..24ff8d8 --- /dev/null +++ b/src/main/java/com/ironservices/telemetry/BreadcrumbCategory.java @@ -0,0 +1,30 @@ +package com.ironservices.telemetry; + +/** + * Category for breadcrumbs. + */ +public enum BreadcrumbCategory { + UI("ui"), + HTTP("http"), + NAVIGATION("navigation"), + CONSOLE("console"), + AUTH("auth"), + BUSINESS("business"), + NOTIFICATION("notification"), + CUSTOM("custom"); + + private final String value; + + BreadcrumbCategory(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/main/java/com/ironservices/telemetry/BreadcrumbManager.java b/src/main/java/com/ironservices/telemetry/BreadcrumbManager.java new file mode 100644 index 0000000..fd75864 --- /dev/null +++ b/src/main/java/com/ironservices/telemetry/BreadcrumbManager.java @@ -0,0 +1,80 @@ +package com.ironservices.telemetry; + +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +/** + * Manages a ring buffer of breadcrumbs. + */ +public class BreadcrumbManager { + private final LinkedList breadcrumbs; + private final int maxBreadcrumbs; + private final Object lock = new Object(); + + public BreadcrumbManager(int maxBreadcrumbs) { + this.breadcrumbs = new LinkedList<>(); + this.maxBreadcrumbs = maxBreadcrumbs; + } + + /** + * Add a breadcrumb to the ring buffer. + */ + public void add(Breadcrumb breadcrumb) { + synchronized (lock) { + if (breadcrumbs.size() >= maxBreadcrumbs) { + breadcrumbs.removeFirst(); + } + breadcrumbs.add(breadcrumb); + } + } + + /** + * Add a simple breadcrumb with just a message and category. + */ + public void add(String message, BreadcrumbCategory category) { + add(Breadcrumb.builder(message, category).build()); + } + + /** + * Add a breadcrumb with a specific level. + */ + public void add(String message, BreadcrumbCategory category, SeverityLevel level) { + add(Breadcrumb.builder(message, category).level(level).build()); + } + + /** + * Add a breadcrumb with additional data. + */ + public void add(String message, BreadcrumbCategory category, SeverityLevel level, Map data) { + add(Breadcrumb.builder(message, category).level(level).data(data).build()); + } + + /** + * Get all breadcrumbs as a copy. + */ + public List getAll() { + synchronized (lock) { + return new ArrayList<>(breadcrumbs); + } + } + + /** + * Clear all breadcrumbs. + */ + public void clear() { + synchronized (lock) { + breadcrumbs.clear(); + } + } + + /** + * Get the number of breadcrumbs. + */ + public int size() { + synchronized (lock) { + return breadcrumbs.size(); + } + } +} diff --git a/src/main/java/com/ironservices/telemetry/ExceptionInfo.java b/src/main/java/com/ironservices/telemetry/ExceptionInfo.java new file mode 100644 index 0000000..19d91bc --- /dev/null +++ b/src/main/java/com/ironservices/telemetry/ExceptionInfo.java @@ -0,0 +1,43 @@ +package com.ironservices.telemetry; + +import java.util.ArrayList; +import java.util.List; + +/** + * Represents exception/error information. + */ +public class ExceptionInfo { + private final String type; + private final String message; + private final List stacktrace; + + public ExceptionInfo(String type, String message, List stacktrace) { + this.type = type; + this.message = message; + this.stacktrace = stacktrace; + } + + public static ExceptionInfo fromThrowable(Throwable throwable) { + List frames = new ArrayList<>(); + for (StackTraceElement element : throwable.getStackTrace()) { + frames.add(new StackFrame(element)); + } + return new ExceptionInfo( + throwable.getClass().getName(), + throwable.getMessage(), + frames + ); + } + + public String getType() { + return type; + } + + public String getMessage() { + return message; + } + + public List getStacktrace() { + return stacktrace; + } +} diff --git a/src/main/java/com/ironservices/telemetry/Journey.java b/src/main/java/com/ironservices/telemetry/Journey.java new file mode 100644 index 0000000..843707a --- /dev/null +++ b/src/main/java/com/ironservices/telemetry/Journey.java @@ -0,0 +1,123 @@ +package com.ironservices.telemetry; + +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Callable; + +/** + * Represents an active journey. + */ +public class Journey { + private final JourneyManager manager; + private final JourneyContext context; + + Journey(JourneyManager manager, JourneyContext context) { + this.manager = manager; + this.context = context; + } + + /** + * Set the user for the journey. + */ + public Journey setUser(String id, String email, String name) { + context.setMetadata("userId", id); + if (email != null) { + context.setMetadata("userEmail", email); + } + if (name != null) { + context.setMetadata("userName", name); + } + return this; + } + + /** + * Set metadata for the journey. + */ + public Journey setMetadata(String key, Object value) { + context.setMetadata(key, value); + return this; + } + + /** + * Start a new step in the journey. + */ + public Step startStep(String name, BreadcrumbCategory category) { + context.setCurrentStep(name); + + TelemetryClient client = manager.getClient(); + if (client != null) { + Map data = new HashMap<>(); + data.put("journeyId", context.getJourneyId()); + data.put("journeyName", context.getName()); + client.addBreadcrumb("Started step: " + name, category, SeverityLevel.INFO, data); + } + + return new Step(this, name, category); + } + + /** + * Run a function as a step. + */ + public T runStep(String name, BreadcrumbCategory category, Callable fn) throws Exception { + Step step = startStep(name, category); + try { + T result = fn.call(); + step.complete(); + return result; + } catch (Exception e) { + step.fail(e); + throw e; + } + } + + /** + * Run a void function as a step. + */ + public void runStep(String name, BreadcrumbCategory category, Runnable fn) { + Step step = startStep(name, category); + try { + fn.run(); + step.complete(); + } catch (Exception e) { + step.fail(e); + throw e; + } + } + + /** + * Complete the journey successfully. + */ + public void complete() { + TelemetryClient client = manager.getClient(); + if (client != null) { + Map data = new HashMap<>(); + data.put("journeyId", context.getJourneyId()); + data.put("duration", Duration.between(context.getStartedAt(), Instant.now()).toMillis()); + client.addBreadcrumb("Completed journey: " + context.getName(), BreadcrumbCategory.BUSINESS, SeverityLevel.INFO, data); + } + manager.clear(); + } + + /** + * Mark the journey as failed. + */ + public void fail(Throwable error) { + TelemetryClient client = manager.getClient(); + if (client != null) { + Map data = new HashMap<>(); + data.put("journeyId", context.getJourneyId()); + data.put("duration", Duration.between(context.getStartedAt(), Instant.now()).toMillis()); + if (error != null) { + data.put("error", error.getMessage()); + } + client.addBreadcrumb("Failed journey: " + context.getName(), BreadcrumbCategory.BUSINESS, SeverityLevel.ERROR, data); + } + manager.clear(); + } + + JourneyContext getContext() { + return context; + } +} diff --git a/src/main/java/com/ironservices/telemetry/JourneyContext.java b/src/main/java/com/ironservices/telemetry/JourneyContext.java new file mode 100644 index 0000000..6b4381d --- /dev/null +++ b/src/main/java/com/ironservices/telemetry/JourneyContext.java @@ -0,0 +1,51 @@ +package com.ironservices.telemetry; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +/** + * Represents journey context for tracking user flows. + */ +public class JourneyContext { + private final String journeyId; + private final String name; + private String currentStep; + private final Instant startedAt; + private final Map metadata; + + public JourneyContext(String journeyId, String name) { + this.journeyId = journeyId; + this.name = name; + this.startedAt = Instant.now(); + this.metadata = new HashMap<>(); + } + + public String getJourneyId() { + return journeyId; + } + + public String getName() { + return name; + } + + public String getCurrentStep() { + return currentStep; + } + + public void setCurrentStep(String currentStep) { + this.currentStep = currentStep; + } + + public Instant getStartedAt() { + return startedAt; + } + + public Map getMetadata() { + return metadata; + } + + public void setMetadata(String key, Object value) { + this.metadata.put(key, value); + } +} diff --git a/src/main/java/com/ironservices/telemetry/JourneyManager.java b/src/main/java/com/ironservices/telemetry/JourneyManager.java new file mode 100644 index 0000000..aa2b8fe --- /dev/null +++ b/src/main/java/com/ironservices/telemetry/JourneyManager.java @@ -0,0 +1,64 @@ +package com.ironservices.telemetry; + +import java.util.Map; +import java.util.UUID; +import java.util.WeakHashMap; + +/** + * Manages journey context. + */ +public class JourneyManager { + private static final Map journeyClients = new WeakHashMap<>(); + + private final TelemetryClient client; + private volatile JourneyContext current; + private final Object lock = new Object(); + + public JourneyManager(TelemetryClient client) { + this.client = client; + } + + /** + * Start a new journey. + */ + public Journey startJourney(String name) { + synchronized (lock) { + JourneyContext context = new JourneyContext(UUID.randomUUID().toString(), name); + this.current = context; + + if (client != null) { + client.addBreadcrumb("Started journey: " + name, BreadcrumbCategory.BUSINESS); + } + + Journey journey = new Journey(this, context); + journeyClients.put(journey, client); + return journey; + } + } + + /** + * Get the current journey context. + */ + public JourneyContext getCurrent() { + synchronized (lock) { + return current; + } + } + + /** + * Clear the current journey. + */ + public void clear() { + synchronized (lock) { + current = null; + } + } + + TelemetryClient getClient() { + return client; + } + + static TelemetryClient getClientForJourney(Journey journey) { + return journeyClients.get(journey); + } +} diff --git a/src/main/java/com/ironservices/telemetry/ParsedDSN.java b/src/main/java/com/ironservices/telemetry/ParsedDSN.java new file mode 100644 index 0000000..ed40473 --- /dev/null +++ b/src/main/java/com/ironservices/telemetry/ParsedDSN.java @@ -0,0 +1,69 @@ +package com.ironservices.telemetry; + +import java.net.URI; +import java.net.URISyntaxException; + +/** + * Represents parsed DSN components. + */ +public class ParsedDSN { + private final String publicKey; + private final String host; + private final String protocol; + private final String apiBaseUrl; + + private ParsedDSN(String publicKey, String host, String protocol, String apiBaseUrl) { + this.publicKey = publicKey; + this.host = host; + this.protocol = protocol; + this.apiBaseUrl = apiBaseUrl; + } + + /** + * Parse a DSN string. + * Format: https://pk_live_xxx@irontelemetry.com + */ + public static ParsedDSN parse(String dsn) { + if (dsn == null || dsn.isEmpty()) { + throw new IllegalArgumentException("DSN cannot be null or empty"); + } + + try { + URI uri = new URI(dsn); + String userInfo = uri.getUserInfo(); + + if (userInfo == null || !userInfo.startsWith("pk_")) { + throw new IllegalArgumentException("DSN must contain a valid public key starting with pk_"); + } + + String protocol = uri.getScheme(); + String host = uri.getHost(); + int port = uri.getPort(); + + String apiBaseUrl = protocol + "://" + host; + if (port > 0 && port != 80 && port != 443) { + apiBaseUrl += ":" + port; + } + + return new ParsedDSN(userInfo, host, protocol, apiBaseUrl); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("Invalid DSN format: " + dsn, e); + } + } + + public String getPublicKey() { + return publicKey; + } + + public String getHost() { + return host; + } + + public String getProtocol() { + return protocol; + } + + public String getApiBaseUrl() { + return apiBaseUrl; + } +} diff --git a/src/main/java/com/ironservices/telemetry/PlatformInfo.java b/src/main/java/com/ironservices/telemetry/PlatformInfo.java new file mode 100644 index 0000000..8cc9062 --- /dev/null +++ b/src/main/java/com/ironservices/telemetry/PlatformInfo.java @@ -0,0 +1,36 @@ +package com.ironservices.telemetry; + +/** + * Represents platform/runtime information. + */ +public class PlatformInfo { + private final String name; + private final String version; + private final String os; + + public PlatformInfo(String name, String version, String os) { + this.name = name; + this.version = version; + this.os = os; + } + + public static PlatformInfo current() { + return new PlatformInfo( + "java", + System.getProperty("java.version"), + System.getProperty("os.name") + " " + System.getProperty("os.version") + ); + } + + public String getName() { + return name; + } + + public String getVersion() { + return version; + } + + public String getOs() { + return os; + } +} diff --git a/src/main/java/com/ironservices/telemetry/SendResult.java b/src/main/java/com/ironservices/telemetry/SendResult.java new file mode 100644 index 0000000..410a1c6 --- /dev/null +++ b/src/main/java/com/ironservices/telemetry/SendResult.java @@ -0,0 +1,46 @@ +package com.ironservices.telemetry; + +/** + * Represents the result of sending an event. + */ +public class SendResult { + private final boolean success; + private final String eventId; + private final String error; + private final boolean queued; + + private SendResult(boolean success, String eventId, String error, boolean queued) { + this.success = success; + this.eventId = eventId; + this.error = error; + this.queued = queued; + } + + public static SendResult success(String eventId) { + return new SendResult(true, eventId, null, false); + } + + public static SendResult failure(String error) { + return new SendResult(false, null, error, false); + } + + public static SendResult queued(String eventId) { + return new SendResult(true, eventId, null, true); + } + + public boolean isSuccess() { + return success; + } + + public String getEventId() { + return eventId; + } + + public String getError() { + return error; + } + + public boolean isQueued() { + return queued; + } +} diff --git a/src/main/java/com/ironservices/telemetry/SeverityLevel.java b/src/main/java/com/ironservices/telemetry/SeverityLevel.java new file mode 100644 index 0000000..fb046b3 --- /dev/null +++ b/src/main/java/com/ironservices/telemetry/SeverityLevel.java @@ -0,0 +1,27 @@ +package com.ironservices.telemetry; + +/** + * Severity level for telemetry events. + */ +public enum SeverityLevel { + DEBUG("debug"), + INFO("info"), + WARNING("warning"), + ERROR("error"), + FATAL("fatal"); + + private final String value; + + SeverityLevel(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/main/java/com/ironservices/telemetry/StackFrame.java b/src/main/java/com/ironservices/telemetry/StackFrame.java new file mode 100644 index 0000000..18185cf --- /dev/null +++ b/src/main/java/com/ironservices/telemetry/StackFrame.java @@ -0,0 +1,41 @@ +package com.ironservices.telemetry; + +/** + * Represents a single frame in a stack trace. + */ +public class StackFrame { + private final String function; + private final String filename; + private final int lineno; + private final int colno; + + public StackFrame(String function, String filename, int lineno, int colno) { + this.function = function; + this.filename = filename; + this.lineno = lineno; + this.colno = colno; + } + + public StackFrame(StackTraceElement element) { + this.function = element.getClassName() + "." + element.getMethodName(); + this.filename = element.getFileName(); + this.lineno = element.getLineNumber(); + this.colno = 0; + } + + public String getFunction() { + return function; + } + + public String getFilename() { + return filename; + } + + public int getLineno() { + return lineno; + } + + public int getColno() { + return colno; + } +} diff --git a/src/main/java/com/ironservices/telemetry/Step.java b/src/main/java/com/ironservices/telemetry/Step.java new file mode 100644 index 0000000..e9723ea --- /dev/null +++ b/src/main/java/com/ironservices/telemetry/Step.java @@ -0,0 +1,69 @@ +package com.ironservices.telemetry; + +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +/** + * Represents a step within a journey. + */ +public class Step { + private final Journey journey; + private final String name; + private final BreadcrumbCategory category; + private final Instant startedAt; + private final Map data; + + Step(Journey journey, String name, BreadcrumbCategory category) { + this.journey = journey; + this.name = name; + this.category = category; + this.startedAt = Instant.now(); + this.data = new HashMap<>(); + } + + /** + * Set data for the step. + */ + public Step setData(String key, Object value) { + data.put(key, value); + return this; + } + + /** + * Complete the step successfully. + */ + public void complete() { + TelemetryClient client = journey.getContext() != null ? + JourneyManager.getClientForJourney(journey) : null; + + if (client != null) { + Map breadcrumbData = new HashMap<>(); + breadcrumbData.put("journeyId", journey.getContext().getJourneyId()); + breadcrumbData.put("journeyName", journey.getContext().getName()); + breadcrumbData.put("duration", Duration.between(startedAt, Instant.now()).toMillis()); + breadcrumbData.putAll(data); + client.addBreadcrumb("Completed step: " + name, category, SeverityLevel.INFO, breadcrumbData); + } + } + + /** + * Mark the step as failed. + */ + public void fail(Throwable error) { + TelemetryClient client = JourneyManager.getClientForJourney(journey); + + if (client != null) { + Map breadcrumbData = new HashMap<>(); + breadcrumbData.put("journeyId", journey.getContext().getJourneyId()); + breadcrumbData.put("journeyName", journey.getContext().getName()); + breadcrumbData.put("duration", Duration.between(startedAt, Instant.now()).toMillis()); + breadcrumbData.putAll(data); + if (error != null) { + breadcrumbData.put("error", error.getMessage()); + } + client.addBreadcrumb("Failed step: " + name, category, SeverityLevel.ERROR, breadcrumbData); + } + } +} diff --git a/src/main/java/com/ironservices/telemetry/TelemetryClient.java b/src/main/java/com/ironservices/telemetry/TelemetryClient.java new file mode 100644 index 0000000..ce426bb --- /dev/null +++ b/src/main/java/com/ironservices/telemetry/TelemetryClient.java @@ -0,0 +1,262 @@ +package com.ironservices.telemetry; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ThreadLocalRandom; + +/** + * The main IronTelemetry client. + */ +public class TelemetryClient implements AutoCloseable { + private final TelemetryOptions options; + private final ParsedDSN parsedDSN; + private final Transport transport; + private final BreadcrumbManager breadcrumbs; + private final JourneyManager journeys; + private volatile User user; + private final Map tags; + private final Map extra; + private final Object lock = new Object(); + + /** + * Create a new TelemetryClient with a DSN. + */ + public TelemetryClient(String dsn) { + this(new TelemetryOptions(dsn)); + } + + /** + * Create a new TelemetryClient with options. + */ + public TelemetryClient(TelemetryOptions options) { + this.options = options; + this.parsedDSN = ParsedDSN.parse(options.getDsn()); + String apiBaseUrl = options.getApiBaseUrl() != null ? options.getApiBaseUrl() : parsedDSN.getApiBaseUrl(); + this.transport = new Transport(parsedDSN, apiBaseUrl, options.isDebug()); + this.breadcrumbs = new BreadcrumbManager(options.getMaxBreadcrumbs()); + this.journeys = new JourneyManager(this); + this.tags = new HashMap<>(); + this.extra = new HashMap<>(); + + if (options.isDebug()) { + System.out.println("[IronTelemetry] Initialized with DSN: " + parsedDSN.getApiBaseUrl()); + } + } + + /** + * Capture an exception and send it to the server. + */ + public SendResult captureException(Throwable throwable) { + if (throwable == null) { + return SendResult.failure("null exception"); + } + + TelemetryEvent event = createEvent(SeverityLevel.ERROR, throwable.getMessage()); + event.setException(ExceptionInfo.fromThrowable(throwable)); + return sendEvent(event); + } + + /** + * Capture an exception asynchronously. + */ + public CompletableFuture captureExceptionAsync(Throwable throwable) { + return CompletableFuture.supplyAsync(() -> captureException(throwable)); + } + + /** + * Capture a message and send it to the server. + */ + public SendResult captureMessage(String message, SeverityLevel level) { + TelemetryEvent event = createEvent(level, message); + return sendEvent(event); + } + + /** + * Capture a message with INFO level. + */ + public SendResult captureMessage(String message) { + return captureMessage(message, SeverityLevel.INFO); + } + + /** + * Capture a message asynchronously. + */ + public CompletableFuture captureMessageAsync(String message, SeverityLevel level) { + return CompletableFuture.supplyAsync(() -> captureMessage(message, level)); + } + + /** + * Add a breadcrumb. + */ + public void addBreadcrumb(String message, BreadcrumbCategory category) { + breadcrumbs.add(message, category); + } + + /** + * Add a breadcrumb with a level. + */ + public void addBreadcrumb(String message, BreadcrumbCategory category, SeverityLevel level) { + breadcrumbs.add(message, category, level); + } + + /** + * Add a breadcrumb with data. + */ + public void addBreadcrumb(String message, BreadcrumbCategory category, SeverityLevel level, Map data) { + breadcrumbs.add(message, category, level, data); + } + + /** + * Add a breadcrumb object. + */ + public void addBreadcrumb(Breadcrumb breadcrumb) { + breadcrumbs.add(breadcrumb); + } + + /** + * Set the user context. + */ + public void setUser(User user) { + this.user = user; + } + + /** + * Set the user context by ID. + */ + public void setUser(String id) { + this.user = User.builder(id).build(); + } + + /** + * Set the user context with ID and email. + */ + public void setUser(String id, String email) { + this.user = User.builder(id).email(email).build(); + } + + /** + * Set a tag. + */ + public void setTag(String key, String value) { + synchronized (lock) { + tags.put(key, value); + } + } + + /** + * Set multiple tags. + */ + public void setTags(Map tags) { + synchronized (lock) { + this.tags.putAll(tags); + } + } + + /** + * Set extra data. + */ + public void setExtra(String key, Object value) { + synchronized (lock) { + extra.put(key, value); + } + } + + /** + * Set multiple extra data values. + */ + public void setExtras(Map extras) { + synchronized (lock) { + this.extra.putAll(extras); + } + } + + /** + * Start a new journey. + */ + public Journey startJourney(String name) { + return journeys.startJourney(name); + } + + /** + * Get the current journey context. + */ + public JourneyContext getCurrentJourney() { + return journeys.getCurrent(); + } + + /** + * Clear all breadcrumbs. + */ + public void clearBreadcrumbs() { + breadcrumbs.clear(); + } + + /** + * Flush pending events. + */ + public void flush() { + // Future: Implement offline queue flushing + } + + /** + * Close the client and release resources. + */ + @Override + public void close() { + flush(); + transport.close(); + } + + private TelemetryEvent createEvent(SeverityLevel level, String message) { + TelemetryEvent event = new TelemetryEvent( + UUID.randomUUID().toString(), + level, + message + ); + + event.setEnvironment(options.getEnvironment()); + event.setAppVersion(options.getAppVersion()); + event.setBreadcrumbs(breadcrumbs.getAll()); + + synchronized (lock) { + event.setTags(new HashMap<>(tags)); + event.setExtra(new HashMap<>(extra)); + } + + if (user != null) { + event.setUser(user); + } + + JourneyContext journey = journeys.getCurrent(); + if (journey != null) { + event.setJourney(journey); + } + + return event; + } + + private SendResult sendEvent(TelemetryEvent event) { + // Check sample rate + if (options.getSampleRate() < 1.0 && ThreadLocalRandom.current().nextDouble() > options.getSampleRate()) { + if (options.isDebug()) { + System.out.println("[IronTelemetry] Event sampled out: " + event.getEventId()); + } + return SendResult.success(event.getEventId()); + } + + // Apply beforeSend hook + if (options.getBeforeSend() != null) { + event = options.getBeforeSend().apply(event); + if (event == null) { + if (options.isDebug()) { + System.out.println("[IronTelemetry] Event dropped by beforeSend hook"); + } + return SendResult.success(null); + } + } + + return transport.send(event); + } +} diff --git a/src/main/java/com/ironservices/telemetry/TelemetryEvent.java b/src/main/java/com/ironservices/telemetry/TelemetryEvent.java new file mode 100644 index 0000000..385a912 --- /dev/null +++ b/src/main/java/com/ironservices/telemetry/TelemetryEvent.java @@ -0,0 +1,125 @@ +package com.ironservices.telemetry; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Represents a telemetry event payload sent to the server. + */ +public class TelemetryEvent { + private final String eventId; + private final Instant timestamp; + private final SeverityLevel level; + private final String message; + private ExceptionInfo exception; + private User user; + private Map tags; + private Map extra; + private List breadcrumbs; + private JourneyContext journey; + private String environment; + private String appVersion; + private PlatformInfo platform; + + public TelemetryEvent(String eventId, SeverityLevel level, String message) { + this.eventId = eventId; + this.timestamp = Instant.now(); + this.level = level; + this.message = message; + this.tags = new HashMap<>(); + this.extra = new HashMap<>(); + this.breadcrumbs = new ArrayList<>(); + this.platform = PlatformInfo.current(); + } + + public String getEventId() { + return eventId; + } + + public Instant getTimestamp() { + return timestamp; + } + + public SeverityLevel getLevel() { + return level; + } + + public String getMessage() { + return message; + } + + public ExceptionInfo getException() { + return exception; + } + + public void setException(ExceptionInfo exception) { + this.exception = exception; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public Map getTags() { + return tags; + } + + public void setTags(Map tags) { + this.tags = tags; + } + + public Map getExtra() { + return extra; + } + + public void setExtra(Map extra) { + this.extra = extra; + } + + public List getBreadcrumbs() { + return breadcrumbs; + } + + public void setBreadcrumbs(List breadcrumbs) { + this.breadcrumbs = breadcrumbs; + } + + public JourneyContext getJourney() { + return journey; + } + + public void setJourney(JourneyContext journey) { + this.journey = journey; + } + + public String getEnvironment() { + return environment; + } + + public void setEnvironment(String environment) { + this.environment = environment; + } + + public String getAppVersion() { + return appVersion; + } + + public void setAppVersion(String appVersion) { + this.appVersion = appVersion; + } + + public PlatformInfo getPlatform() { + return platform; + } + + public void setPlatform(PlatformInfo platform) { + this.platform = platform; + } +} diff --git a/src/main/java/com/ironservices/telemetry/TelemetryOptions.java b/src/main/java/com/ironservices/telemetry/TelemetryOptions.java new file mode 100644 index 0000000..63ce016 --- /dev/null +++ b/src/main/java/com/ironservices/telemetry/TelemetryOptions.java @@ -0,0 +1,111 @@ +package com.ironservices.telemetry; + +import java.util.function.Function; + +/** + * Configuration options for the telemetry client. + */ +public class TelemetryOptions { + private final String dsn; + private String environment = "production"; + private String appVersion = "0.0.0"; + private double sampleRate = 1.0; + private int maxBreadcrumbs = 100; + private boolean debug = false; + private Function beforeSend; + private boolean enableOfflineQueue = true; + private int maxOfflineQueueSize = 500; + private String apiBaseUrl; + + public TelemetryOptions(String dsn) { + if (dsn == null || dsn.isEmpty()) { + throw new IllegalArgumentException("DSN is required"); + } + this.dsn = dsn; + } + + public String getDsn() { + return dsn; + } + + public String getEnvironment() { + return environment; + } + + public TelemetryOptions setEnvironment(String environment) { + this.environment = environment; + return this; + } + + public String getAppVersion() { + return appVersion; + } + + public TelemetryOptions setAppVersion(String appVersion) { + this.appVersion = appVersion; + return this; + } + + public double getSampleRate() { + return sampleRate; + } + + public TelemetryOptions setSampleRate(double sampleRate) { + this.sampleRate = Math.max(0.0, Math.min(1.0, sampleRate)); + return this; + } + + public int getMaxBreadcrumbs() { + return maxBreadcrumbs; + } + + public TelemetryOptions setMaxBreadcrumbs(int maxBreadcrumbs) { + this.maxBreadcrumbs = maxBreadcrumbs; + return this; + } + + public boolean isDebug() { + return debug; + } + + public TelemetryOptions setDebug(boolean debug) { + this.debug = debug; + return this; + } + + public Function getBeforeSend() { + return beforeSend; + } + + public TelemetryOptions setBeforeSend(Function beforeSend) { + this.beforeSend = beforeSend; + return this; + } + + public boolean isEnableOfflineQueue() { + return enableOfflineQueue; + } + + public TelemetryOptions setEnableOfflineQueue(boolean enableOfflineQueue) { + this.enableOfflineQueue = enableOfflineQueue; + return this; + } + + public int getMaxOfflineQueueSize() { + return maxOfflineQueueSize; + } + + public TelemetryOptions setMaxOfflineQueueSize(int maxOfflineQueueSize) { + this.maxOfflineQueueSize = maxOfflineQueueSize; + return this; + } + + public String getApiBaseUrl() { + return apiBaseUrl; + } + + public TelemetryOptions setApiBaseUrl(String apiBaseUrl) { + this.apiBaseUrl = apiBaseUrl; + return this; + } +} diff --git a/src/main/java/com/ironservices/telemetry/Transport.java b/src/main/java/com/ironservices/telemetry/Transport.java new file mode 100644 index 0000000..663b724 --- /dev/null +++ b/src/main/java/com/ironservices/telemetry/Transport.java @@ -0,0 +1,190 @@ +package com.ironservices.telemetry; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import okhttp3.*; + +import java.io.IOException; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +/** + * Handles HTTP communication with the server. + */ +public class Transport { + private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); + private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_INSTANT; + + private final String apiBaseUrl; + private final String publicKey; + private final boolean debug; + private final OkHttpClient client; + private final Gson gson; + + public Transport(ParsedDSN parsedDSN, String apiBaseUrl, boolean debug) { + this.apiBaseUrl = apiBaseUrl != null ? apiBaseUrl : parsedDSN.getApiBaseUrl(); + this.publicKey = parsedDSN.getPublicKey(); + this.debug = debug; + this.client = new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build(); + this.gson = new GsonBuilder().serializeNulls().create(); + } + + /** + * Send an event synchronously. + */ + public SendResult send(TelemetryEvent event) { + String url = apiBaseUrl + "/api/v1/events"; + + try { + String body = gson.toJson(serializeEvent(event)); + Request request = new Request.Builder() + .url(url) + .header("Content-Type", "application/json") + .header("X-Public-Key", publicKey) + .post(RequestBody.create(body, JSON)) + .build(); + + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful()) { + String errorBody = response.body() != null ? response.body().string() : ""; + String error = "HTTP " + response.code() + ": " + errorBody; + if (debug) { + System.out.println("[IronTelemetry] Failed to send event: " + error); + } + return SendResult.failure(error); + } + + if (debug) { + System.out.println("[IronTelemetry] Event sent successfully: " + event.getEventId()); + } + return SendResult.success(event.getEventId()); + } + } catch (IOException e) { + if (debug) { + System.out.println("[IronTelemetry] Failed to send event: " + e.getMessage()); + } + return SendResult.failure(e.getMessage()); + } + } + + /** + * Send an event asynchronously. + */ + public CompletableFuture sendAsync(TelemetryEvent event) { + return CompletableFuture.supplyAsync(() -> send(event)); + } + + /** + * Check if the server is reachable. + */ + public boolean isOnline() { + String url = apiBaseUrl + "/api/v1/health"; + + try { + Request request = new Request.Builder() + .url(url) + .header("X-Public-Key", publicKey) + .get() + .build(); + + try (Response response = client.newCall(request).execute()) { + return response.isSuccessful(); + } + } catch (IOException e) { + return false; + } + } + + private Map serializeEvent(TelemetryEvent event) { + Map result = new HashMap<>(); + result.put("eventId", event.getEventId()); + result.put("timestamp", formatInstant(event.getTimestamp())); + result.put("level", event.getLevel().getValue()); + result.put("message", event.getMessage()); + result.put("tags", event.getTags()); + result.put("extra", event.getExtra()); + result.put("environment", event.getEnvironment()); + result.put("appVersion", event.getAppVersion()); + + // Breadcrumbs + List> breadcrumbs = new ArrayList<>(); + for (Breadcrumb b : event.getBreadcrumbs()) { + Map bc = new HashMap<>(); + bc.put("timestamp", formatInstant(b.getTimestamp())); + bc.put("category", b.getCategory().getValue()); + bc.put("message", b.getMessage()); + bc.put("level", b.getLevel().getValue()); + bc.put("data", b.getData()); + breadcrumbs.add(bc); + } + result.put("breadcrumbs", breadcrumbs); + + // Platform + if (event.getPlatform() != null) { + Map platform = new HashMap<>(); + platform.put("name", event.getPlatform().getName()); + platform.put("version", event.getPlatform().getVersion()); + platform.put("os", event.getPlatform().getOs()); + result.put("platform", platform); + } + + // Exception + if (event.getException() != null) { + Map exception = new HashMap<>(); + exception.put("type", event.getException().getType()); + exception.put("message", event.getException().getMessage()); + List> stacktrace = new ArrayList<>(); + for (StackFrame f : event.getException().getStacktrace()) { + Map frame = new HashMap<>(); + frame.put("function", f.getFunction()); + frame.put("filename", f.getFilename()); + frame.put("lineno", f.getLineno()); + stacktrace.add(frame); + } + exception.put("stacktrace", stacktrace); + result.put("exception", exception); + } + + // User + if (event.getUser() != null) { + Map user = new HashMap<>(); + user.put("id", event.getUser().getId()); + user.put("email", event.getUser().getEmail()); + user.put("name", event.getUser().getName()); + user.put("data", event.getUser().getData()); + result.put("user", user); + } + + // Journey + if (event.getJourney() != null) { + Map journey = new HashMap<>(); + journey.put("journeyId", event.getJourney().getJourneyId()); + journey.put("name", event.getJourney().getName()); + journey.put("currentStep", event.getJourney().getCurrentStep()); + journey.put("startedAt", formatInstant(event.getJourney().getStartedAt())); + journey.put("metadata", event.getJourney().getMetadata()); + result.put("journey", journey); + } + + return result; + } + + private String formatInstant(Instant instant) { + return ISO_FORMATTER.format(instant); + } + + public void close() { + client.dispatcher().executorService().shutdown(); + client.connectionPool().evictAll(); + } +} diff --git a/src/main/java/com/ironservices/telemetry/User.java b/src/main/java/com/ironservices/telemetry/User.java new file mode 100644 index 0000000..526072c --- /dev/null +++ b/src/main/java/com/ironservices/telemetry/User.java @@ -0,0 +1,76 @@ +package com.ironservices.telemetry; + +import java.util.HashMap; +import java.util.Map; + +/** + * Represents user information for context. + */ +public class User { + private final String id; + private final String email; + private final String name; + private final Map data; + + private User(Builder builder) { + this.id = builder.id; + this.email = builder.email; + this.name = builder.name; + this.data = builder.data; + } + + public String getId() { + return id; + } + + public String getEmail() { + return email; + } + + public String getName() { + return name; + } + + public Map getData() { + return data; + } + + public static Builder builder(String id) { + return new Builder(id); + } + + public static class Builder { + private final String id; + private String email; + private String name; + private Map data = new HashMap<>(); + + public Builder(String id) { + this.id = id; + } + + public Builder email(String email) { + this.email = email; + return this; + } + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder data(Map data) { + this.data = data; + return this; + } + + public Builder addData(String key, Object value) { + this.data.put(key, value); + return this; + } + + public User build() { + return new User(this); + } + } +}