diff --git a/README.md b/README.md index af05125..35e3628 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,22 @@ metrics.set("memory_usage_mb", 512.5); metrics.set("queue_size", 0); ``` +## 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. +Set `app` to name this app, so the dashboard can filter logs and metrics by app: + +```java +var logdash = Logdash.builder() + .apiKey(System.getenv("LOGDASH_API_KEY")) // the domain key + .app("api") + .build(); +``` + +Every log and metric this instance sends carries `"app": "api"`. +With a domain key, the app is created on first use. +Older service keys ignore `app`. + ## View Your Data To see the logs or metrics, go to your project dashboard at [logdash.io](https://logdash.io/app/clusters) @@ -315,20 +331,19 @@ Logdash logdash = Logdash.builder() .build(); ``` -## Configuration - ### All Configuration Options | Parameter | Default | Description | |-------------------------|--------------------------|----------------------------------| | `apiKey` | `null` | Your Logdash API key (required) | +| `app` | `null` | App name sent with logs/metrics | | `baseUrl` | `https://api.logdash.io` | Logdash API endpoint | | `enableConsoleOutput` | `true` | Show logs/metrics in console | | `enableVerboseLogging` | `false` | Enable SDK debug output | | `requestTimeoutMs` | `15000` | HTTP request timeout | | `maxRetries` | `3` | Failed request retry attempts | | `retryDelayMs` | `500` | Base delay between retries | -| `maxConcurrentRequests` | `10` | Maximum concurrent HTTP requests | +| `maxConcurrentRequests` | `20` | Maximum concurrent HTTP requests | | `shutdownTimeoutMs` | `10000` | Graceful shutdown timeout | ## Performance Considerations @@ -337,7 +352,6 @@ Logdash logdash = Logdash.builder() - **Use structured logging** for better searchability and performance - **Avoid logging sensitive data** (passwords, tokens, PII) - **The SDK is async** - logging calls return immediately without blocking -- **Memory efficient** - automatic batching and resource management ## Getting Your API Key diff --git a/src/main/java/io/logdash/sdk/Logdash.java b/src/main/java/io/logdash/sdk/Logdash.java index ca0b6fa..4a1d7ea 100644 --- a/src/main/java/io/logdash/sdk/Logdash.java +++ b/src/main/java/io/logdash/sdk/Logdash.java @@ -245,6 +245,7 @@ public static final class Builder { private long requestTimeoutMs = 15000L; private long shutdownTimeoutMs = 10000L; private int maxConcurrentRequests = 20; + private String app; private Builder() { } @@ -342,6 +343,31 @@ public Builder maxConcurrentRequests(int maxConcurrentRequests) { return this; } + /** + * Sets the name of the app that sends the data, such as {@code desktop}, {@code api} or + * {@code landing}. + * + *

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}. + * + *

