Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,19 @@ metrics.set("memory_usage_mb", 512.5);
metrics.set("queue_size", 0);
```

## Events

Track what happens in your app, such as an agent run:

```java
logdash.track("agent_run", Map.of("agent", "support", "steps", 4, "success", true), user.id());
```

Events show up in the domain's Analytics as goals, with a daily trend.
Pass the user ID for signed-in users, so one person counts once across the website and apps.
Without it, Logdash counts a visitor of that app that changes every day.
Never put personal data in props.

## Apps

A domain has one ingest key, the domain key, shared by all apps of that domain, such as a desktop app, an API and a landing page.
Expand All @@ -294,7 +307,7 @@ var logdash = Logdash.builder()
.build();
```

Every log and metric this instance sends carries `"app": "api"`.
Every log, metric and event this instance sends carries `"app": "api"`.
With a domain key, the app is created on first use.
Older service keys ignore `app`.

Expand Down Expand Up @@ -336,7 +349,7 @@ Logdash logdash = Logdash.builder()
| Parameter | Default | Description |
|-------------------------|--------------------------|----------------------------------|
| `apiKey` | `null` | Your Logdash API key (required) |
| `app` | `null` | App name sent with logs/metrics |
| `app` | `null` | App name sent with all data |
| `baseUrl` | `https://api.logdash.io` | Logdash API endpoint |
| `enableConsoleOutput` | `true` | Show logs/metrics in console |
| `enableVerboseLogging` | `false` | Enable SDK debug output |
Expand Down
70 changes: 62 additions & 8 deletions src/main/java/io/logdash/sdk/Logdash.java
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
package io.logdash.sdk;

import io.logdash.sdk.config.LogdashConfig;
import io.logdash.sdk.event.EventEntry;
import io.logdash.sdk.log.LogdashLogger;
import io.logdash.sdk.metrics.LogdashMetrics;
import io.logdash.sdk.transport.HttpTransport;
import io.logdash.sdk.transport.LogdashTransport;
import io.logdash.sdk.transport.NoOpTransport;

import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

/**
* Main entry point for the Logdash Java SDK.
*
* <p>This class provides access to logging and metrics functionality for sending observability data
* to the Logdash.io platform. It manages the underlying transport layer and provides a simple API
* for developers to integrate observability into their applications.
* <p>This class provides access to logging, metrics and event tracking for sending observability
* data to the Logdash.io platform. It manages the underlying transport layer and provides a simple
* API for developers to integrate observability into their applications.
*
* <p>The SDK automatically handles HTTP transport with retry logic, rate limiting, and graceful
* shutdown. If no API key is provided or HTTP transport fails, it falls back to console-only mode.
Expand Down Expand Up @@ -102,6 +104,58 @@ public LogdashMetrics metrics() {
return new LogdashMetrics(config, transport);
}

/**
* Tracks an event without properties or a user.
*
* @param name the event name, matching {@code ^[a-z][a-z0-9_]{0,63}$}
* @see #track(String, Map, String)
*/
public void track(String name) {
track(name, Map.of(), null);
}

/**
* Tracks an event with properties and no user.
*
* @param name the event name, matching {@code ^[a-z][a-z0-9_]{0,63}$}
* @param props flat properties with string, number or boolean values
* @see #track(String, Map, String)
*/
public void track(String name, Map<String, Object> props) {
track(name, props, null);
}

/**
* Tracks an event, such as an agent run, for the domain's Analytics.
*
* <p>Like logs, the event is sent in the background, and this method never throws. Flush and
* close send pending events too. Without an API key, the event is printed to the console only.
*
* <p>An invalid name, a name of the website tracker ({@code pageview}, {@code pageleave} or
* {@code browser_error}), or a user ID that contains {@code @} drops the event, with a warning
* when verbose logging is on.
*
* <pre>{@code
* logdash.track("agent_run", Map.of("agent", "support", "steps", 4), user.id());
* }</pre>
*
* @param name the event name, matching {@code ^[a-z][a-z0-9_]{0,63}$}
* @param props up to 10 flat properties with keys matching {@code ^[a-z][a-z0-9_]{0,39}$} and
* string, number or boolean values; never personal data
* @param userId the signed-in user's internal ID, never an email address, so one person counts
* once across the website and apps; null counts a visitor of this app that
* changes every day
*/
public void track(String name, Map<String, Object> props, String userId) {
try {
transport.sendEvent(new EventEntry(name, props, userId));
} catch (RuntimeException e) {
if (config.enableVerboseLogging()) {
System.err.println("Warning: Event dropped: " + e.getMessage());
}
}
}

