diff --git a/build-extras.gradle b/build-extras.gradle index fad8b710..f6dcc02b 100644 --- a/build-extras.gradle +++ b/build-extras.gradle @@ -28,3 +28,25 @@ tasks.named('test', Test) { events "passed" } } + +tasks.register('generatePomProperties') { + + def propertiesFile = layout.buildDirectory.file("resources/main/META-INF/maven/com.clerk/backend-api/pom.properties").get().asFile + outputs.file(propertiesFile) + + doLast { + + def props = new Properties() + props.put("groupId", project.group) + props.put("version", project.version) + + def jarTask = tasks.named("jar").get() + props.put("artifactId", jarTask.archiveBaseName.get()) + + + propertiesFile.parentFile.mkdirs() + propertiesFile.withWriter { props.store(it, null) } + } +} + +processResources.dependsOn generatePomProperties \ No newline at end of file diff --git a/src/main/java/com/clerk/backend_api/hooks/SDKHooks.java b/src/main/java/com/clerk/backend_api/hooks/SDKHooks.java index e1877c62..74d9ff31 100644 --- a/src/main/java/com/clerk/backend_api/hooks/SDKHooks.java +++ b/src/main/java/com/clerk/backend_api/hooks/SDKHooks.java @@ -1,4 +1,4 @@ -/* +/* * Code generated by Speakeasy (https://speakeasyapi.dev). DO NOT EDIT. */ @@ -10,6 +10,16 @@ // consequence any customization of this class will be preserved. // +import com.clerk.backend_api.hooks.telemetry.TelemetryAfterErrorHook; +import com.clerk.backend_api.hooks.telemetry.TelemetryAfterSuccessHook; +import com.clerk.backend_api.hooks.telemetry.TelemetryBeforeRequestHook; +import com.clerk.backend_api.hooks.telemetry.TelemetryCollector; +import com.clerk.backend_api.utils.Hooks; +import io.jsonwebtoken.lang.Objects; + +import java.util.ArrayList; +import java.util.List; + public final class SDKHooks { private SDKHooks() { @@ -18,12 +28,33 @@ private SDKHooks() { public static final void initialize(com.clerk.backend_api.utils.Hooks hooks) { // register hooks here - + // for more information see // https://www.speakeasyapi.dev/docs/additional-features/sdk-hooks ClerkBeforeRequestHook clerkBeforeRequestHook = new ClerkBeforeRequestHook(); hooks.registerBeforeRequest(clerkBeforeRequestHook); + + configureTelemetry(hooks, System.getenv("CLERK_TELEMETRY_DISABLED"), System.getenv("CLERK_TELEMETRY_DEBUG")); } - + + static void configureTelemetry( + Hooks hooks, + String clerkTelemetryDisabledEnvVar, + String clerkTelemetryDebugEnvVar) { + if (Objects.nullSafeEquals(clerkTelemetryDisabledEnvVar, "1")) { + return; + } + + List collectors = new ArrayList<>(2); + collectors.add(TelemetryCollector.live()); + if (Objects.nullSafeEquals(clerkTelemetryDebugEnvVar, "1")) { + collectors.add(new TelemetryCollector.DebugCollector()); + } + + hooks.registerBeforeRequest(new TelemetryBeforeRequestHook(collectors)); + hooks.registerAfterSuccess(new TelemetryAfterSuccessHook(collectors)); + hooks.registerAfterError(new TelemetryAfterErrorHook(collectors)); + } + } diff --git a/src/main/java/com/clerk/backend_api/hooks/telemetry/PreparedEvent.java b/src/main/java/com/clerk/backend_api/hooks/telemetry/PreparedEvent.java new file mode 100644 index 00000000..51379ee6 --- /dev/null +++ b/src/main/java/com/clerk/backend_api/hooks/telemetry/PreparedEvent.java @@ -0,0 +1,35 @@ +package com.clerk.backend_api.hooks.telemetry; + +import java.util.Map; +import java.util.TreeMap; + +public class PreparedEvent { + public final String event; + public final String it; + public final String sdk; + public final String sdkv; + public final String sk; + public final Map payload; + + public PreparedEvent(String event, String it, String sdk, String sdkv, String sk, Map payload) { + this.event = event; + this.it = it; + this.sdk = sdk; + this.sdkv = sdkv; + this.sk = sk; + this.payload = payload; + } + + public TreeMap sanitize() { + TreeMap sanitizedEvent = new TreeMap<>(); + + sanitizedEvent.put("event", event); + sanitizedEvent.put("it", it); + sanitizedEvent.put("sdk", sdk); + sanitizedEvent.put("sdkv", sdkv); + sanitizedEvent.putAll(payload); + + return sanitizedEvent; + } + +} diff --git a/src/main/java/com/clerk/backend_api/hooks/telemetry/SdkInfo.java b/src/main/java/com/clerk/backend_api/hooks/telemetry/SdkInfo.java new file mode 100644 index 00000000..2d0c95ef --- /dev/null +++ b/src/main/java/com/clerk/backend_api/hooks/telemetry/SdkInfo.java @@ -0,0 +1,54 @@ +package com.clerk.backend_api.hooks.telemetry; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Optional; +import java.util.Properties; + +public class SdkInfo { + public String version; + public String name; + public String groupId; + + public SdkInfo(String version, String name, String groupId) { + this.version = version; + this.name = name; + this.groupId = groupId; + } + + @Override + public String toString() { + return "{" + + "\"version\":\"" + version + '"' + + ",\"name\":\"" + name + '"' + + ",\"groupId\":\"'" + groupId + '"' + + '}'; + } + + + public static Optional loadFromResources() { + String[] possiblePaths = { + "/META-INF/maven/com.clerk/backend-api/pom.properties", + "META-INF/maven/com.clerk/backend-api/pom.properties", + "build/resources/main/META-INF/maven/com.clerk/backend-api/pom.properties" + }; + + for (String path : possiblePaths) { + try (InputStream input = SdkInfo.class.getClassLoader().getResourceAsStream(path)) { + if (input != null) { + Properties properties = new Properties(); + properties.load(input); + return Optional.of(new SdkInfo( + properties.getProperty("version"), + properties.getProperty("artifactId"), + properties.getProperty("groupId"))); + } + } catch (IOException e) { + return Optional.empty(); + } + } + + return Optional.empty(); + } + +} diff --git a/src/main/java/com/clerk/backend_api/hooks/telemetry/TelemetryAfterErrorHook.java b/src/main/java/com/clerk/backend_api/hooks/telemetry/TelemetryAfterErrorHook.java new file mode 100644 index 00000000..86c8195c --- /dev/null +++ b/src/main/java/com/clerk/backend_api/hooks/telemetry/TelemetryAfterErrorHook.java @@ -0,0 +1,48 @@ +package com.clerk.backend_api.hooks.telemetry; + +import com.clerk.backend_api.utils.Hook; + +import java.io.InputStream; +import java.net.http.HttpResponse; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class TelemetryAfterErrorHook implements Hook.AfterError { + + // visible for testing + public final List collectors; + + public TelemetryAfterErrorHook(List collectors) { + this.collectors = collectors; + } + + @SuppressWarnings("OptionalIsPresent") + @Override + public HttpResponse afterError(Hook.AfterErrorContext context, Optional> response, Optional error) throws Exception { + Map additionalPayload = new HashMap<>(); + if (response.isPresent()) { + additionalPayload.put("status_code", String.valueOf(response.get().statusCode())); + } + if (error.isPresent()) { + additionalPayload.put("error_message", error.get().getMessage()); + } + + TelemetryEvent event = TelemetryEvent.fromContext( + context, + TelemetryEvent.EVENT_METHOD_FAILED, + 0.1f, + additionalPayload + ); + collectors.forEach(c -> c.collect(event)); + if (response.isPresent()) { + return response.get(); + } else if (error.isPresent()) { + throw error.get(); + } else { + // should not happen since one of response or error should be present + throw new IllegalStateException("afterError called with no response or error"); + } + } +} diff --git a/src/main/java/com/clerk/backend_api/hooks/telemetry/TelemetryAfterSuccessHook.java b/src/main/java/com/clerk/backend_api/hooks/telemetry/TelemetryAfterSuccessHook.java new file mode 100644 index 00000000..5d4b0fef --- /dev/null +++ b/src/main/java/com/clerk/backend_api/hooks/telemetry/TelemetryAfterSuccessHook.java @@ -0,0 +1,30 @@ +package com.clerk.backend_api.hooks.telemetry; + +import com.clerk.backend_api.utils.Hook; + +import java.io.InputStream; +import java.net.http.HttpResponse; +import java.util.List; +import java.util.Map; + +public class TelemetryAfterSuccessHook implements Hook.AfterSuccess { + + // visible for testing + public final List collectors; + + public TelemetryAfterSuccessHook(List collectors) { + this.collectors = collectors; + } + + @Override + public HttpResponse afterSuccess(Hook.AfterSuccessContext context, HttpResponse response) throws Exception { + TelemetryEvent event = TelemetryEvent.fromContext( + context, + TelemetryEvent.EVENT_METHOD_SUCCEEDED, + 0.1f, + Map.of("status_code", String.valueOf(response.statusCode())) + ); + collectors.forEach(c -> c.collect(event)); + return response; + } +} diff --git a/src/main/java/com/clerk/backend_api/hooks/telemetry/TelemetryBeforeRequestHook.java b/src/main/java/com/clerk/backend_api/hooks/telemetry/TelemetryBeforeRequestHook.java new file mode 100644 index 00000000..683ce558 --- /dev/null +++ b/src/main/java/com/clerk/backend_api/hooks/telemetry/TelemetryBeforeRequestHook.java @@ -0,0 +1,30 @@ +package com.clerk.backend_api.hooks.telemetry; + +import com.clerk.backend_api.utils.Hook; + +import java.net.http.HttpRequest; +import java.util.List; +import java.util.Map; + +public class TelemetryBeforeRequestHook implements Hook.BeforeRequest { + + // only visible so we can test + public final List collectors; + + public TelemetryBeforeRequestHook(List collectors) { + this.collectors = collectors; + } + + @Override + public HttpRequest beforeRequest(Hook.BeforeRequestContext context, HttpRequest request) throws Exception { + TelemetryEvent event = TelemetryEvent.fromContext( + context, + TelemetryEvent.EVENT_METHOD_CALLED, + 0.1f, + Map.of() + ); + collectors.forEach(c -> c.collect(event)); + return request; + + } +} diff --git a/src/main/java/com/clerk/backend_api/hooks/telemetry/TelemetryCollector.java b/src/main/java/com/clerk/backend_api/hooks/telemetry/TelemetryCollector.java new file mode 100644 index 00000000..51be4955 --- /dev/null +++ b/src/main/java/com/clerk/backend_api/hooks/telemetry/TelemetryCollector.java @@ -0,0 +1,150 @@ +package com.clerk.backend_api.hooks.telemetry; + +import com.clerk.backend_api.utils.JSON; +import com.fasterxml.jackson.core.JsonProcessingException; +import org.apache.commons.io.IOUtils; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; +import java.util.TreeMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.logging.Level; +import java.util.logging.Logger; + +public interface TelemetryCollector { + + void collect(TelemetryEvent event); + + static TelemetryCollector live() { + return new LiveCollector( + List.of(TelemetrySampler.RandomSampler.standard(), TelemetrySampler.DeduplicatingSampler.standard()), + Executors.newFixedThreadPool(2, r -> { + Thread t = new Thread(r); + t.setDaemon(true); + t.setName("telemetry-collector"); + return t; + })); + } + + abstract class BaseCollector implements TelemetryCollector { + + private final String sdkv; + private final String sdk; + + public BaseCollector() { + Optional sdkInfo = SdkInfo.loadFromResources(); + sdkv = sdkInfo.map(x -> x.version).orElse("unknown"); + sdk = sdkInfo.map(x -> x.groupId + ":" + x.name).orElse("java:unknown"); + } + + @Override + public final void collect(TelemetryEvent event) { + if (event.it.equals("development")) { + collectInternal(event); + } + } + + protected String serializeToJson(PreparedEvent preparedEvent) throws JsonProcessingException { + return JSON.getMapper().writeValueAsString(preparedEvent); + } + + protected PreparedEvent prepareEvent(TelemetryEvent event) { + // We use a TreeMap to ensure the order of the fields in the JSON + // Here just for convenience + return new PreparedEvent( + event.event, + event.it, + sdk, + sdkv, + event.sk, + new TreeMap<>(event.payload) + ); + } + + protected abstract void collectInternal(TelemetryEvent event); + + } + + class DebugCollector extends BaseCollector { + @Override + protected void collectInternal(TelemetryEvent event) { + try { + System.err.println(serializeToJson(prepareEvent(event))); + } catch (JsonProcessingException e) { + System.err.println("Failed to serialize event: " + e.getMessage()); + } + } + + } + + class LiveCollector extends BaseCollector { + private static final String ENDPOINT = "http://localhost:3000/"; + private final ExecutorService svc; + private final List samplers; + + private static final Logger logger = Logger.getLogger(LiveCollector.class.getName()); + + public LiveCollector(List samplers, ExecutorService svc) { + super(); + this.svc = svc; + this.samplers = samplers; + } + + @Override + protected void collectInternal(TelemetryEvent event) { + PreparedEvent preparedEvent = prepareEvent(event); + for (TelemetrySampler sampler : samplers) { + if (!sampler.test(preparedEvent, event)) { + return; + } + } + + svc.submit(() -> sendEvent(event)); + } + + public void sendEvent(TelemetryEvent event) { + try { + URL url = new URL(ENDPOINT); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + + try { // Try-with-resources doesn't work here because a HttpURLConnection isn't AutoCloseable + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setRequestProperty("Connection", "close"); + connection.setDoOutput(true); + + String eventJson = serializeToJson(prepareEvent(event)); + try (OutputStream os = connection.getOutputStream()) { + IOUtils.write(eventJson, os, StandardCharsets.UTF_8); + } + + // Strictly we don't have to read the response, but if we don't and the server is trying to respond + // it's going to keep getting connection resets. So we read the response and log it. + int responseCode = connection.getResponseCode(); + if (responseCode != HttpURLConnection.HTTP_OK && responseCode != HttpURLConnection.HTTP_CREATED) { + InputStream errorStream = connection.getErrorStream(); + String errorMsg = errorStream != null ? IOUtils.toString(errorStream, StandardCharsets.UTF_8) : ""; + logger.log(Level.WARNING, "Failed to send telemetry event. Response code: " + responseCode + ", error: " + errorMsg); + } else { + InputStream responseStream = connection.getInputStream(); + String response = responseStream != null ? IOUtils.toString(responseStream, StandardCharsets.UTF_8) : ""; + logger.log(Level.FINE, "Telemetry event sent successfully. Response: " + response); + } + + } finally { + connection.disconnect(); + } + } catch (Exception e) { + // Log the exception but don't let it propagate + System.err.println("Error sending telemetry event: " + e.getMessage()); + } + } + } + +} diff --git a/src/main/java/com/clerk/backend_api/hooks/telemetry/TelemetryEvent.java b/src/main/java/com/clerk/backend_api/hooks/telemetry/TelemetryEvent.java new file mode 100644 index 00000000..1f1eb630 --- /dev/null +++ b/src/main/java/com/clerk/backend_api/hooks/telemetry/TelemetryEvent.java @@ -0,0 +1,52 @@ +package com.clerk.backend_api.hooks.telemetry; + +import com.clerk.backend_api.utils.Hook; + +import java.util.Map; +import java.util.TreeMap; + +public class TelemetryEvent { + public static final String EVENT_METHOD_CALLED = "METHOD_CALLED"; + public static final String EVENT_METHOD_SUCCEEDED = "METHOD_SUCCEEDED"; + public static final String EVENT_METHOD_FAILED = "METHOD_FAILED"; + + public final String sk; + public final String it; + public final String event; + public final Map payload; + public final float samplingRate; + + public TelemetryEvent( + String sk, + String event, + Map payload, + float samplingRate + ) { + this.sk = sk; + this.it = sk != null && sk.startsWith("sk_test") ? "development" : "production"; + this.event = event; + this.payload = payload; + this.samplingRate = samplingRate; + } + + public static TelemetryEvent fromContext( + Hook.HookContext ctx, + String event, + float samplingRate, + Map additionalPayload + ) { + String sk = ctx.securitySource() + .flatMap(x -> x.getSecurity().bearerAuth()) + .orElse("unknown"); + Map payload = new TreeMap<>(); + payload.put("method", ctx.operationId()); + payload.putAll(additionalPayload); + return new TelemetryEvent( + sk, + event, + payload, + samplingRate + ); + } + +} diff --git a/src/main/java/com/clerk/backend_api/hooks/telemetry/TelemetrySampler.java b/src/main/java/com/clerk/backend_api/hooks/telemetry/TelemetrySampler.java new file mode 100644 index 00000000..680b1994 --- /dev/null +++ b/src/main/java/com/clerk/backend_api/hooks/telemetry/TelemetrySampler.java @@ -0,0 +1,65 @@ +package com.clerk.backend_api.hooks.telemetry; + +import com.clerk.backend_api.utils.JSON; +import com.fasterxml.jackson.core.JsonProcessingException; + +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Random; +import java.util.function.BiPredicate; + +public interface TelemetrySampler extends BiPredicate { + + class RandomSampler implements TelemetrySampler { + private final Random random; + + public RandomSampler(Random random) { + this.random = random; + } + + @Override + public boolean test(PreparedEvent preparedEvent, TelemetryEvent event) { + return random.nextDouble(0, 1.0) < event.samplingRate; + } + + public static RandomSampler standard() { + return new RandomSampler(new Random(1L)); + } + } + + class DeduplicatingSampler implements TelemetrySampler { + private final HashMap cache; + private final Duration window; + private final Clock clock; + + public DeduplicatingSampler(Duration window, Clock clock) { + this.window = window; + this.clock = clock; + this.cache = new HashMap<>(16); + } + + @Override + public boolean test(PreparedEvent preparedEvent, TelemetryEvent event) { + try { + String key = JSON.getMapper().writeValueAsString(preparedEvent.sanitize()); + LocalDateTime now = LocalDateTime.now(clock); + LocalDateTime lastSampled = cache.get(key); + + if (lastSampled == null || !Duration.between(lastSampled, now).minus(window).isNegative()) { + cache.put(key, now); + return true; + } + } catch (JsonProcessingException ignored) { + } + return false; + } + + public static TelemetrySampler standard() { + return new DeduplicatingSampler(Duration.ofDays(1), Clock.systemUTC()); + } + + } + +} diff --git a/src/test/java/com/clerk/backend_api/hooks/SDKHooksTest.java b/src/test/java/com/clerk/backend_api/hooks/SDKHooksTest.java new file mode 100644 index 00000000..f21d9ee9 --- /dev/null +++ b/src/test/java/com/clerk/backend_api/hooks/SDKHooksTest.java @@ -0,0 +1,116 @@ +package com.clerk.backend_api.hooks; + +import com.clerk.backend_api.hooks.telemetry.TelemetryAfterErrorHook; +import com.clerk.backend_api.hooks.telemetry.TelemetryAfterSuccessHook; +import com.clerk.backend_api.hooks.telemetry.TelemetryBeforeRequestHook; +import com.clerk.backend_api.utils.Hook; +import com.clerk.backend_api.utils.Hooks; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class SDKHooksTest { + + private TestableHooks hooks; + + @BeforeEach + void setUp() { + hooks = new TestableHooks(); + } + + @Test + void testInitialize_RegistersClerkBeforeRequestHook() { + SDKHooks.initialize(hooks); + + boolean hasClerkBeforeRequestHook = hooks.beforeRequestHooks.stream() + .anyMatch(hook -> hook instanceof ClerkBeforeRequestHook); + assertTrue(hasClerkBeforeRequestHook, "Should register ClerkBeforeRequestHook"); + } + + @Test + void testConfigureTelemetry_WithTelemetryEnabled_RegistersAllTelemetryHooks() { + SDKHooks.configureTelemetry(hooks, null, null); + + boolean hasBeforeRequestTelemetry = hooks.beforeRequestHooks.stream() + .anyMatch(hook -> hook instanceof TelemetryBeforeRequestHook); + boolean hasAfterSuccessTelemetry = hooks.afterSuccessHooks.stream() + .anyMatch(hook -> hook instanceof TelemetryAfterSuccessHook); + boolean hasAfterErrorTelemetry = hooks.afterErrorHooks.stream() + .anyMatch(hook -> hook instanceof TelemetryAfterErrorHook); + + assertTrue(hasBeforeRequestTelemetry, "Should register TelemetryBeforeRequestHook"); + assertTrue(hasAfterSuccessTelemetry, "Should register TelemetryAfterSuccessHook"); + assertTrue(hasAfterErrorTelemetry, "Should register TelemetryAfterErrorHook"); + } + + @Test + void testConfigureTelemetry_WithTelemetryDisabled_DoesNotRegisterTelemetryHooks() { + SDKHooks.configureTelemetry(hooks, "1", null); + + assertTrue(hooks.beforeRequestHooks.isEmpty(), "Should not register any before request telemetry hooks"); + assertTrue(hooks.afterSuccessHooks.isEmpty(), "Should not register any after success hooks"); + assertTrue(hooks.afterErrorHooks.isEmpty(), "Should not register any after error hooks"); + } + + @Test + void testConfigureTelemetry_WithTelemetryDebugEnabled_RegistersDebugCollector() throws Exception { + SDKHooks.configureTelemetry(hooks, null, "1"); + + { + TelemetryBeforeRequestHook telBefore = (TelemetryBeforeRequestHook) hooks.beforeRequestHooks.stream() + .filter(hook -> hook instanceof TelemetryBeforeRequestHook) + .findFirst() + .orElseThrow(() -> new AssertionError("TelemetryBeforeRequestHook not found")); + + assertEquals(2, telBefore.collectors.size(), "Should have 2 collectors when debug is enabled"); + } + + { + TelemetryAfterSuccessHook telAfter = (TelemetryAfterSuccessHook) hooks.afterSuccessHooks.stream() + .filter(hook -> hook instanceof TelemetryAfterSuccessHook) + .findFirst() + .orElseThrow(() -> new AssertionError("TelemetryAfterSuccessHook not found")); + + assertEquals(2, telAfter.collectors.size(), "Should have 2 collectors when debug is enabled"); + } + + { + TelemetryAfterErrorHook telError = (TelemetryAfterErrorHook) hooks.afterErrorHooks.stream() + .filter(hook -> hook instanceof TelemetryAfterErrorHook) + .findFirst() + .orElseThrow(() -> new AssertionError("TelemetryAfterErrorHook not found")); + + assertEquals(2, telError.collectors.size(), "Should have 2 collectors when debug is enabled"); + } + } + + /** + * A testable version of Hooks that exposes registered hooks for assertion + */ + private static class TestableHooks extends Hooks { + final List beforeRequestHooks = new java.util.ArrayList<>(); + final List afterSuccessHooks = new java.util.ArrayList<>(); + final List afterErrorHooks = new java.util.ArrayList<>(); + + @Override + public Hooks registerBeforeRequest(Hook.BeforeRequest hook) { + beforeRequestHooks.add(hook); + return this; + } + + @Override + public Hooks registerAfterSuccess(Hook.AfterSuccess hook) { + afterSuccessHooks.add(hook); + return this; + } + + @Override + public Hooks registerAfterError(Hook.AfterError hook) { + afterErrorHooks.add(hook); + return this; + } + } +} \ No newline at end of file diff --git a/src/test/java/com/clerk/backend_api/hooks/telemetry/PreparedEventTest.java b/src/test/java/com/clerk/backend_api/hooks/telemetry/PreparedEventTest.java new file mode 100644 index 00000000..ebfcef80 --- /dev/null +++ b/src/test/java/com/clerk/backend_api/hooks/telemetry/PreparedEventTest.java @@ -0,0 +1,132 @@ +package com.clerk.backend_api.hooks.telemetry; + +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; +import java.util.TreeMap; + +import static org.junit.jupiter.api.Assertions.*; + +class PreparedEventTest { + + @Test + void testSanitize_ContainsAllFields() { + // Arrange + String event = "test-event"; + String it = "test-it"; + String sdk = "java"; + String sdkv = "1.0.0"; + String sk = "sk_test_123"; + Map payload = Map.of("key1", "value1", "key2", "value2"); + + PreparedEvent preparedEvent = new PreparedEvent(event, it, sdk, sdkv, sk, payload); + + // Act + TreeMap sanitized = preparedEvent.sanitize(); + + // Assert + assertEquals(event, sanitized.get("event"), "Event should be included"); + assertEquals(it, sanitized.get("it"), "It should be included"); + assertEquals(sdk, sanitized.get("sdk"), "SDK should be included"); + assertEquals(sdkv, sanitized.get("sdkv"), "SDKV should be included"); + assertEquals("value1", sanitized.get("key1"), "Payload key1 should be included"); + assertEquals("value2", sanitized.get("key2"), "Payload key2 should be included"); + } + + @Test + void testSanitize_DoesNotIncludeSk() { + // Arrange + PreparedEvent preparedEvent = new PreparedEvent( + "test-event", + "test-it", + "java", + "1.0.0", + "sk_test_123", + Map.of() + ); + + // Act + TreeMap sanitized = preparedEvent.sanitize(); + + // Assert + assertFalse(sanitized.containsKey("sk"), "SK should not be included in sanitized output"); + } + + @Test + void testSanitize_PayloadOverridesDefaultFields() { + // This is a negative test + // It's not that we want this behaviour so much as we want to document it + // Arrange + Map payload = new HashMap<>(); + payload.put("event", "overridden-event"); + payload.put("sdk", "overridden-sdk"); + + PreparedEvent preparedEvent = new PreparedEvent( + "original-event", + "test-it", + "original-sdk", + "1.0.0", + "sk_test_123", + payload + ); + + // Act + TreeMap sanitized = preparedEvent.sanitize(); + + // Assert + assertEquals("overridden-event", sanitized.get("event"), "Payload should override default fields"); + assertEquals("overridden-sdk", sanitized.get("sdk"), "Payload should override default fields"); + } + + @Test + void testSanitize_HandlesEmptyPayload() { + // Arrange + PreparedEvent preparedEvent = new PreparedEvent( + "test-event", + "test-it", + "java", + "1.0.0", + "sk_test_123", + Map.of() + ); + + // Act + TreeMap sanitized = preparedEvent.sanitize(); + + // Assert + assertEquals(4, sanitized.size(), "Should have 4 entries with empty payload"); + assertEquals("test-event", sanitized.get("event")); + assertEquals("test-it", sanitized.get("it")); + assertEquals("java", sanitized.get("sdk")); + assertEquals("1.0.0", sanitized.get("sdkv")); + } + + @Test + void testSanitize_SortsKeys() { + // Arrange + Map payload = new HashMap<>(); + payload.put("z-key", "z-value"); + payload.put("a-key", "a-value"); + payload.put("m-key", "m-value"); + + PreparedEvent preparedEvent = new PreparedEvent( + "test-event", + "test-it", + "java", + "1.0.0", + "sk_test_123", + payload + ); + + // Act + TreeMap sanitized = preparedEvent.sanitize(); + + // Assert + // TreeMap sorts keys alphabetically + String[] expectedOrder = {"a-key", "event", "it", "m-key", "sdk", "sdkv", "z-key"}; + String[] actualOrder = sanitized.keySet().toArray(new String[0]); + + assertArrayEquals(expectedOrder, actualOrder, "Keys should be in alphabetical order"); + } +} diff --git a/src/test/java/com/clerk/backend_api/hooks/telemetry/TelemetryAfterErrorHookTest.java b/src/test/java/com/clerk/backend_api/hooks/telemetry/TelemetryAfterErrorHookTest.java new file mode 100644 index 00000000..de2fb2f8 --- /dev/null +++ b/src/test/java/com/clerk/backend_api/hooks/telemetry/TelemetryAfterErrorHookTest.java @@ -0,0 +1,83 @@ +package com.clerk.backend_api.hooks.telemetry; + +import com.clerk.backend_api.SecuritySource; +import com.clerk.backend_api.hooks.telemetry.TelemetryBeforeRequestHookTest.TestCollector; +import com.clerk.backend_api.models.components.Security; +import com.clerk.backend_api.utils.Hook; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.net.http.HttpResponse; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +class TelemetryAfterErrorHookTest { + + @Test + void testAfterError_WithResponse_CollectsEventAndReturnsResponse() throws Exception { + // Arrange + TestCollector collector = new TestCollector(); + TelemetryAfterErrorHook hook = new TelemetryAfterErrorHook(List.of(collector)); + + String operationId = "test-operation"; + String expectedSk = "abc"; + Hook.AfterErrorContext context = new Hook.AfterErrorContextImpl( + operationId, + Optional.empty(), + Optional.of(new SecuritySource.DefaultSecuritySource(new Security(Optional.of(expectedSk))))); + + HttpResponse originalResponse = new TelemetryAfterSuccessHookTest.TestHttpResponse(404); + + // Act + HttpResponse resultResponse = hook.afterError(context, Optional.of(originalResponse), Optional.empty()); + + // Assert + TelemetryEvent actual = collector.collectedEvents.get(0); + assertNotNull(actual, "Event should have been collected"); + assertEquals(expectedSk, actual.sk, "SK should be set to the token"); + assertEquals(TelemetryEvent.EVENT_METHOD_FAILED, actual.event, "Event type should be METHOD_FAILED"); + assertEquals(0.1f, actual.samplingRate, "Sampling rate should be 0.1f"); + assertEquals(operationId, actual.payload.get("method"), "Operation ID should be in payload"); + assertEquals("404", actual.payload.get("status_code"), "Status code should be in payload"); + assertFalse(actual.payload.containsKey("error_message"), "Error message should not be in payload"); + assertSame(originalResponse, resultResponse, "Response should be returned unchanged"); + } + + @Test + void testAfterError_WithException_CollectsEventAndRethrowsException() { + // Arrange + TestCollector collector = new TestCollector(); + TelemetryAfterErrorHook hook = new TelemetryAfterErrorHook(List.of(collector)); + + String operationId = "test-operation"; + String expectedSk = "abc"; + Hook.AfterErrorContext context = new Hook.AfterErrorContextImpl( + operationId, + Optional.empty(), + Optional.of(new SecuritySource.DefaultSecuritySource(new Security(Optional.of(expectedSk))))); + + Exception originalException = new RuntimeException("Test error message"); + + // Act & Assert + Exception thrownException = assertThrows(Exception.class, () -> + hook.afterError(context, Optional.empty(), Optional.of(originalException)) + ); + + // Assert exception + assertSame(originalException, thrownException, "Original exception should be rethrown"); + + // Assert telemetry event + TelemetryEvent actual = collector.collectedEvents.get(0); + assertNotNull(actual, "Event should have been collected"); + assertEquals(expectedSk, actual.sk, "SK should be set to the token"); + assertEquals(TelemetryEvent.EVENT_METHOD_FAILED, actual.event, "Event type should be METHOD_FAILED"); + assertEquals(0.1f, actual.samplingRate, "Sampling rate should be 0.1f"); + assertEquals(operationId, actual.payload.get("method"), "Operation ID should be in payload"); + assertFalse(actual.payload.containsKey("status_code"), "Status code should not be in payload"); + assertEquals("Test error message", actual.payload.get("error_message"), "Error message should be in payload"); + } + + +} \ No newline at end of file diff --git a/src/test/java/com/clerk/backend_api/hooks/telemetry/TelemetryAfterSuccessHookTest.java b/src/test/java/com/clerk/backend_api/hooks/telemetry/TelemetryAfterSuccessHookTest.java new file mode 100644 index 00000000..6046431d --- /dev/null +++ b/src/test/java/com/clerk/backend_api/hooks/telemetry/TelemetryAfterSuccessHookTest.java @@ -0,0 +1,107 @@ +package com.clerk.backend_api.hooks.telemetry; + +import com.clerk.backend_api.SecuritySource; +import com.clerk.backend_api.hooks.telemetry.TelemetryBeforeRequestHookTest.TestCollector; +import com.clerk.backend_api.models.components.Security; +import com.clerk.backend_api.utils.Hook; +import org.junit.jupiter.api.Test; + +import javax.net.ssl.SSLSession; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +class TelemetryAfterSuccessHookTest { + + @Test + void testAfterSuccess_CollectsEvent() throws Exception { + // Arrange + TestCollector collector = new TestCollector(); + + TelemetryAfterSuccessHook hook = new TelemetryAfterSuccessHook(List.of(collector)); + + String operationId = "test-operation"; + String expectedSk = "abc"; + Hook.AfterSuccessContext context = new Hook.AfterSuccessContextImpl( + operationId, + Optional.empty(), + Optional.of(new SecuritySource.DefaultSecuritySource(new Security(Optional.of(expectedSk))))); + + HttpResponse originalResponse = new TestHttpResponse(200); + + // Act + HttpResponse resultResponse = hook.afterSuccess(context, originalResponse); + + // Assert + TelemetryEvent actual = collector.collectedEvents.get(0); + assertNotNull(actual, "Event should have been collected"); + assertEquals(expectedSk, actual.sk, "SK should be set to the token"); + assertEquals(TelemetryEvent.EVENT_METHOD_SUCCEEDED, actual.event, "Event type should be METHOD_SUCCEEDED"); + assertEquals(0.1f, actual.samplingRate, "Sampling rate should be 0.1f"); + assertEquals(operationId, actual.payload.get("method"), "Operation ID should be in payload"); + assertEquals("200", actual.payload.get("status_code"), "Status code should be in payload"); + assertSame(originalResponse, resultResponse, "Response should be returned unchanged"); + } + + // Helper test classes + // We just make a fake even though we only need this to use statusCode + public static class TestHttpResponse implements HttpResponse { + private final int statusCode; + + public TestHttpResponse(int statusCode) { + this.statusCode = statusCode; + } + + @Override + public int statusCode() { + return statusCode; + } + + @Override + public HttpRequest request() { + return null; + } + + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return HttpHeaders.of(new java.util.HashMap<>(), (k, v) -> true); + } + + @Override + public InputStream body() { + return null; + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + + @Override + public URI uri() { + try { + return new URI("https://example.com"); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Override + public HttpClient.Version version() { + return HttpClient.Version.HTTP_2; + } + } + +} \ No newline at end of file diff --git a/src/test/java/com/clerk/backend_api/hooks/telemetry/TelemetryBeforeRequestHookTest.java b/src/test/java/com/clerk/backend_api/hooks/telemetry/TelemetryBeforeRequestHookTest.java new file mode 100644 index 00000000..923e1af9 --- /dev/null +++ b/src/test/java/com/clerk/backend_api/hooks/telemetry/TelemetryBeforeRequestHookTest.java @@ -0,0 +1,59 @@ +package com.clerk.backend_api.hooks.telemetry; + +import com.clerk.backend_api.SecuritySource; +import com.clerk.backend_api.models.components.Security; +import com.clerk.backend_api.utils.Hook; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.net.http.HttpRequest; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +class TelemetryBeforeRequestHookTest { + + + @Test + void testBeforeRequest_CollectsEvent() throws Exception { + // Arrange + TestCollector collector = new TestCollector(); + + TelemetryBeforeRequestHook hook = new TelemetryBeforeRequestHook(List.of(collector)); + + String operationId = "test-operation"; + String expectedSk = "abc"; + Hook.BeforeRequestContext context = new Hook.BeforeRequestContextImpl( + operationId, + Optional.empty(), + Optional.of(new SecuritySource.DefaultSecuritySource(new Security(Optional.of(expectedSk))))); + + HttpRequest originalRequest = HttpRequest.newBuilder() + .uri(new URI("http://example.com")) + .build(); + + // Act + HttpRequest resultRequest = hook.beforeRequest(context, originalRequest); + + // Assert + TelemetryEvent actual = collector.collectedEvents.get(0); + assertNotNull(actual, "Event should have been collected"); + assertEquals("abc", actual.sk, "SK should be set to the token"); + assertEquals(TelemetryEvent.EVENT_METHOD_CALLED, actual.event, "Event type should be METHOD_CALLED"); + assertEquals(0.1f, actual.samplingRate, "Sampling rate should be 0.1f"); + assertEquals(operationId, actual.payload.get("method"), "Operation ID should be in payload"); + assertSame(originalRequest, resultRequest, "Request should be returned unchanged"); + } + + // Helper test classes + static class TestCollector implements TelemetryCollector { + public final List collectedEvents = new ArrayList<>(); + @Override + public void collect(TelemetryEvent event) { + collectedEvents.add(event); + } + } + +} \ No newline at end of file diff --git a/src/test/java/com/clerk/backend_api/hooks/telemetry/TelemetryCollectorTest.java b/src/test/java/com/clerk/backend_api/hooks/telemetry/TelemetryCollectorTest.java new file mode 100644 index 00000000..ae47e14e --- /dev/null +++ b/src/test/java/com/clerk/backend_api/hooks/telemetry/TelemetryCollectorTest.java @@ -0,0 +1,292 @@ +package com.clerk.backend_api.hooks.telemetry; + +import com.fasterxml.jackson.core.JsonProcessingException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.AbstractExecutorService; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +class TelemetryCollectorTest { + + private final ByteArrayOutputStream errContent = new ByteArrayOutputStream(); + private final PrintStream originalErr = System.err; + + @BeforeEach + void setUpStreams() { + System.setErr(new PrintStream(errContent)); + } + + @AfterEach + void restoreStreams() { + System.setErr(originalErr); + } + + @Test + void testBaseCollector_CollectIgnoresProductionEvents() { + // Arrange + TelemetryEvent prodEvent = new TelemetryEvent( + "sk_live_123", + TelemetryEvent.EVENT_METHOD_CALLED, + Map.of("method", "test-method"), + 1.0f + ); + + TestCollector collector = new TestCollector(); + + // Act + collector.collect(prodEvent); + + // Assert + assertFalse(collector.wasCollectInternalCalled, "collectInternal should not be called for production events"); + } + + @Test + void testBaseCollector_CollectCallsCollectInternalForDevelopment() { + // Arrange + TelemetryEvent devEvent = new TelemetryEvent( + "sk_test_123", + TelemetryEvent.EVENT_METHOD_CALLED, + Map.of("method", "test-method"), + 1.0f + ); + + TestCollector collector = new TestCollector(); + + // Act + collector.collect(devEvent); + + // Assert + assertTrue(collector.wasCollectInternalCalled, "collectInternal should be called for development events"); + assertEquals(devEvent, collector.lastEvent, "The event passed to collectInternal should match"); + } + + @Test + void testBaseCollector_PrepareEventPopulatesAllFields() { + // Arrange + TelemetryEvent event = new TelemetryEvent( + "sk_test_123", + TelemetryEvent.EVENT_METHOD_CALLED, + Map.of("key1", "value1"), + 1.0f + ); + + TestCollector collector = new TestCollector(); + + // Act + PreparedEvent prepared = collector.prepareEvent(event); + + // Assert + assertEquals(event.event, prepared.event); + assertEquals(event.it, prepared.it); + assertNotNull(prepared.sdk); + assertNotNull(prepared.sdkv); + assertEquals(event.sk, prepared.sk); + assertInstanceOf(TreeMap.class, prepared.payload); + assertEquals("value1", prepared.payload.get("key1")); + } + + @Test + void testDebugCollector_PrintsToStderr() { + // Arrange + TelemetryEvent event = new TelemetryEvent( + "sk_test_123", + TelemetryEvent.EVENT_METHOD_CALLED, + Map.of("method", "test-method"), + 1.0f + ); + + ThrowingDebugCollector collector = new ThrowingDebugCollector(); + + // Act + collector.collectInternal(event); + + // Assert + String output = errContent.toString(); + assertFalse(output.isEmpty(), "Debug collector should print to stderr"); + assertTrue(output.contains(collector.serializedOutput), "Debug collector should print the serialized event"); + } + + @Test + void testDebugCollector_HandlesSerializationError() { + // Arrange + TelemetryEvent event = new TelemetryEvent( + "sk_test_123", + TelemetryEvent.EVENT_METHOD_CALLED, + Map.of("method", "test-method"), + 1.0f + ); + + ThrowingDebugCollector collector = new ThrowingDebugCollector(); + collector.throwOnSerialize = true; + + // Act + collector.collectInternal(event); + + // Assert + String output = errContent.toString(); + assertTrue(output.contains("Failed to serialize event"), "Should print error message on serialization failure"); + } + + @Test + void testLiveCollector_FiltersBySamplers() { + // Arrange + TelemetryEvent event = new TelemetryEvent( + "sk_test_123", + TelemetryEvent.EVENT_METHOD_CALLED, + Map.of("method", "test-method"), + 1.0f + ); + + // Create a sampler that always returns false + TelemetrySampler rejectingSampler = (preparedEvent, telemetryEvent) -> false; + + // Create test executor and collector + TestExecutorService executor = new TestExecutorService(); + ThrowingLiveCollector collector = new ThrowingLiveCollector(List.of(rejectingSampler), executor); + + // Act + collector.collectInternal(event); + + // Assert + assertEquals(0, executor.submissions.size(), "No task should be submitted when sampler rejects"); + } + + @Test + void testLiveCollector_SubmitsTaskWhenSamplingPasses() { + // Arrange + TelemetryEvent event = new TelemetryEvent( + "sk_test_123", + TelemetryEvent.EVENT_METHOD_CALLED, + Map.of("method", "test-method"), + 1.0f + ); + + // Create a sampler that always returns true + TelemetrySampler acceptingSampler = (preparedEvent, telemetryEvent) -> true; + + // Create test executor and collector + TestExecutorService executor = new TestExecutorService(); + ThrowingLiveCollector collector = new ThrowingLiveCollector(List.of(acceptingSampler), executor); + + // Act + collector.collectInternal(event); + + // Assert + assertEquals(1, executor.submissions.size(), "Task should be submitted when sampler accepts"); + } + + @Test + void testLiveCollector_SendEventCatchesExceptions() { + // Arrange + TelemetryEvent event = new TelemetryEvent( + "sk_test_123", + TelemetryEvent.EVENT_METHOD_CALLED, + Map.of("method", "test-method"), + 1.0f + ); + + ThrowingLiveCollector collector = new ThrowingLiveCollector( + Collections.emptyList(), Executors.newSingleThreadExecutor()); + collector.throwOnSerialize = true; + + // Act - should not throw exception out of the method + collector.sendEvent(event); + + // Assert + String output = errContent.toString(); + assertTrue(output.contains("Error sending telemetry event"), + "Should log error message when exception occurs"); + } + + // Helper test classes + // We make all of these because we don't have Mockito here and we don't want to introduce + // more dependencies just for testing + + private static class TestCollector extends TelemetryCollector.BaseCollector { + public boolean wasCollectInternalCalled = false; + public TelemetryEvent lastEvent = null; + + @Override + protected void collectInternal(TelemetryEvent event) { + wasCollectInternalCalled = true; + lastEvent = event; + } + } + + private static class ThrowingDebugCollector extends TelemetryCollector.DebugCollector { + public boolean throwOnSerialize = false; + public String serializedOutput = "{\"test\":\"json\"}"; + + @Override + protected String serializeToJson(PreparedEvent preparedEvent) throws com.fasterxml.jackson.core.JsonProcessingException { + if (throwOnSerialize) { + throw new com.fasterxml.jackson.core.JsonProcessingException("Test error") {}; + } + return serializedOutput; + } + } + + private static class ThrowingLiveCollector extends TelemetryCollector.LiveCollector { + public boolean throwOnSerialize = false; + + public ThrowingLiveCollector(List samplers, ExecutorService svc) { + super(samplers, svc); + } + + @Override + protected String serializeToJson(PreparedEvent preparedEvent) throws JsonProcessingException { + if (throwOnSerialize) { + throw new RuntimeException("Test exception"); + } + return super.serializeToJson(preparedEvent); + } + } + + static class TestExecutorService extends AbstractExecutorService { + public final List submissions = new ArrayList<>(); + + @Override + public void execute(Runnable command) { + submissions.add(command); + } + + @Override + public List shutdownNow() { + return submissions; + } + + @Override + public void shutdown() { + } + + @Override + public boolean isShutdown() { + return false; + } + + @Override + public boolean isTerminated() { + return false; + } + + @Override + public boolean awaitTermination(long timeout, TimeUnit unit) { + return true; + } + + } + +} \ No newline at end of file diff --git a/src/test/java/com/clerk/backend_api/hooks/telemetry/TelemetryEventTest.java b/src/test/java/com/clerk/backend_api/hooks/telemetry/TelemetryEventTest.java new file mode 100644 index 00000000..ef58fa44 --- /dev/null +++ b/src/test/java/com/clerk/backend_api/hooks/telemetry/TelemetryEventTest.java @@ -0,0 +1,198 @@ +package com.clerk.backend_api.hooks.telemetry; + +import com.clerk.backend_api.SecuritySource; +import com.clerk.backend_api.models.components.Security; +import com.clerk.backend_api.utils.Hook; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +class TelemetryEventTest { + + @Test + void testConstructor_InitializesAllFields() { + // Arrange + String sk = "sk_test_123"; + String event = TelemetryEvent.EVENT_METHOD_CALLED; + Map payload = Map.of("key1", "value1", "key2", "value2"); + float samplingRate = 0.5f; + + // Act + TelemetryEvent telemetryEvent = new TelemetryEvent(sk, event, payload, samplingRate); + + // Assert + assertEquals(sk, telemetryEvent.sk, "SK should match"); + assertEquals("development", telemetryEvent.it, "IT should be development for test SK"); + assertEquals(event, telemetryEvent.event, "Event should match"); + assertEquals(payload, telemetryEvent.payload, "Payload should match"); + assertEquals(samplingRate, telemetryEvent.samplingRate, "Sampling rate should match"); + } + + @ParameterizedTest + @MethodSource("provideSkValues") + void testConstructor_SetsItBasedOnSk(String sk, String expectedIt) { + // Arrange & Act + TelemetryEvent telemetryEvent = new TelemetryEvent( + sk, + TelemetryEvent.EVENT_METHOD_CALLED, + Map.of(), + 0.5f + ); + + // Assert + assertEquals(expectedIt, telemetryEvent.it, "IT should be set correctly based on SK"); + } + + private static Stream provideSkValues() { + return Stream.of( + Arguments.of("sk_test_123", "development"), + Arguments.of("sk_test_abc", "development"), + Arguments.of("sk_live_456", "production"), + // overly defensive + Arguments.of("sk_123", "production"), + Arguments.of(null, "production") + ); + } + + @Test + void testFromContext_WithSecuritySource() { + // Arrange + String operationId = "testOperation"; + String sk = "sk_test_xyz"; + Map additionalPayload = Map.of("key1", "value1"); + float samplingRate = 0.75f; + + SecuritySource securitySource = getSecuritySource(sk); + Hook.HookContext context = new TestHookContext(operationId, securitySource); + + // Act + TelemetryEvent event = TelemetryEvent.fromContext( + context, + TelemetryEvent.EVENT_METHOD_SUCCEEDED, + samplingRate, + additionalPayload + ); + + // Assert + assertEquals(sk, event.sk, "SK should be extracted from security source"); + assertEquals("development", event.it, "IT should be set to development for test SK"); + assertEquals(TelemetryEvent.EVENT_METHOD_SUCCEEDED, event.event, "Event type should match"); + assertEquals(samplingRate, event.samplingRate, "Sampling rate should match"); + assertEquals("testOperation", event.payload.get("method"), "Operation ID should be in payload"); + assertEquals("value1", event.payload.get("key1"), "Additional payload should be included"); + } + + private static SecuritySource getSecuritySource(String sk) { + return new SecuritySource.DefaultSecuritySource(new Security(Optional.of(sk))); + } + + @Test + void testFromContext_WithoutSecuritySource() { + // Arrange + String operationId = "testOperation"; + Map additionalPayload = Map.of("key1", "value1"); + float samplingRate = 0.75f; + + Hook.HookContext context = new TestHookContext(operationId, null); + + // Act + TelemetryEvent event = TelemetryEvent.fromContext( + context, + TelemetryEvent.EVENT_METHOD_FAILED, + samplingRate, + additionalPayload + ); + + // Assert + assertEquals("unknown", event.sk, "SK should be 'unknown' when security source is absent"); + assertEquals("production", event.it, "IT should be set to production for unknown SK"); + assertEquals(TelemetryEvent.EVENT_METHOD_FAILED, event.event, "Event type should match"); + assertEquals(samplingRate, event.samplingRate, "Sampling rate should match"); + assertEquals("testOperation", event.payload.get("method"), "Operation ID should be in payload"); + assertEquals("value1", event.payload.get("key1"), "Additional payload should be included"); + } + + @Test + void testFromContext_EmptyAdditionalPayload() { + // Arrange + String operationId = "testOperation"; + String sk = "sk_live_abc"; + Map emptyPayload = Map.of(); + float samplingRate = 0.1f; + + SecuritySource securitySource = getSecuritySource(sk); + Hook.HookContext context = new TestHookContext(operationId, securitySource); + + // Act + TelemetryEvent event = TelemetryEvent.fromContext( + context, + TelemetryEvent.EVENT_METHOD_CALLED, + samplingRate, + emptyPayload + ); + + // Assert + assertEquals(1, event.payload.size(), "Payload should only contain the method"); + assertEquals("testOperation", event.payload.get("method"), "Operation ID should be in payload"); + } + + @Test + void testFromContext_AdditionalPayloadOverridesMethod() { + // Not so much behaviour we desire as documentation that this occurs + // Arrange + String operationId = "testOperation"; + String sk = "sk_test_abc"; + Map overridingPayload = Map.of("method", "overridden-method"); + float samplingRate = 0.5f; + + SecuritySource securitySource = getSecuritySource(sk); + Hook.HookContext context = new TestHookContext(operationId, securitySource); + + // Act + TelemetryEvent event = TelemetryEvent.fromContext( + context, + TelemetryEvent.EVENT_METHOD_CALLED, + samplingRate, + overridingPayload + ); + + // Assert + assertEquals(1, event.payload.size(), "Payload should only contain one method entry"); + assertEquals("overridden-method", event.payload.get("method"), "Additional payload should override the method"); + } + + // Test implementations of the interfaces needed for testing + private static class TestHookContext implements Hook.HookContext { + private final String operationId; + private final SecuritySource securitySource; + + public TestHookContext(String operationId, SecuritySource securitySource) { + this.operationId = operationId; + this.securitySource = securitySource; + } + + @Override + public String operationId() { + return operationId; + } + + @Override + public Optional> oauthScopes() { + return Optional.empty(); + } + + @Override + public Optional securitySource() { + return Optional.ofNullable(securitySource); + } + } + +} \ No newline at end of file diff --git a/src/test/java/com/clerk/backend_api/hooks/telemetry/TelemetrySamplerTest.java b/src/test/java/com/clerk/backend_api/hooks/telemetry/TelemetrySamplerTest.java new file mode 100644 index 00000000..43aafacc --- /dev/null +++ b/src/test/java/com/clerk/backend_api/hooks/telemetry/TelemetrySamplerTest.java @@ -0,0 +1,198 @@ +package com.clerk.backend_api.hooks.telemetry; + +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Map; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.*; + +class TelemetrySamplerTest { + + private static TelemetryEvent testEvent(float samplingRate) { + return new TelemetryEvent( + "sk_test_123", + TelemetryEvent.EVENT_METHOD_CALLED, + Map.of("method", "test-method"), + samplingRate + ); + } + + private static PreparedEvent testPreparedEvent(TelemetryEvent event) { + return new PreparedEvent(event.event, event.it, "sdk", "sdkv", event.sk, event.payload); + } + + @Test + void testRandomSampler_WithSeedWorks() { + // Arrange + Random fixedRandom = new Random(1L); + TelemetrySampler.RandomSampler sampler = new TelemetrySampler.RandomSampler(fixedRandom); + + TelemetryEvent event = testEvent(0.5f); + PreparedEvent preparedEvent = testPreparedEvent(event); + + // Act - with a fixed seed, we should get deterministic results + boolean firstResult = sampler.test(preparedEvent, event); + boolean secondResult = sampler.test(preparedEvent, event); + + // The exact results will depend on the random seed, but we can at least verify they're different + // With seed 1, these should be predictable + assertNotEquals(firstResult, secondResult, "With seed 1, the first two samples should differ"); + } + + @Test + void testRandomSampler_SamplingRateZero_AlwaysFalse() { + // Arrange + TelemetrySampler.RandomSampler sampler = new TelemetrySampler.RandomSampler(new Random()); + + TelemetryEvent event = testEvent(0.0f); + PreparedEvent preparedEvent = testPreparedEvent(event); + + // Act & Assert + for (int i = 0; i < 100; i++) { + assertFalse(sampler.test(preparedEvent, event), "Should always return false with 0.0 sampling rate"); + } + } + + @Test + void testRandomSampler_SamplingRateOne_AlwaysTrue() { + // Arrange + TelemetrySampler.RandomSampler sampler = new TelemetrySampler.RandomSampler(new Random()); + + TelemetryEvent event = testEvent(1.0f); + PreparedEvent preparedEvent = testPreparedEvent(event); + + // Act & Assert + for (int i = 0; i < 100; i++) { + assertTrue(sampler.test(preparedEvent, event), "Should always return true with 1.0 sampling rate"); + } + } + + @Test + void testDeduplicatingSampler_FirstEventAccepted() { + // Arrange + Clock fixedClock = Clock.fixed(Instant.parse("2023-01-01T12:00:00Z"), ZoneId.of("UTC")); + TelemetrySampler.DeduplicatingSampler sampler = new TelemetrySampler.DeduplicatingSampler( + Duration.ofDays(1), fixedClock); + + TelemetryEvent event = testEvent(0.5f); + PreparedEvent preparedEvent = testPreparedEvent(event); + + // Act & Assert + assertTrue(sampler.test(preparedEvent, event), "First event should be accepted"); + } + + @Test + void testDeduplicatingSampler_DuplicateEventWithinWindowRejected() { + // Arrange + Instant start = Instant.parse("2023-01-01T12:00:00Z"); + TestClock testClock = new TestClock(start); + + TelemetrySampler.DeduplicatingSampler sampler = new TelemetrySampler.DeduplicatingSampler( + Duration.ofDays(1), testClock); + + TelemetryEvent event = testEvent(0.5f); + PreparedEvent preparedEvent = testPreparedEvent(event); + + // Act + boolean firstResult = sampler.test(preparedEvent, event); + + // Move time forward, but still within window + testClock.setInstant(start.plus(Duration.ofHours(23))); + + boolean secondResult = sampler.test(preparedEvent, event); + + // Assert + assertTrue(firstResult, "First event should be accepted"); + assertFalse(secondResult, "Duplicate event within window should be rejected"); + } + + @Test + void testDeduplicatingSampler_EventAfterWindowAccepted() { + // Arrange + Instant start = Instant.parse("2023-01-01T12:00:00Z"); + TestClock testClock = new TestClock(start); + + TelemetrySampler.DeduplicatingSampler sampler = new TelemetrySampler.DeduplicatingSampler( + Duration.ofDays(1), testClock); + + TelemetryEvent event = testEvent(0.5f); + PreparedEvent preparedEvent = testPreparedEvent(event); + + // Act + boolean firstResult = sampler.test(preparedEvent, event); + + // Move time forward beyond window + testClock.setInstant(start.plus(Duration.ofHours(25))); + + boolean secondResult = sampler.test(preparedEvent, event); + + // Assert + assertTrue(firstResult, "First event should be accepted"); + assertTrue(secondResult, "Event after window should be accepted"); + } + + @Test + void testDeduplicatingSampler_DifferentEventsAccepted() { + // Arrange + Clock fixedClock = Clock.fixed(Instant.parse("2023-01-01T12:00:00Z"), ZoneId.of("UTC")); + TelemetrySampler.DeduplicatingSampler sampler = new TelemetrySampler.DeduplicatingSampler( + Duration.ofDays(1), fixedClock); + + TelemetryEvent firstEvent = new TelemetryEvent( + "sk_test_123", + TelemetryEvent.EVENT_METHOD_CALLED, + Map.of("method", "test-method-1"), + 0.5f + ); + PreparedEvent firstPreparedEvent = testPreparedEvent(firstEvent); + + TelemetryEvent secondEvent = new TelemetryEvent( + "sk_test_123", + TelemetryEvent.EVENT_METHOD_CALLED, + Map.of("method", "test-method-2"), + 0.5f + ); + PreparedEvent secondPreparedEvent = testPreparedEvent(secondEvent); + + // Act + boolean firstResult = sampler.test(firstPreparedEvent, firstEvent); + boolean secondResult = sampler.test(secondPreparedEvent, secondEvent); + + // Assert + assertTrue(firstResult, "First event should be accepted"); + assertTrue(secondResult, "Different event should be accepted"); + } + + // A test clock implementation that allows changing the time + private static class TestClock extends Clock { + private Instant instant; + + public TestClock(Instant instant) { + this.instant = instant; + } + + public void setInstant(Instant instant) { + this.instant = instant; + } + + @Override + public ZoneId getZone() { + return ZoneId.of("UTC"); + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return instant; + } + } +}