{@code
+         * var logdash = Logdash.builder()
+         *         .apiKey(System.getenv("LOGDASH_API_KEY"))
+         *         .app("api")
+         *         .build();
+         * }
+ * + * @param app the app name (defaults to none; blank means none; the server cuts it to 64 + * characters) + * @return this builder + */ + public Builder app(String app) { + this.app = app; + return this; + } + /** * Builds the Logdash instance with the configured settings. * @@ -366,7 +392,8 @@ public Logdash build() { retryDelayMs, requestTimeoutMs, shutdownTimeoutMs, - maxConcurrentRequests); + maxConcurrentRequests, + app); return new Logdash(config); } } diff --git a/src/main/java/io/logdash/sdk/config/LogdashConfig.java b/src/main/java/io/logdash/sdk/config/LogdashConfig.java index 2988d37..66566d1 100644 --- a/src/main/java/io/logdash/sdk/config/LogdashConfig.java +++ b/src/main/java/io/logdash/sdk/config/LogdashConfig.java @@ -20,6 +20,8 @@ * @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 */ public record LogdashConfig( String apiKey, @@ -30,7 +32,8 @@ public record LogdashConfig( long retryDelayMs, long requestTimeoutMs, long shutdownTimeoutMs, - int maxConcurrentRequests) { + int maxConcurrentRequests, + String app) { private static final int MIN_API_KEY_LENGTH = 3; private static final int MAX_API_KEY_LENGTH = 256; @@ -43,6 +46,7 @@ public record LogdashConfig( requestTimeoutMs, shutdownTimeoutMs, maxConcurrentRequests); + app = app == null || app.isBlank() ? null : app.strip(); } /** @@ -120,6 +124,7 @@ public static final class Builder { private long requestTimeoutMs = 15000L; private long shutdownTimeoutMs = 10000L; private int maxConcurrentRequests = 10; + private String app = null; private Builder() { } @@ -169,6 +174,11 @@ public Builder maxConcurrentRequests(int maxConcurrentRequests) { return this; } + public Builder app(String app) { + this.app = app; + return this; + } + /** * Builds the LogdashConfig instance with the configured settings. * @@ -185,7 +195,8 @@ public LogdashConfig build() { retryDelayMs, requestTimeoutMs, shutdownTimeoutMs, - maxConcurrentRequests); + maxConcurrentRequests, + app); } } } diff --git a/src/main/java/io/logdash/sdk/transport/HttpTransport.java b/src/main/java/io/logdash/sdk/transport/HttpTransport.java index 217537b..edecad6 100644 --- a/src/main/java/io/logdash/sdk/transport/HttpTransport.java +++ b/src/main/java/io/logdash/sdk/transport/HttpTransport.java @@ -61,7 +61,7 @@ public final class HttpTransport implements LogdashTransport, AutoCloseable { */ public HttpTransport(LogdashConfig config) { this.config = config; - this.serializer = new JsonSerializer(); + this.serializer = new JsonSerializer(config.app()); this.rateLimiter = new Semaphore(config.maxConcurrentRequests(), true); this.executorService = createOptimizedExecutorService(); this.httpClient = createOptimizedHttpClient(); diff --git a/src/main/java/io/logdash/sdk/util/JsonSerializer.java b/src/main/java/io/logdash/sdk/util/JsonSerializer.java index a65535d..974a78e 100644 --- a/src/main/java/io/logdash/sdk/util/JsonSerializer.java +++ b/src/main/java/io/logdash/sdk/util/JsonSerializer.java @@ -11,6 +11,7 @@ import io.logdash.sdk.log.LogEntry; import io.logdash.sdk.metrics.MetricEntry; +import java.util.HashMap; import java.util.Map; /** @@ -25,6 +26,24 @@ public final class JsonSerializer { private static final ObjectMapper OBJECT_MAPPER = createObjectMapper(); + private final String app; + + /** + * Creates a serializer that sends no app. + */ + public JsonSerializer() { + this(null); + } + + /** + * Creates a serializer that adds the app to every log and metric. + * + * @param app the app name, or null to leave the {@code app} key out + */ + public JsonSerializer(String app) { + this.app = app; + } + private static ObjectMapper createObjectMapper() { return JsonMapper.builder() .addModule(new JavaTimeModule()) @@ -44,11 +63,15 @@ private static ObjectMapper createObjectMapper() { public String serialize(LogEntry logEntry) { try { var logData = - Map.of( - "message", sanitizeString(logEntry.message()), - "level", logEntry.level().getValue(), - "createdAt", logEntry.createdAt(), - "sequenceNumber", logEntry.sequenceNumber()); + new HashMap( + Map.of( + "message", sanitizeString(logEntry.message()), + "level", logEntry.level().getValue(), + "createdAt", logEntry.createdAt(), + "sequenceNumber", logEntry.sequenceNumber())); + if (app != null) { + logData.put("app", app); + } return OBJECT_MAPPER.writeValueAsString(logData); } catch (JsonProcessingException e) { return createFallbackLogJson(logEntry, e); @@ -64,10 +87,14 @@ public String serialize(LogEntry logEntry) { public String serialize(MetricEntry metricEntry) { try { var metricData = - Map.of( - "name", sanitizeString(metricEntry.name()), - "value", sanitizeNumber(metricEntry.value()), - "operation", metricEntry.operation().getValue()); + new HashMap( + Map.of( + "name", sanitizeString(metricEntry.name()), + "value", sanitizeNumber(metricEntry.value()), + "operation", metricEntry.operation().getValue())); + if (app != null) { + metricData.put("app", app); + } return OBJECT_MAPPER.writeValueAsString(metricData); } catch (JsonProcessingException e) { return createFallbackMetricJson(metricEntry, e); diff --git a/src/test/java/io/logdash/sdk/LogdashTest.java b/src/test/java/io/logdash/sdk/LogdashTest.java index edb7fde..7be24de 100644 --- a/src/test/java/io/logdash/sdk/LogdashTest.java +++ b/src/test/java/io/logdash/sdk/LogdashTest.java @@ -105,6 +105,7 @@ void should_configure_all_builder_options() { .requestTimeoutMs(20000L) .shutdownTimeoutMs(15000L) .maxConcurrentRequests(50) + .app("desktop") .build()) { // Assert @@ -118,6 +119,7 @@ void should_configure_all_builder_options() { assertThat(config.requestTimeoutMs()).isEqualTo(20000L); assertThat(config.shutdownTimeoutMs()).isEqualTo(15000L); assertThat(config.maxConcurrentRequests()).isEqualTo(50); + assertThat(config.app()).isEqualTo("desktop"); } } @@ -163,6 +165,7 @@ void should_handle_builder_with_all_default_values() { assertThat(config.requestTimeoutMs()).isEqualTo(15000L); assertThat(config.shutdownTimeoutMs()).isEqualTo(10000L); assertThat(config.maxConcurrentRequests()).isEqualTo(20); + assertThat(config.app()).isNull(); } } diff --git a/src/test/java/io/logdash/sdk/config/LogdashConfigTest.java b/src/test/java/io/logdash/sdk/config/LogdashConfigTest.java index f1cebd4..958d3b0 100644 --- a/src/test/java/io/logdash/sdk/config/LogdashConfigTest.java +++ b/src/test/java/io/logdash/sdk/config/LogdashConfigTest.java @@ -2,6 +2,9 @@ import io.logdash.sdk.exception.LogdashException; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; import static org.assertj.core.api.Assertions.*; @@ -21,6 +24,7 @@ void should_create_config_with_valid_parameters() { .requestTimeoutMs(15000L) .shutdownTimeoutMs(10000L) .maxConcurrentRequests(10) + .app("desktop") .build(); // Assert @@ -33,6 +37,7 @@ void should_create_config_with_valid_parameters() { assertThat(config.requestTimeoutMs()).isEqualTo(15000L); assertThat(config.shutdownTimeoutMs()).isEqualTo(10000L); assertThat(config.maxConcurrentRequests()).isEqualTo(10); + assertThat(config.app()).isEqualTo("desktop"); } @Test @@ -50,6 +55,27 @@ void should_use_default_values() { assertThat(config.requestTimeoutMs()).isEqualTo(15000L); assertThat(config.shutdownTimeoutMs()).isEqualTo(10000L); assertThat(config.maxConcurrentRequests()).isEqualTo(10); + assertThat(config.app()).isNull(); + } + + @Test + void should_trim_app() { + // Act + var config = LogdashConfig.builder().app(" desktop ").build(); + + // Assert + assertThat(config.app()).isEqualTo("desktop"); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "\t", "\n"}) + void should_treat_blank_app_as_unset(String app) { + // Act + var config = LogdashConfig.builder().app(app).build(); + + // Assert + assertThat(config.app()).isNull(); } @Test @@ -178,7 +204,8 @@ void should_validate_all_parameters_in_constructor() { 1000L, 15000L, 10000L, - 10 + 10, + null ) ).isInstanceOf(LogdashException.class) .hasMessageContaining("Base URL cannot be null or blank"); diff --git a/src/test/java/io/logdash/sdk/log/LogdashLoggerTest.java b/src/test/java/io/logdash/sdk/log/LogdashLoggerTest.java index 7c71cee..ded6c24 100644 --- a/src/test/java/io/logdash/sdk/log/LogdashLoggerTest.java +++ b/src/test/java/io/logdash/sdk/log/LogdashLoggerTest.java @@ -33,7 +33,7 @@ class LogdashLoggerTest { void setUp() { config = new LogdashConfig( - "test-key", "https://api.logdash.io", false, false, 3, 1000L, 5000L, 10000L, 100); + "test-key", "https://api.logdash.io", false, false, 3, 1000L, 5000L, 10000L, 100, null); given(transport.sendLog(any())).willReturn(CompletableFuture.completedFuture(null)); logger = new LogdashLogger(config, transport); @@ -141,7 +141,7 @@ void should_handle_console_output_when_enabled() { // Arrange var configWithConsole = new LogdashConfig( - "test-key", "https://api.logdash.io", true, false, 3, 1000L, 5000L, 10000L, 100); + "test-key", "https://api.logdash.io", true, false, 3, 1000L, 5000L, 10000L, 100, null); var loggerWithConsole = new LogdashLogger(configWithConsole, transport); // Act & Assert diff --git a/src/test/java/io/logdash/sdk/metrics/LogdashMetricsTest.java b/src/test/java/io/logdash/sdk/metrics/LogdashMetricsTest.java index 4668aa5..07be292 100644 --- a/src/test/java/io/logdash/sdk/metrics/LogdashMetricsTest.java +++ b/src/test/java/io/logdash/sdk/metrics/LogdashMetricsTest.java @@ -35,7 +35,8 @@ void setUp() { 1000L, 5000L, 10000L, - 100 + 100, + null ); given(transport.sendMetric(any())).willReturn(CompletableFuture.completedFuture(null)); diff --git a/src/test/java/io/logdash/sdk/transport/HttpTransportTest.java b/src/test/java/io/logdash/sdk/transport/HttpTransportTest.java index 1f73fd0..e49844b 100644 --- a/src/test/java/io/logdash/sdk/transport/HttpTransportTest.java +++ b/src/test/java/io/logdash/sdk/transport/HttpTransportTest.java @@ -365,6 +365,58 @@ void should_send_correct_json_for_metrics() throws Exception { .withRequestBody(containing("\"operation\":\"set\""))); } + @Test + void should_send_app_with_logs_and_metrics_when_set() { + // Arrange + wireMockServer.stubFor(post(urlEqualTo("/logs")).willReturn(aResponse().withStatus(200))); + wireMockServer.stubFor(put(urlEqualTo("/metrics")).willReturn(aResponse().withStatus(200))); + + var configWithApp = + LogdashConfig.builder() + .apiKey("test-api-key") + .baseUrl("http://localhost:" + wireMockServer.port()) + .app("desktop") + .build(); + + try (var appTransport = new HttpTransport(configWithApp)) { + // Act + var logFuture = appTransport.sendLog(new LogEntry("Test message", LogLevel.INFO, 1L)); + var metricFuture = + appTransport.sendMetric(new MetricEntry("test_metric", 42, MetricType.SET)); + + // Assert + assertThat(logFuture).succeedsWithin(3, TimeUnit.SECONDS); + assertThat(metricFuture).succeedsWithin(3, TimeUnit.SECONDS); + } + + wireMockServer.verify( + postRequestedFor(urlEqualTo("/logs")) + .withRequestBody(matchingJsonPath("$.app", equalTo("desktop")))); + wireMockServer.verify( + putRequestedFor(urlEqualTo("/metrics")) + .withRequestBody(matchingJsonPath("$.app", equalTo("desktop")))); + } + + @Test + void should_leave_app_out_of_logs_and_metrics_when_unset() { + // Arrange + wireMockServer.stubFor(post(urlEqualTo("/logs")).willReturn(aResponse().withStatus(200))); + wireMockServer.stubFor(put(urlEqualTo("/metrics")).willReturn(aResponse().withStatus(200))); + + // Act + var logFuture = transport.sendLog(new LogEntry("Test message", LogLevel.INFO, 1L)); + var metricFuture = transport.sendMetric(new MetricEntry("test_metric", 42, MetricType.SET)); + + // Assert + assertThat(logFuture).succeedsWithin(3, TimeUnit.SECONDS); + assertThat(metricFuture).succeedsWithin(3, TimeUnit.SECONDS); + + wireMockServer.verify( + postRequestedFor(urlEqualTo("/logs")).withRequestBody(notContaining("\"app\""))); + wireMockServer.verify( + putRequestedFor(urlEqualTo("/metrics")).withRequestBody(notContaining("\"app\""))); + } + @Test @Timeout(15) void should_handle_high_throughput_requests_with_realistic_expectations()