/**
* Returns the configuration used by this instance.
*
Expand All @@ -112,7 +166,7 @@ public LogdashConfig config() {
}

/**
* Flushes all pending logs and metrics synchronously.
* Flushes all pending logs, metrics and events synchronously.
*
* <p>Blocks until all queued requests complete or the configured timeout is reached. This method
* is useful for ensuring all data is sent before application shutdown.
Expand Down Expand Up @@ -267,7 +321,7 @@ public Builder baseUrl(String baseUrl) {
}

/**
* Enables or disables console output for logs and metrics.
* Enables or disables console output for logs, metrics and events.
*
* @param enableConsoleOutput true to enable console output
* @return this builder
Expand Down Expand Up @@ -348,9 +402,9 @@ public Builder maxConcurrentRequests(int maxConcurrentRequests) {
* {@code landing}.
*
* <p>The domain key is shared by all apps of a domain, and {@code app} names this one, so the
* dashboard can filter logs and metrics by app. Every log and metric this instance sends
* carries it. With a domain key, the app is created on first use. Older service keys ignore
* {@code app}.
* dashboard can filter logs and metrics by app. Every log, metric and event this instance
* sends carries it. With a domain key, the app is created on first use. Older service keys
* ignore {@code app}.
*
* <pre>{@code
* var logdash = Logdash.builder()
Expand Down
6 changes: 3 additions & 3 deletions src/main/java/io/logdash/sdk/config/LogdashConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@
*
* @param apiKey API key for authenticating with Logdash.io
* @param baseUrl Base URL for the Logdash API (defaults to https://api.logdash.io)
* @param enableConsoleOutput Whether to output logs and metrics to console
* @param enableConsoleOutput Whether to output logs, metrics and events to console
* @param enableVerboseLogging Whether to enable verbose internal logging
* @param maxRetries Maximum number of retry attempts for failed requests
* @param retryDelayMs Delay between retry attempts in milliseconds
* @param requestTimeoutMs HTTP request timeout in milliseconds
* @param shutdownTimeoutMs Maximum time to wait for graceful shutdown
* @param maxConcurrentRequests Maximum number of concurrent HTTP requests
* @param app Name of the app sent with every log and metric, trimmed; null when
* not set or blank
* @param app Name of the app sent with every log, metric and event, trimmed; null
* when not set or blank
*/
public record LogdashConfig(
String apiKey,
Expand Down
103 changes: 103 additions & 0 deletions src/main/java/io/logdash/sdk/event/EventEntry.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package io.logdash.sdk.event;

import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Pattern;

/**
* Immutable data container representing an event to be sent to Logdash.
*
* <p>An event entry consists of:
*
* <ul>
* <li>A name matching {@code ^[a-z][a-z0-9_]{0,63}$}, such as {@code agent_run}
* <li>Flat properties with string, number or boolean values
* <li>The signed-in user's internal ID, never an email address
* <li>A UUID v4 that retries reuse, so the event is stored once
* </ul>
*
* @param name the event name, must match {@code ^[a-z][a-z0-9_]{0,63}$}
* @param props the event properties; null keys and values are left out, and values other than
* strings, numbers and booleans become strings
* @param userId the signed-in user's internal ID of up to 256 characters, never an email address;
* null when not set or blank
* @param id the UUID v4 of this event, generated when null
*/
public record EventEntry(String name, Map<String, Object> props, String userId, UUID id) {
private static final Pattern NAME = Pattern.compile("[a-z][a-z0-9_]{0,63}");
private static final Set<String> WEBSITE_TRACKER_EVENTS =
Set.of("pageview", "pageleave", "browser_error");
private static final int MAX_USER_ID_LENGTH = 256;

/**
* Validates and normalizes the event entry parameters during construction.
*
* @throws IllegalArgumentException if the name is invalid or belongs to the website tracker, or
* the user ID contains {@code @} or is too long
*/
public EventEntry {
if (name == null || !NAME.matcher(name).matches()) {
throw new IllegalArgumentException(
"Event name must match ^[a-z][a-z0-9_]{0,63}$, got: " + name);
}
if (WEBSITE_TRACKER_EVENTS.contains(name)) {
throw new IllegalArgumentException(
"Event name " + name + " belongs to the website tracker");
}
if (userId != null && userId.isBlank()) {
userId = null;
}
if (userId != null && userId.contains("@")) {
throw new IllegalArgumentException(
"User ID of event " + name + " must be an internal ID, never an email address");
}
if (userId != null && userId.length() > MAX_USER_ID_LENGTH) {
throw new IllegalArgumentException(
"User ID of event " + name + " must be at most " + MAX_USER_ID_LENGTH + " characters");
}
props = flatten(props);
if (id == null) {
id = UUID.randomUUID();
}
}

/**
* Creates an event entry with a new UUID v4.
*
* @param name the event name
* @param props the event properties
* @param userId the signed-in user's internal ID, or null
*/
public EventEntry(String name, Map<String, Object> props, String userId) {
this(name, props, userId, null);
}

/**
* Returns the properties of this event, in the order they were given.
*
* @return an immutable copy of the props map
*/
public Map<String, Object> props() {
return Collections.unmodifiableMap(new LinkedHashMap<>(props));
}

private static Map<String, Object> flatten(Map<String, Object> props) {
var flat = new LinkedHashMap<String, Object>();
if (props != null) {
props.forEach(
(key, value) -> {
if (key != null && value != null) {
flat.put(
key,
value instanceof Number || value instanceof Boolean
? value
: String.valueOf(value));
}
});
}
return flat;
}
}
Loading
Loading