From 3dd4e9188079cbb20e7cb969baa8d9a1c10ec811 Mon Sep 17 00:00:00 2001 From: Christopher Brady Date: Thu, 28 May 2026 08:06:06 -0600 Subject: [PATCH 1/4] handle partial messages properly --- .../schematic/api/datastream/EntityMerge.java | 123 +++++- .../api/datastream/EntityMergeTest.java | 377 ++++++++++++++++++ 2 files changed, 498 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/schematic/api/datastream/EntityMerge.java b/src/main/java/com/schematic/api/datastream/EntityMerge.java index 76d8c7e..7b9cbc6 100644 --- a/src/main/java/com/schematic/api/datastream/EntityMerge.java +++ b/src/main/java/com/schematic/api/datastream/EntityMerge.java @@ -2,10 +2,13 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.IntNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.schematic.api.core.ObjectMappers; import com.schematic.api.types.RulesengineCompany; import com.schematic.api.types.RulesengineUser; +import java.util.HashMap; import java.util.Iterator; import java.util.Map; @@ -18,11 +21,23 @@ * upsert for {@code metrics}, replace for all other fields *
  • User: additive merge for {@code keys}, replace for all other fields
  • * + * + *

    Partials don't carry refreshed entitlements, so when {@code credit_balances} + * or {@code metrics} change in another part of the company we sync the derived + * fields on existing entitlements here to match server behavior: + *

    + * Both are skipped when the partial also sends {@code entitlements} wholesale. */ final class EntityMerge { private static final ObjectMapper MAPPER = ObjectMappers.JSON_MAPPER; + /** Usage default for a matched metric that carries no value. */ + private static final IntNode ZERO = IntNode.valueOf(0); + private EntityMerge() {} /** @@ -37,6 +52,9 @@ static RulesengineCompany partialCompany(RulesengineCompany existing, JsonNode p // Serialize existing to a mutable JSON tree ObjectNode base = (ObjectNode) MAPPER.valueToTree(existing); + JsonNode updatedBalances = null; + boolean metricsUpdated = false; + Iterator> fields = partial.fields(); while (fields.hasNext()) { Map.Entry field = fields.next(); @@ -45,13 +63,17 @@ static RulesengineCompany partialCompany(RulesengineCompany existing, JsonNode p switch (key) { case "keys": - case "credit_balances": // Additive merge: overlay partial keys onto existing mergeObject(base, key, value); break; + case "credit_balances": + mergeObject(base, key, value); + updatedBalances = value; + break; case "metrics": // Upsert: match by (event_subtype, period, month_reset) upsertMetrics(base, value); + metricsUpdated = true; break; default: // Replace @@ -60,9 +82,89 @@ static RulesengineCompany partialCompany(RulesengineCompany existing, JsonNode p } } + // Partials don't carry refreshed entitlements, so re-derive credit_remaining + // and usage from the merged credit_balances/metrics. Skipped when the partial + // sent entitlements wholesale — we trust those. + if ((updatedBalances != null || metricsUpdated) && !partial.has("entitlements")) { + syncEntitlements(base, updatedBalances, metricsUpdated); + } + return MAPPER.convertValue(base, RulesengineCompany.class); } + /** + * Re-derives entitlement fields that a partial leaves stale: + * {@code credit_remaining} from the merged credit balances and {@code usage} + * from the merged metrics. Existing entitlements are rebuilt in place; the + * matching mirrors the server's effective-entitlement lookup. + * + * @param base the company JSON tree being built (with metrics/balances already merged) + * @param updatedBalances the partial's {@code credit_balances} node, or {@code null} if unchanged + * @param metricsUpdated whether the partial updated {@code metrics} + */ + private static void syncEntitlements(ObjectNode base, JsonNode updatedBalances, boolean metricsUpdated) { + JsonNode entitlements = base.get("entitlements"); + if (entitlements == null || !entitlements.isArray() || entitlements.isEmpty()) { + return; + } + + // Index merged metric values by (event_subtype, period, month_reset). + Map metricsLookup = new HashMap<>(); + if (metricsUpdated) { + JsonNode metrics = base.get("metrics"); + if (metrics != null && metrics.isArray()) { + for (JsonNode metric : metrics) { + if (metric == null || !metric.isObject()) { + continue; + } + String key = metricKey( + textOrEmpty(metric, "event_subtype"), + textOrEmpty(metric, "period"), + textOrEmpty(metric, "month_reset")); + // Mirror Python/Ruby: a metric with no value counts as 0. + JsonNode value = metric.get("value"); + metricsLookup.put(key, (value == null || value.isNull()) ? ZERO : value); + } + } + } + + boolean balancesUsable = updatedBalances != null && updatedBalances.isObject(); + + ArrayNode result = MAPPER.createArrayNode(); + for (JsonNode entNode : entitlements) { + if (entNode == null || !entNode.isObject()) { + result.add(entNode); + continue; + } + ObjectNode ent = ((ObjectNode) entNode).deepCopy(); + + if (balancesUsable) { + String creditId = textOrEmpty(ent, "credit_id"); + if (!creditId.isEmpty() && updatedBalances.has(creditId)) { + ent.set("credit_remaining", updatedBalances.get(creditId)); + } + } + + if (metricsUpdated) { + String eventName = textOrEmpty(ent, "event_name"); + if (!eventName.isEmpty()) { + // Server defaults when the entitlement omits these. + String period = textOrDefault(ent, "metric_period", "all_time"); + String monthReset = textOrDefault(ent, "month_reset", "first_of_month"); + // A matched key always sets usage; absent keys leave it unchanged. + JsonNode matched = metricsLookup.get(metricKey(eventName, period, monthReset)); + if (matched != null) { + ent.set("usage", matched); + } + } + } + + result.add(ent); + } + + base.set("entitlements", result); + } + /** * Merges a partial user update into an existing user. * Only fields present in the partial are applied. @@ -131,7 +233,7 @@ private static void upsertMetrics(ObjectNode base, JsonNode partialMetrics) { } // Build mutable list from existing - com.fasterxml.jackson.databind.node.ArrayNode result = MAPPER.createArrayNode(); + ArrayNode result = MAPPER.createArrayNode(); // Copy existing metrics, replacing any that match a partial metric for (JsonNode existing : existingMetrics) { boolean replaced = false; @@ -175,4 +277,21 @@ private static boolean textEquals(JsonNode a, JsonNode b, String field) { String bVal = b.has(field) ? b.get(field).asText("") : ""; return aVal.equals(bVal); } + + /** Composite key for matching a metric to an entitlement. */ + private static String metricKey(String eventSubtype, String period, String monthReset) { + return eventSubtype + '\0' + period + '\0' + monthReset; + } + + /** Returns the field's text value, or empty string if absent or null. */ + private static String textOrEmpty(JsonNode node, String field) { + JsonNode value = node.get(field); + return (value == null || value.isNull()) ? "" : value.asText(""); + } + + /** Returns the field's text value, or {@code dflt} if absent, null, or empty. */ + private static String textOrDefault(JsonNode node, String field, String dflt) { + String value = textOrEmpty(node, field); + return value.isEmpty() ? dflt : value; + } } diff --git a/src/test/java/com/schematic/api/datastream/EntityMergeTest.java b/src/test/java/com/schematic/api/datastream/EntityMergeTest.java index 7351129..264dc18 100644 --- a/src/test/java/com/schematic/api/datastream/EntityMergeTest.java +++ b/src/test/java/com/schematic/api/datastream/EntityMergeTest.java @@ -9,6 +9,8 @@ import com.schematic.api.types.RulesengineCompany; import com.schematic.api.types.RulesengineEntitlementValueType; import com.schematic.api.types.RulesengineFeatureEntitlement; +import com.schematic.api.types.RulesengineMetricPeriod; +import com.schematic.api.types.RulesengineMetricPeriodMonthReset; import com.schematic.api.types.RulesengineRule; import com.schematic.api.types.RulesengineRuleType; import com.schematic.api.types.RulesengineTrait; @@ -541,6 +543,300 @@ void partialUser_fullEntityPartialMessage() { assertEquals("rule-u1", merged.getRules().get(0).getId()); } + // --- Entitlement credit_remaining sync tests --- + // Credit-balance partials don't include refreshed entitlements, so the SDK syncs + // credit_remaining locally to mirror the server's partial-message handling. + + @Test + void partialCompany_syncsCreditRemainingForMatchingCreditId() { + RulesengineCompany existing = companyWithEntitlements( + Collections.singletonMap("credit-1", 100.0), + List.of( + entitlement("feat-1", "f1", "credit-1", 100.0, null, null, null, null), + // no credit_id — must stay untouched + entitlement("feat-2", "f2", null, null, null, null, null, null))); + + ObjectNode partial = objectMapper.createObjectNode(); + partial.set("credit_balances", balances("credit-1", 25.0)); + + RulesengineCompany merged = EntityMerge.partialCompany(existing, partial); + + assertEquals(25.0, merged.getCreditBalances().get("credit-1")); + assertTrue(merged.getEntitlements().isPresent()); + assertEquals( + 25.0, merged.getEntitlements().get().get(0).getCreditRemaining().get()); + assertFalse(merged.getEntitlements().get().get(1).getCreditRemaining().isPresent()); + } + + @Test + void partialCompany_syncsCreditRemainingAcrossMultipleCreditIds() { + Map existingBalances = new HashMap<>(); + existingBalances.put("credit-1", 100.0); + existingBalances.put("credit-2", 50.0); + + RulesengineCompany existing = companyWithEntitlements( + existingBalances, + List.of( + entitlement("feat-1", "f1", "credit-1", 100.0, null, null, null, null), + entitlement("feat-2", "f2", "credit-2", 50.0, null, null, null, null))); + + ObjectNode newBalances = objectMapper.createObjectNode(); + newBalances.put("credit-1", 75.0); + newBalances.put("credit-2", 10.0); + ObjectNode partial = objectMapper.createObjectNode(); + partial.set("credit_balances", newBalances); + + RulesengineCompany merged = EntityMerge.partialCompany(existing, partial); + + assertEquals( + 75.0, merged.getEntitlements().get().get(0).getCreditRemaining().get()); + assertEquals( + 10.0, merged.getEntitlements().get().get(1).getCreditRemaining().get()); + } + + @Test + void partialCompany_leavesUnmatchedEntitlementCreditIdUntouched() { + Map existingBalances = new HashMap<>(); + existingBalances.put("credit-1", 100.0); + existingBalances.put("credit-other", 999.0); + + RulesengineCompany existing = companyWithEntitlements( + existingBalances, List.of(entitlement("feat-1", "f1", "credit-other", 999.0, null, null, null, null))); + + // Partial only updates credit-1; entitlement points at credit-other. + ObjectNode partial = objectMapper.createObjectNode(); + partial.set("credit_balances", balances("credit-1", 25.0)); + + RulesengineCompany merged = EntityMerge.partialCompany(existing, partial); + + assertEquals( + 999.0, + merged.getEntitlements().get().get(0).getCreditRemaining().get()); + } + + @Test + void partialCompany_singleCreditFansOutToMultipleEntitlements() { + // One credit type can fund multiple features; a balance update must sync + // credit_remaining on every entitlement pointing at that credit. + RulesengineCompany existing = companyWithEntitlements( + Collections.singletonMap("credit-shared", 500.0), + List.of( + entitlement("feat-a", "feature-a", "credit-shared", 500.0, null, null, null, null), + entitlement("feat-b", "feature-b", "credit-shared", 500.0, null, null, null, null), + entitlement("feat-c", "feature-c", "credit-shared", 500.0, null, null, null, null), + entitlement("feat-d", "feature-d", null, null, null, null, null, null))); + + ObjectNode partial = objectMapper.createObjectNode(); + partial.set("credit_balances", balances("credit-shared", 120.0)); + + RulesengineCompany merged = EntityMerge.partialCompany(existing, partial); + + assertEquals( + 120.0, + merged.getEntitlements().get().get(0).getCreditRemaining().get()); + assertEquals( + 120.0, + merged.getEntitlements().get().get(1).getCreditRemaining().get()); + assertEquals( + 120.0, + merged.getEntitlements().get().get(2).getCreditRemaining().get()); + assertFalse(merged.getEntitlements().get().get(3).getCreditRemaining().isPresent()); + } + + @Test + void partialCompany_skipsSyncWhenPartialSendsEntitlements() { + // If the partial carries entitlements, trust those wholesale. + RulesengineCompany existing = companyWithEntitlements( + Collections.singletonMap("credit-1", 100.0), + List.of(entitlement("feat-1", "f1", "credit-1", 100.0, null, null, null, null))); + + ObjectNode partial = objectMapper.createObjectNode(); + partial.set("credit_balances", balances("credit-1", 25.0)); + ArrayNode partialEnts = objectMapper.createArrayNode(); + ObjectNode ent = objectMapper.createObjectNode(); + ent.put("feature_id", "feat-1"); + ent.put("feature_key", "f1"); + ent.put("value_type", "boolean"); + ent.put("credit_id", "credit-1"); + ent.put("credit_remaining", 17.0); + partialEnts.add(ent); + partial.set("entitlements", partialEnts); + + RulesengineCompany merged = EntityMerge.partialCompany(existing, partial); + + assertEquals( + 17.0, merged.getEntitlements().get().get(0).getCreditRemaining().get()); + } + + @Test + void partialCompany_creditSyncNoOpWhenNoEntitlements() { + RulesengineCompany existing = buildCompany("comp-1", Collections.singletonMap("id", "comp-1")); + + ObjectNode partial = objectMapper.createObjectNode(); + partial.set("credit_balances", balances("credit-1", 25.0)); + + RulesengineCompany merged = EntityMerge.partialCompany(existing, partial); + + assertEquals(25.0, merged.getCreditBalances().get("credit-1")); + assertFalse(merged.getEntitlements().isPresent()); + } + + // --- Entitlement usage sync tests --- + // A metrics partial doesn't carry refreshed entitlements, so usage is re-derived + // from the matching metric (event_subtype + period + month_reset). + + @Test + void partialCompany_syncsUsageForEventBasedEntitlement() { + RulesengineCompany existing = companyWithEntitlementsAndMetrics( + Collections.emptyMap(), + List.of(entitlement( + "feat-1", + "f1", + null, + null, + "credits_used", + RulesengineMetricPeriod.CURRENT_MONTH, + RulesengineMetricPeriodMonthReset.FIRST_OF_MONTH, + 10L)), + metric("credits_used", "current_month", "first_of_month", 10)); + + ObjectNode partial = objectMapper.createObjectNode(); + ArrayNode metrics = objectMapper.createArrayNode(); + metrics.add(metric("credits_used", "current_month", "first_of_month", 42)); + partial.set("metrics", metrics); + + RulesengineCompany merged = EntityMerge.partialCompany(existing, partial); + + assertEquals(42L, merged.getEntitlements().get().get(0).getUsage().get()); + } + + @Test + void partialCompany_usageMatchRequiresPeriodAndMonthReset() { + // Matching uses the full triple; a metric with a different period must not match. + RulesengineCompany existing = companyWithEntitlementsAndMetrics( + Collections.emptyMap(), + List.of(entitlement( + "feat-1", + "f1", + null, + null, + "api_calls", + RulesengineMetricPeriod.CURRENT_MONTH, // differs from metric's period + RulesengineMetricPeriodMonthReset.FIRST_OF_MONTH, + 5L)), + metric("api_calls", "all_time", "first_of_month", 100)); + + ObjectNode partial = objectMapper.createObjectNode(); + ArrayNode metrics = objectMapper.createArrayNode(); + metrics.add(metric("api_calls", "all_time", "first_of_month", 999)); + partial.set("metrics", metrics); + + RulesengineCompany merged = EntityMerge.partialCompany(existing, partial); + + assertEquals(5L, merged.getEntitlements().get().get(0).getUsage().get()); + } + + @Test + void partialCompany_usageMatchDefaultsToAllTimeFirstOfMonth() { + // When period/month_reset are absent, the lookup defaults to all_time/first_of_month. + RulesengineCompany existing = companyWithEntitlementsAndMetrics( + Collections.emptyMap(), + List.of(entitlement("feat-1", "f1", null, null, "api_calls", null, null, null)), + metric("api_calls", "all_time", "first_of_month", 0)); + + ObjectNode partial = objectMapper.createObjectNode(); + ArrayNode metrics = objectMapper.createArrayNode(); + metrics.add(metric("api_calls", "all_time", "first_of_month", 7)); + partial.set("metrics", metrics); + + RulesengineCompany merged = EntityMerge.partialCompany(existing, partial); + + assertEquals(7L, merged.getEntitlements().get().get(0).getUsage().get()); + } + + @Test + void partialCompany_usageUnchangedWhenNoMatchingMetricInPartial() { + // Partial updates a different event; event-a stays in merged metrics at 50, so usage stays 50. + RulesengineCompany existing = companyWithEntitlementsAndMetrics( + Collections.emptyMap(), + List.of(entitlement( + "feat-1", + "f1", + null, + null, + "event-a", + RulesengineMetricPeriod.ALL_TIME, + RulesengineMetricPeriodMonthReset.FIRST_OF_MONTH, + 50L)), + metric("event-a", "all_time", "first_of_month", 50)); + + ObjectNode partial = objectMapper.createObjectNode(); + ArrayNode metrics = objectMapper.createArrayNode(); + metrics.add(metric("event-b", "all_time", "first_of_month", 999)); + partial.set("metrics", metrics); + + RulesengineCompany merged = EntityMerge.partialCompany(existing, partial); + + assertEquals(50L, merged.getEntitlements().get().get(0).getUsage().get()); + } + + @Test + void partialCompany_syncsUsageAndCreditRemainingInOnePartial() { + RulesengineCompany existing = companyWithEntitlementsAndMetrics( + Collections.singletonMap("credit-1", 100.0), + List.of(entitlement( + "feat-1", + "f1", + "credit-1", + 100.0, + "event-a", + RulesengineMetricPeriod.ALL_TIME, + RulesengineMetricPeriodMonthReset.FIRST_OF_MONTH, + 5L)), + metric("event-a", "all_time", "first_of_month", 5)); + + ObjectNode partial = objectMapper.createObjectNode(); + partial.set("credit_balances", balances("credit-1", 25.0)); + ArrayNode metrics = objectMapper.createArrayNode(); + metrics.add(metric("event-a", "all_time", "first_of_month", 80)); + partial.set("metrics", metrics); + + RulesengineCompany merged = EntityMerge.partialCompany(existing, partial); + + assertEquals( + 25.0, merged.getEntitlements().get().get(0).getCreditRemaining().get()); + assertEquals(80L, merged.getEntitlements().get().get(0).getUsage().get()); + } + + @Test + void partialCompany_usageDefaultsToZeroWhenMatchedMetricHasNoValue() { + // Mirrors Python/Ruby: a matching metric that carries no value counts as 0, + // so a previously non-zero usage is reset rather than left stale. + RulesengineCompany existing = companyWithEntitlementsAndMetrics( + Collections.emptyMap(), + List.of(entitlement( + "feat-1", + "f1", + null, + null, + "credits_used", + RulesengineMetricPeriod.ALL_TIME, + RulesengineMetricPeriodMonthReset.FIRST_OF_MONTH, + 10L)), + metric("credits_used", "all_time", "first_of_month", 10)); + + ObjectNode partial = objectMapper.createObjectNode(); + ArrayNode metrics = objectMapper.createArrayNode(); + ObjectNode valueless = metric("credits_used", "all_time", "first_of_month", 0); + valueless.remove("value"); + metrics.add(valueless); + partial.set("metrics", metrics); + + RulesengineCompany merged = EntityMerge.partialCompany(existing, partial); + + assertEquals(0L, merged.getEntitlements().get().get(0).getUsage().get()); + } + // --- Helpers --- private RulesengineCompany buildCompany(String id, Map keys) { @@ -569,4 +865,85 @@ private RulesengineUser buildUser(String id, Map keys) { .rules(Collections.emptyList()) .build(); } + + private RulesengineCompany companyWithEntitlements( + Map creditBalances, List entitlements) { + return RulesengineCompany.builder() + .accountId("acc_1") + .environmentId("env_1") + .id("comp-1") + .keys(Collections.singletonMap("id", "comp-1")) + .traits(Collections.emptyList()) + .metrics(Collections.emptyList()) + .rules(Collections.emptyList()) + .billingProductIds(Collections.emptyList()) + .creditBalances(creditBalances) + .planIds(Collections.emptyList()) + .planVersionIds(Collections.emptyList()) + .entitlements(entitlements) + .build(); + } + + private RulesengineCompany companyWithEntitlementsAndMetrics( + Map creditBalances, List entitlements, ObjectNode metric) { + RulesengineCompany company = companyWithEntitlements(creditBalances, entitlements); + ObjectNode tree = (ObjectNode) objectMapper.valueToTree(company); + ArrayNode metrics = objectMapper.createArrayNode(); + metrics.add(metric); + tree.set("metrics", metrics); + return objectMapper.convertValue(tree, RulesengineCompany.class); + } + + private RulesengineFeatureEntitlement entitlement( + String featureId, + String featureKey, + String creditId, + Double creditRemaining, + String eventName, + RulesengineMetricPeriod metricPeriod, + RulesengineMetricPeriodMonthReset monthReset, + Long usage) { + RulesengineFeatureEntitlement._FinalStage builder = RulesengineFeatureEntitlement.builder() + .featureId(featureId) + .featureKey(featureKey) + .valueType(RulesengineEntitlementValueType.BOOLEAN); + if (creditId != null) { + builder.creditId(creditId); + } + if (creditRemaining != null) { + builder.creditRemaining(creditRemaining); + } + if (eventName != null) { + builder.eventName(eventName); + } + if (metricPeriod != null) { + builder.metricPeriod(metricPeriod); + } + if (monthReset != null) { + builder.monthReset(monthReset); + } + if (usage != null) { + builder.usage(usage); + } + return builder.build(); + } + + private ObjectNode metric(String eventSubtype, String period, String monthReset, int value) { + ObjectNode m = objectMapper.createObjectNode(); + m.put("account_id", "acc_1"); + m.put("company_id", "comp-1"); + m.put("environment_id", "env_1"); + m.put("created_at", "2026-01-01T00:00:00Z"); + m.put("event_subtype", eventSubtype); + m.put("period", period); + m.put("month_reset", monthReset); + m.put("value", value); + return m; + } + + private ObjectNode balances(String creditId, double amount) { + ObjectNode node = objectMapper.createObjectNode(); + node.put(creditId, amount); + return node; + } } From 5e2bb70c69868467b6b187421b1395590134d0fa Mon Sep 17 00:00:00 2001 From: Christopher Brady Date: Thu, 28 May 2026 08:25:31 -0600 Subject: [PATCH 2/4] add extra options to track and identify events --- .../com/schematic/api/HttpEventSender.java | 66 +++++++++----- .../com/schematic/api/IdentifyOptions.java | 40 +++++++++ .../java/com/schematic/api/Schematic.java | 62 +++++++++++-- .../java/com/schematic/api/TrackOptions.java | 88 +++++++++++++++++++ .../schematic/api/HttpEventSenderTest.java | 62 +++++++++++++ 5 files changed, 286 insertions(+), 32 deletions(-) create mode 100644 src/main/java/com/schematic/api/IdentifyOptions.java create mode 100644 src/main/java/com/schematic/api/TrackOptions.java create mode 100644 src/test/java/com/schematic/api/HttpEventSenderTest.java diff --git a/src/main/java/com/schematic/api/HttpEventSender.java b/src/main/java/com/schematic/api/HttpEventSender.java index 57f6d96..20ab37e 100644 --- a/src/main/java/com/schematic/api/HttpEventSender.java +++ b/src/main/java/com/schematic/api/HttpEventSender.java @@ -20,8 +20,9 @@ * by posting to https://c.schematichq.com/batch. * *

    Each event payload is built from the Fern-generated {@link CreateEventRequestBody} model - * with {@code api_key} injected, so any fields added to the generated model are automatically - * included in the capture service payload. + * with {@code api_key} injected. Optional metadata fields ({@code idempotency_key}, + * {@code sent_at}, {@code trusted_client_clock}, {@code backfill}) are forwarded only when set, + * matching the {@code exclude_none} wire format used by the other SDKs. */ public class HttpEventSender implements Closeable { private static final String DEFAULT_EVENT_CAPTURE_BASE_URL = "https://c.schematichq.com"; @@ -50,29 +51,9 @@ public void sendBatch(List events) throws IOException { return; } - // Build batch matching the capture service format (same as Go SDK's EventPayload) - ArrayNode eventsArray = ObjectMappers.JSON_MAPPER.createArrayNode(); - for (CreateEventRequestBody event : events) { - ObjectNode eventNode = ObjectMappers.JSON_MAPPER.createObjectNode(); - eventNode.put("api_key", apiKey); - eventNode.put("type", event.getEventType().toString()); - if (event.getBody().isPresent()) { - eventNode.set( - "body", - ObjectMappers.JSON_MAPPER.valueToTree(event.getBody().get())); - } - if (event.getSentAt().isPresent()) { - eventNode.put("sent_at", event.getSentAt().get().toString()); - } - eventsArray.add(eventNode); - } - - ObjectNode batchPayload = ObjectMappers.JSON_MAPPER.createObjectNode(); - batchPayload.set("events", eventsArray); - String json; try { - json = ObjectMappers.JSON_MAPPER.writeValueAsString(batchPayload); + json = serializeBatch(events); } catch (JsonProcessingException e) { throw new IOException("Failed to serialize event batch", e); } @@ -104,6 +85,45 @@ public void sendBatch(List events) throws IOException { } } + /** + * Serializes a batch of events into the capture service's wire format (same shape as the + * Go/Ruby/C#/Python SDKs): a {@code type} field, an embedded {@code api_key}, and the optional + * metadata fields forwarded only when present so we never send explicit nulls. + * + *

    Package-private for unit testing of the wire mapping. + */ + String serializeBatch(List events) throws JsonProcessingException { + ArrayNode eventsArray = ObjectMappers.JSON_MAPPER.createArrayNode(); + for (CreateEventRequestBody event : events) { + ObjectNode eventNode = ObjectMappers.JSON_MAPPER.createObjectNode(); + eventNode.put("api_key", apiKey); + eventNode.put("type", event.getEventType().toString()); + if (event.getBody().isPresent()) { + eventNode.set( + "body", + ObjectMappers.JSON_MAPPER.valueToTree(event.getBody().get())); + } + if (event.getSentAt().isPresent()) { + eventNode.put("sent_at", event.getSentAt().get().toString()); + } + if (event.getIdempotencyKey().isPresent()) { + eventNode.put("idempotency_key", event.getIdempotencyKey().get()); + } + if (event.getTrustedClientClock().isPresent()) { + eventNode.put( + "trusted_client_clock", event.getTrustedClientClock().get()); + } + if (event.getBackfill().isPresent()) { + eventNode.put("backfill", event.getBackfill().get()); + } + eventsArray.add(eventNode); + } + + ObjectNode batchPayload = ObjectMappers.JSON_MAPPER.createObjectNode(); + batchPayload.set("events", eventsArray); + return ObjectMappers.JSON_MAPPER.writeValueAsString(batchPayload); + } + @Override public void close() { httpClient.dispatcher().executorService().shutdownNow(); diff --git a/src/main/java/com/schematic/api/IdentifyOptions.java b/src/main/java/com/schematic/api/IdentifyOptions.java new file mode 100644 index 0000000..176654c --- /dev/null +++ b/src/main/java/com/schematic/api/IdentifyOptions.java @@ -0,0 +1,40 @@ +package com.schematic.api; + +/** + * Optional metadata for an {@link Schematic#identify} event. + * + *

    Omit any field you don't need; the SDK only sends fields that are explicitly set. + */ +public final class IdentifyOptions { + + private final String idempotencyKey; + + private IdentifyOptions(Builder builder) { + this.idempotencyKey = builder.idempotencyKey; + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Client-supplied dedupe key. Duplicate events with the same key (scoped to the environment) are + * dropped server-side for 24 hours. + */ + public String getIdempotencyKey() { + return idempotencyKey; + } + + public static final class Builder { + private String idempotencyKey; + + public Builder idempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + return this; + } + + public IdentifyOptions build() { + return new IdentifyOptions(this); + } + } +} diff --git a/src/main/java/com/schematic/api/Schematic.java b/src/main/java/com/schematic/api/Schematic.java index cabf449..3d2c1b0 100644 --- a/src/main/java/com/schematic/api/Schematic.java +++ b/src/main/java/com/schematic/api/Schematic.java @@ -556,6 +556,15 @@ private RulesengineCheckFlagResult checkFlagViaApi( public void identify( Map keys, EventBodyIdentifyCompany company, String name, Map traits) { + identify(keys, company, name, traits, null); + } + + public void identify( + Map keys, + EventBodyIdentifyCompany company, + String name, + Map traits, + IdentifyOptions options) { if (offline) return; try { @@ -566,13 +575,17 @@ public void identify( .traits(objectMapToJsonNode(traits)) .build(); - CreateEventRequestBody event = CreateEventRequestBody.builder() + CreateEventRequestBody._FinalStage event = CreateEventRequestBody.builder() .eventType(EventType.IDENTIFY) .body(EventBody.of(body)) - .sentAt(OffsetDateTime.now()) - .build(); + .sentAt(OffsetDateTime.now()); - eventBuffer.push(event); + // Null passes through to Optional.empty() and is omitted from the wire. + if (options != null) { + event.idempotencyKey(options.getIdempotencyKey()); + } + + eventBuffer.push(event.build()); } catch (Exception e) { logger.error("Error sending identify event: " + e.getMessage()); } @@ -580,7 +593,7 @@ public void identify( public void track( String eventName, Map company, Map user, Map traits) { - track(eventName, company, user, traits, 1); + track(eventName, company, user, traits, 1, null); } public void track( @@ -589,6 +602,25 @@ public void track( Map user, Map traits, Integer quantity) { + track(eventName, company, user, traits, quantity, null); + } + + public void track( + String eventName, + Map company, + Map user, + Map traits, + TrackOptions options) { + track(eventName, company, user, traits, 1, options); + } + + public void track( + String eventName, + Map company, + Map user, + Map traits, + Integer quantity, + TrackOptions options) { if (offline) return; try { @@ -600,13 +632,25 @@ public void track( .quantity(quantity) .build(); - CreateEventRequestBody event = CreateEventRequestBody.builder() + // Java has always stamped sent_at with the local clock; an explicit + // sentAt option overrides it (required when trustedClientClock is set). + CreateEventRequestBody._FinalStage event = CreateEventRequestBody.builder() .eventType(EventType.TRACK) .body(EventBody.of(body)) - .sentAt(OffsetDateTime.now()) - .build(); + .sentAt( + options != null && options.getSentAt() != null + ? options.getSentAt() + : OffsetDateTime.now()); + + // Nulls pass through to Optional.empty() and are omitted from the wire. + // sentAt is handled above since null there would drop the now() default. + if (options != null) { + event.idempotencyKey(options.getIdempotencyKey()) + .trustedClientClock(options.getTrustedClientClock()) + .backfill(options.getBackfill()); + } - eventBuffer.push(event); + eventBuffer.push(event.build()); // Update cached company metrics if datastream is active if (company != null && !company.isEmpty() && dataStreamClient != null && dataStreamClient.isConnected()) { diff --git a/src/main/java/com/schematic/api/TrackOptions.java b/src/main/java/com/schematic/api/TrackOptions.java new file mode 100644 index 0000000..a9f9b5b --- /dev/null +++ b/src/main/java/com/schematic/api/TrackOptions.java @@ -0,0 +1,88 @@ +package com.schematic.api; + +import java.time.OffsetDateTime; + +/** + * Optional metadata for a {@link Schematic#track} event. + * + *

    Fields map directly to the corresponding {@code CreateEventRequestBody} properties. Omit any + * field you don't need; the SDK only sends fields that are explicitly set. + */ +public final class TrackOptions { + + private final String idempotencyKey; + private final OffsetDateTime sentAt; + private final Boolean trustedClientClock; + private final Boolean backfill; + + private TrackOptions(Builder builder) { + this.idempotencyKey = builder.idempotencyKey; + this.sentAt = builder.sentAt; + this.trustedClientClock = builder.trustedClientClock; + this.backfill = builder.backfill; + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Client-supplied dedupe key. Duplicate events with the same key (scoped to the environment) are + * dropped server-side for 24 hours. + */ + public String getIdempotencyKey() { + return idempotencyKey; + } + + /** Timestamp the event was sent. Required when {@code trustedClientClock} is true. */ + public OffsetDateTime getSentAt() { + return sentAt; + } + + /** + * When true, use {@code sentAt} as the effective event timestamp instead of server receipt time. + * Requires a secret API key and {@code sentAt}. + */ + public Boolean getTrustedClientClock() { + return trustedClientClock; + } + + /** + * Import historical data without affecting billing. Requires a secret API key and + * {@code trustedClientClock}. + */ + public Boolean getBackfill() { + return backfill; + } + + public static final class Builder { + private String idempotencyKey; + private OffsetDateTime sentAt; + private Boolean trustedClientClock; + private Boolean backfill; + + public Builder idempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + return this; + } + + public Builder sentAt(OffsetDateTime sentAt) { + this.sentAt = sentAt; + return this; + } + + public Builder trustedClientClock(Boolean trustedClientClock) { + this.trustedClientClock = trustedClientClock; + return this; + } + + public Builder backfill(Boolean backfill) { + this.backfill = backfill; + return this; + } + + public TrackOptions build() { + return new TrackOptions(this); + } + } +} diff --git a/src/test/java/com/schematic/api/HttpEventSenderTest.java b/src/test/java/com/schematic/api/HttpEventSenderTest.java new file mode 100644 index 0000000..a7e1d38 --- /dev/null +++ b/src/test/java/com/schematic/api/HttpEventSenderTest.java @@ -0,0 +1,62 @@ +package com.schematic.api; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.JsonNode; +import com.schematic.api.core.ObjectMappers; +import com.schematic.api.types.CreateEventRequestBody; +import com.schematic.api.types.EventType; +import java.time.OffsetDateTime; +import java.util.Collections; +import org.junit.jupiter.api.Test; + +/** Pins the capture-service wire format produced by {@link HttpEventSender#serializeBatch}. */ +class HttpEventSenderTest { + + private final HttpEventSender sender = new HttpEventSender(null, "test_api_key", null, null); + + private JsonNode firstEvent(CreateEventRequestBody event) throws Exception { + String json = sender.serializeBatch(Collections.singletonList(event)); + return ObjectMappers.JSON_MAPPER.readTree(json).get("events").get(0); + } + + @Test + void serializeBatch_alwaysIncludesApiKeyAndType() throws Exception { + JsonNode wire = firstEvent( + CreateEventRequestBody.builder().eventType(EventType.TRACK).build()); + + assertEquals("test_api_key", wire.get("api_key").asText()); + assertEquals("track", wire.get("type").asText()); + } + + @Test + void serializeBatch_excludesUnsetOptionalFields() throws Exception { + JsonNode wire = firstEvent( + CreateEventRequestBody.builder().eventType(EventType.TRACK).build()); + + // Unset optional fields must not appear as explicit nulls on the wire. + assertFalse(wire.has("idempotency_key")); + assertFalse(wire.has("sent_at")); + assertFalse(wire.has("trusted_client_clock")); + assertFalse(wire.has("backfill")); + } + + @Test + void serializeBatch_includesSetOptionalFields() throws Exception { + OffsetDateTime sentAt = OffsetDateTime.parse("2026-01-01T00:00:00Z"); + JsonNode wire = firstEvent(CreateEventRequestBody.builder() + .eventType(EventType.TRACK) + .idempotencyKey("evt_xyz") + .sentAt(sentAt) + .trustedClientClock(true) + // backfill=false is explicitly set, so it must still reach the wire. + .backfill(false) + .build()); + + assertEquals("evt_xyz", wire.get("idempotency_key").asText()); + assertEquals(sentAt.toString(), wire.get("sent_at").asText()); + assertTrue(wire.get("trusted_client_clock").asBoolean()); + assertTrue(wire.has("backfill")); + assertFalse(wire.get("backfill").asBoolean()); + } +} From 370a917bfe10e4eb4f77194935907de268832a21 Mon Sep 17 00:00:00 2001 From: Christopher Brady Date: Thu, 28 May 2026 08:46:05 -0600 Subject: [PATCH 3/4] logger improvements --- .fernignore | 4 + .../java/com/schematic/api/Schematic.java | 16 ++- .../schematic/api/logger/ConsoleLogger.java | 39 +++++- .../com/schematic/api/logger/LogLevel.java | 20 +++ .../java/com/schematic/api/TestLogger.java | 125 +++++++++++------- 5 files changed, 151 insertions(+), 53 deletions(-) create mode 100644 src/main/java/com/schematic/api/logger/LogLevel.java diff --git a/.fernignore b/.fernignore index de7d09c..8c55e60 100644 --- a/.fernignore +++ b/.fernignore @@ -12,7 +12,9 @@ sample-app/ src/main/java/com/schematic/api/BaseSchematic.java src/main/java/com/schematic/api/EventBuffer.java src/main/java/com/schematic/api/HttpEventSender.java +src/main/java/com/schematic/api/IdentifyOptions.java src/main/java/com/schematic/api/Schematic.java +src/main/java/com/schematic/api/TrackOptions.java src/main/java/com/schematic/api/cache/CacheProvider.java src/main/java/com/schematic/api/cache/CachedItem.java src/main/java/com/schematic/api/cache/LocalCache.java @@ -20,9 +22,11 @@ src/main/java/com/schematic/api/cache/RedisCacheConfig.java src/main/java/com/schematic/api/cache/RedisCacheProvider.java src/main/java/com/schematic/api/core/NoOpHttpClient.java src/main/java/com/schematic/api/logger/ConsoleLogger.java +src/main/java/com/schematic/api/logger/LogLevel.java src/main/java/com/schematic/api/datastream/ src/main/java/com/schematic/api/logger/SchematicLogger.java src/main/java/com/schematic/webhook/ +src/test/java/com/schematic/api/HttpEventSenderTest.java src/test/java/com/schematic/api/TestCache.java src/test/java/com/schematic/api/TestEventBuffer.java src/test/java/com/schematic/api/TestLogger.java diff --git a/src/main/java/com/schematic/api/Schematic.java b/src/main/java/com/schematic/api/Schematic.java index 3d2c1b0..9987a1e 100644 --- a/src/main/java/com/schematic/api/Schematic.java +++ b/src/main/java/com/schematic/api/Schematic.java @@ -12,6 +12,7 @@ import com.schematic.api.datastream.DatastreamOptions; import com.schematic.api.datastream.WasmRulesEngine; import com.schematic.api.logger.ConsoleLogger; +import com.schematic.api.logger.LogLevel; import com.schematic.api.logger.SchematicLogger; import com.schematic.api.resources.features.types.CheckFlagResponse; import com.schematic.api.resources.features.types.CheckFlagsResponse; @@ -53,7 +54,9 @@ private Schematic(Builder builder) { this.apiKey = builder.apiKey; this.eventBufferInterval = builder.eventBufferInterval != null ? builder.eventBufferInterval : Duration.ofMillis(5000); - this.logger = builder.logger != null ? builder.logger : new ConsoleLogger(); + // A consumer-provided logger is used as-is (its own level governs); logLevel only + // configures the default ConsoleLogger, which otherwise defaults to WARN. + this.logger = builder.logger != null ? builder.logger : new ConsoleLogger(builder.logLevel); this.flagDefaults = builder.flagDefaults != null ? builder.flagDefaults : new HashMap<>(); this.offline = builder.offline; this.flagCheckCacheProviders = builder.cacheProviders != null @@ -150,6 +153,7 @@ public static Builder builder() { public static class Builder { private String apiKey; private SchematicLogger logger; + private LogLevel logLevel; private Map flagDefaults; private List> cacheProviders; private boolean offline; @@ -170,6 +174,16 @@ public Builder logger(SchematicLogger logger) { return this; } + /** + * Sets the level for the default {@link ConsoleLogger} (defaults to {@link LogLevel#WARN}). + * Ignored when a custom {@link #logger(SchematicLogger)} is provided — that logger's own level + * configuration is the source of truth. + */ + public Builder logLevel(LogLevel logLevel) { + this.logLevel = logLevel; + return this; + } + public Builder flagDefaults(Map flagDefaults) { this.flagDefaults = flagDefaults; return this; diff --git a/src/main/java/com/schematic/api/logger/ConsoleLogger.java b/src/main/java/com/schematic/api/logger/ConsoleLogger.java index e569538..267864a 100644 --- a/src/main/java/com/schematic/api/logger/ConsoleLogger.java +++ b/src/main/java/com/schematic/api/logger/ConsoleLogger.java @@ -1,23 +1,54 @@ package com.schematic.api.logger; +/** + * Default {@link SchematicLogger} implementation that writes to standard out, filtering messages by + * a configured {@link LogLevel}. + * + *

    Defaults to {@link LogLevel#WARN}: {@code debug} and {@code info} are suppressed unless a more + * verbose level is requested, so production consumers aren't flooded with diagnostics they never + * asked for. Raise the level (e.g. {@link LogLevel#DEBUG}) for development. + */ public class ConsoleLogger implements SchematicLogger { + + private final LogLevel level; + + /** Creates a logger at the default {@link LogLevel#WARN} level. */ + public ConsoleLogger() { + this(LogLevel.WARN); + } + + /** Creates a logger that emits messages at {@code level} or more severe. Null defaults to WARN. */ + public ConsoleLogger(LogLevel level) { + this.level = level != null ? level : LogLevel.WARN; + } + @Override public void error(String message, Object... args) { - System.out.println("[ERROR] " + String.format(message, args)); + log(LogLevel.ERROR, message, args); } @Override public void warn(String message, Object... args) { - System.out.println("[WARN] " + String.format(message, args)); + log(LogLevel.WARN, message, args); } @Override public void info(String message, Object... args) { - System.out.println("[INFO] " + String.format(message, args)); + log(LogLevel.INFO, message, args); } @Override public void debug(String message, Object... args) { - System.out.println("[DEBUG] " + String.format(message, args)); + log(LogLevel.DEBUG, message, args); + } + + private void log(LogLevel messageLevel, String message, Object... args) { + if (!level.allows(messageLevel)) { + return; + } + // Only run through String.format when args are supplied, so a literal '%' in an + // arg-less message doesn't blow up with a format exception. + String formatted = (args == null || args.length == 0) ? message : String.format(message, args); + System.out.println("[" + messageLevel.name() + "] " + formatted); } } diff --git a/src/main/java/com/schematic/api/logger/LogLevel.java b/src/main/java/com/schematic/api/logger/LogLevel.java new file mode 100644 index 0000000..fdb8ba6 --- /dev/null +++ b/src/main/java/com/schematic/api/logger/LogLevel.java @@ -0,0 +1,20 @@ +package com.schematic.api.logger; + +/** + * Severity levels for {@link SchematicLogger}, ordered from most to least verbose. + * + *

    A logger configured at a given level emits messages at that level and every more severe level, + * suppressing the rest. For example {@link #WARN} emits {@code warn} and {@code error} but drops + * {@code info} and {@code debug}. + */ +public enum LogLevel { + DEBUG, + INFO, + WARN, + ERROR; + + /** Whether a message logged at {@code messageLevel} should be emitted by a logger at this level. */ + boolean allows(LogLevel messageLevel) { + return messageLevel.ordinal() >= this.ordinal(); + } +} diff --git a/src/test/java/com/schematic/api/TestLogger.java b/src/test/java/com/schematic/api/TestLogger.java index f8dcb23..3986509 100644 --- a/src/test/java/com/schematic/api/TestLogger.java +++ b/src/test/java/com/schematic/api/TestLogger.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.*; import com.schematic.api.logger.ConsoleLogger; +import com.schematic.api.logger.LogLevel; import java.io.ByteArrayOutputStream; import java.io.PrintStream; import org.junit.jupiter.api.AfterEach; @@ -12,14 +13,12 @@ class LoggerTest { private ByteArrayOutputStream outputStream; private PrintStream originalOut; - private ConsoleLogger logger; @BeforeEach void setUp() { outputStream = new ByteArrayOutputStream(); originalOut = System.out; System.setOut(new PrintStream(outputStream)); - logger = new ConsoleLogger(); } @AfterEach @@ -27,79 +26,109 @@ void tearDown() { System.setOut(originalOut); } + private String output() { + return outputStream.toString().trim(); + } + + // --- Emission + formatting (verbose logger so every level is allowed) --- + @Test void testErrorLogsMessage() { - String message = "This is an error message"; - logger.error(message); - - String output = outputStream.toString().trim(); - assertTrue(output.contains("[ERROR]")); - assertTrue(output.contains(message)); + new ConsoleLogger(LogLevel.DEBUG).error("This is an error message"); + assertTrue(output().contains("[ERROR]")); + assertTrue(output().contains("This is an error message")); } @Test void testWarnLogsMessage() { - String message = "This is a warning message"; - logger.warn(message); - - String output = outputStream.toString().trim(); - assertTrue(output.contains("[WARN]")); - assertTrue(output.contains(message)); + new ConsoleLogger(LogLevel.DEBUG).warn("This is a warning message"); + assertTrue(output().contains("[WARN]")); + assertTrue(output().contains("This is a warning message")); } @Test void testInfoLogsMessage() { - String message = "This is an info message"; - logger.info(message); - - String output = outputStream.toString().trim(); - assertTrue(output.contains("[INFO]")); - assertTrue(output.contains(message)); + new ConsoleLogger(LogLevel.DEBUG).info("This is an info message"); + assertTrue(output().contains("[INFO]")); + assertTrue(output().contains("This is an info message")); } @Test void testDebugLogsMessage() { - String message = "This is a debug message"; - logger.debug(message); - - String output = outputStream.toString().trim(); - assertTrue(output.contains("[DEBUG]")); - assertTrue(output.contains(message)); + new ConsoleLogger(LogLevel.DEBUG).debug("This is a debug message"); + assertTrue(output().contains("[DEBUG]")); + assertTrue(output().contains("This is a debug message")); } @Test - void testErrorFormatsMessageWithArgs() { - logger.error("Error %s", "123"); - - String output = outputStream.toString().trim(); - assertTrue(output.contains("[ERROR]")); - assertTrue(output.contains("Error 123")); + void testFormatsMessageWithArgs() { + new ConsoleLogger(LogLevel.DEBUG).debug("Debug %s", "123"); + assertTrue(output().contains("[DEBUG]")); + assertTrue(output().contains("Debug 123")); } @Test - void testWarnFormatsMessageWithArgs() { - logger.warn("Warning %s", "123"); - - String output = outputStream.toString().trim(); - assertTrue(output.contains("[WARN]")); - assertTrue(output.contains("Warning 123")); + void testArglessMessageWithPercentDoesNotThrow() { + // A literal '%' with no args must not trigger a format exception. + assertDoesNotThrow(() -> new ConsoleLogger(LogLevel.DEBUG).warn("100% complete")); + assertTrue(output().contains("100% complete")); } + // --- Level filtering (the conformance requirement) --- + @Test - void testInfoFormatsMessageWithArgs() { - logger.info("Info %s", "123"); + void testDefaultLevelIsWarnAndSuppressesInfoAndDebug() { + ConsoleLogger logger = new ConsoleLogger(); // default WARN + logger.debug("dbg"); + logger.info("inf"); + logger.warn("wrn"); + logger.error("err"); + + String out = output(); + assertFalse(out.contains("[DEBUG]"), "debug should be suppressed at WARN"); + assertFalse(out.contains("[INFO]"), "info should be suppressed at WARN"); + assertTrue(out.contains("[WARN]")); + assertTrue(out.contains("[ERROR]")); + } - String output = outputStream.toString().trim(); - assertTrue(output.contains("[INFO]")); - assertTrue(output.contains("Info 123")); + @Test + void testNullLevelDefaultsToWarn() { + ConsoleLogger logger = new ConsoleLogger(null); + logger.info("inf"); + logger.warn("wrn"); + + String out = output(); + assertFalse(out.contains("[INFO]")); + assertTrue(out.contains("[WARN]")); } @Test - void testDebugFormatsMessageWithArgs() { - logger.debug("Debug %s", "123"); + void testDebugLevelEmitsEveryLevel() { + ConsoleLogger logger = new ConsoleLogger(LogLevel.DEBUG); + logger.debug("dbg"); + logger.info("inf"); + logger.warn("wrn"); + logger.error("err"); + + String out = output(); + assertTrue(out.contains("[DEBUG]")); + assertTrue(out.contains("[INFO]")); + assertTrue(out.contains("[WARN]")); + assertTrue(out.contains("[ERROR]")); + } - String output = outputStream.toString().trim(); - assertTrue(output.contains("[DEBUG]")); - assertTrue(output.contains("Debug 123")); + @Test + void testErrorLevelSuppressesEverythingBelowError() { + ConsoleLogger logger = new ConsoleLogger(LogLevel.ERROR); + logger.debug("dbg"); + logger.info("inf"); + logger.warn("wrn"); + logger.error("err"); + + String out = output(); + assertFalse(out.contains("[DEBUG]")); + assertFalse(out.contains("[INFO]")); + assertFalse(out.contains("[WARN]")); + assertTrue(out.contains("[ERROR]")); } } From f3119c915fbd1f7c061dd088c7f009b4c30167e1 Mon Sep 17 00:00:00 2001 From: Christopher Brady Date: Thu, 28 May 2026 09:14:46 -0600 Subject: [PATCH 4/4] add tests and address nits --- .../java/com/schematic/api/Schematic.java | 67 ++++++++------- .../com/schematic/api/logger/LogLevel.java | 16 ++-- .../java/com/schematic/api/TestSchematic.java | 82 +++++++++++++++++++ .../api/datastream/EntityMergeTest.java | 28 +++++++ 4 files changed, 158 insertions(+), 35 deletions(-) diff --git a/src/main/java/com/schematic/api/Schematic.java b/src/main/java/com/schematic/api/Schematic.java index 9987a1e..a7e6a99 100644 --- a/src/main/java/com/schematic/api/Schematic.java +++ b/src/main/java/com/schematic/api/Schematic.java @@ -589,17 +589,7 @@ public void identify( .traits(objectMapToJsonNode(traits)) .build(); - CreateEventRequestBody._FinalStage event = CreateEventRequestBody.builder() - .eventType(EventType.IDENTIFY) - .body(EventBody.of(body)) - .sentAt(OffsetDateTime.now()); - - // Null passes through to Optional.empty() and is omitted from the wire. - if (options != null) { - event.idempotencyKey(options.getIdempotencyKey()); - } - - eventBuffer.push(event.build()); + eventBuffer.push(buildIdentifyEvent(EventBody.of(body), options)); } catch (Exception e) { logger.error("Error sending identify event: " + e.getMessage()); } @@ -646,25 +636,7 @@ public void track( .quantity(quantity) .build(); - // Java has always stamped sent_at with the local clock; an explicit - // sentAt option overrides it (required when trustedClientClock is set). - CreateEventRequestBody._FinalStage event = CreateEventRequestBody.builder() - .eventType(EventType.TRACK) - .body(EventBody.of(body)) - .sentAt( - options != null && options.getSentAt() != null - ? options.getSentAt() - : OffsetDateTime.now()); - - // Nulls pass through to Optional.empty() and are omitted from the wire. - // sentAt is handled above since null there would drop the now() default. - if (options != null) { - event.idempotencyKey(options.getIdempotencyKey()) - .trustedClientClock(options.getTrustedClientClock()) - .backfill(options.getBackfill()); - } - - eventBuffer.push(event.build()); + eventBuffer.push(buildTrackEvent(EventBody.of(body), options)); // Update cached company metrics if datastream is active if (company != null && !company.isEmpty() && dataStreamClient != null && dataStreamClient.isConnected()) { @@ -679,6 +651,41 @@ public void track( } } + /** + * Builds the identify event pushed to the buffer. Package-private for unit-testing the + * option-to-event mapping. {@code sent_at} is stamped with the local clock; a null option + * field passes through to {@code Optional.empty()} and is omitted from the wire. + */ + static CreateEventRequestBody buildIdentifyEvent(EventBody body, IdentifyOptions options) { + CreateEventRequestBody._FinalStage event = CreateEventRequestBody.builder() + .eventType(EventType.IDENTIFY) + .body(body) + .sentAt(OffsetDateTime.now()); + if (options != null) { + event.idempotencyKey(options.getIdempotencyKey()); + } + return event.build(); + } + + /** + * Builds the track event pushed to the buffer. Package-private for unit-testing the + * option-to-event mapping. An explicit {@code sentAt} option overrides the local-clock default + * (required when {@code trustedClientClock} is set); other null option fields pass through to + * {@code Optional.empty()} and are omitted from the wire. + */ + static CreateEventRequestBody buildTrackEvent(EventBody body, TrackOptions options) { + CreateEventRequestBody._FinalStage event = CreateEventRequestBody.builder() + .eventType(EventType.TRACK) + .body(body) + .sentAt(options != null && options.getSentAt() != null ? options.getSentAt() : OffsetDateTime.now()); + if (options != null) { + event.idempotencyKey(options.getIdempotencyKey()) + .trustedClientClock(options.getTrustedClientClock()) + .backfill(options.getBackfill()); + } + return event.build(); + } + @Override public void close() { try { diff --git a/src/main/java/com/schematic/api/logger/LogLevel.java b/src/main/java/com/schematic/api/logger/LogLevel.java index fdb8ba6..a456d26 100644 --- a/src/main/java/com/schematic/api/logger/LogLevel.java +++ b/src/main/java/com/schematic/api/logger/LogLevel.java @@ -8,13 +8,19 @@ * {@code info} and {@code debug}. */ public enum LogLevel { - DEBUG, - INFO, - WARN, - ERROR; + DEBUG(0), + INFO(1), + WARN(2), + ERROR(3); + + private final int severity; + + LogLevel(int severity) { + this.severity = severity; + } /** Whether a message logged at {@code messageLevel} should be emitted by a logger at this level. */ boolean allows(LogLevel messageLevel) { - return messageLevel.ordinal() >= this.ordinal(); + return messageLevel.severity >= this.severity; } } diff --git a/src/test/java/com/schematic/api/TestSchematic.java b/src/test/java/com/schematic/api/TestSchematic.java index ca5978f..dab557d 100644 --- a/src/test/java/com/schematic/api/TestSchematic.java +++ b/src/test/java/com/schematic/api/TestSchematic.java @@ -15,9 +15,15 @@ import com.schematic.api.types.CheckFlagRequestBody; import com.schematic.api.types.CheckFlagResponseData; import com.schematic.api.types.CheckFlagsResponseData; +import com.schematic.api.types.CreateEventRequestBody; +import com.schematic.api.types.EventBody; +import com.schematic.api.types.EventBodyIdentify; import com.schematic.api.types.EventBodyIdentifyCompany; +import com.schematic.api.types.EventBodyTrack; +import com.schematic.api.types.EventType; import com.schematic.api.types.RulesengineCheckFlagResult; import java.time.Duration; +import java.time.OffsetDateTime; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; @@ -172,6 +178,82 @@ void track_EnqueuesEventNonBlocking() throws InterruptedException { verify(logger, never()).error(any()); } + // --- Track/identify option mapping (buildTrackEvent / buildIdentifyEvent) --- + + @Test + void buildTrackEvent_appliesAllOptions() { + OffsetDateTime sentAt = OffsetDateTime.parse("2026-01-01T00:00:00Z"); + EventBody body = EventBody.of(EventBodyTrack.builder().event("e").build()); + + CreateEventRequestBody event = Schematic.buildTrackEvent( + body, + TrackOptions.builder() + .idempotencyKey("idem-1") + .sentAt(sentAt) + .trustedClientClock(true) + .backfill(false) + .build()); + + assertEquals(EventType.TRACK, event.getEventType()); + assertEquals("idem-1", event.getIdempotencyKey().get()); + assertEquals(sentAt, event.getSentAt().get()); + assertTrue(event.getTrustedClientClock().get()); + assertFalse(event.getBackfill().get()); + } + + @Test + void buildTrackEvent_nullOptionsLeavesMetadataUnsetAndStampsSentAt() { + EventBody body = EventBody.of(EventBodyTrack.builder().event("e").build()); + + CreateEventRequestBody event = Schematic.buildTrackEvent(body, null); + + assertFalse(event.getIdempotencyKey().isPresent()); + assertFalse(event.getTrustedClientClock().isPresent()); + assertFalse(event.getBackfill().isPresent()); + assertTrue(event.getSentAt().isPresent(), "sent_at should default to now()"); + } + + @Test + void buildTrackEvent_defaultsSentAtToNowWhenOptionOmitsIt() { + EventBody body = EventBody.of(EventBodyTrack.builder().event("e").build()); + OffsetDateTime before = OffsetDateTime.now().minusSeconds(1); + + CreateEventRequestBody event = Schematic.buildTrackEvent( + body, TrackOptions.builder().idempotencyKey("idem-1").build()); + + OffsetDateTime after = OffsetDateTime.now().plusSeconds(1); + assertTrue(event.getSentAt().isPresent()); + OffsetDateTime sentAt = event.getSentAt().get(); + assertTrue(sentAt.isAfter(before) && sentAt.isBefore(after)); + assertEquals("idem-1", event.getIdempotencyKey().get()); + } + + @Test + void buildIdentifyEvent_appliesIdempotencyKey() { + EventBody body = EventBody.of(EventBodyIdentify.builder() + .keys(Collections.singletonMap("user_id", "u1")) + .build()); + + CreateEventRequestBody event = Schematic.buildIdentifyEvent( + body, IdentifyOptions.builder().idempotencyKey("idem-2").build()); + + assertEquals(EventType.IDENTIFY, event.getEventType()); + assertEquals("idem-2", event.getIdempotencyKey().get()); + assertTrue(event.getSentAt().isPresent()); + } + + @Test + void buildIdentifyEvent_nullOptionsLeavesIdempotencyUnset() { + EventBody body = EventBody.of(EventBodyIdentify.builder() + .keys(Collections.singletonMap("user_id", "u1")) + .build()); + + CreateEventRequestBody event = Schematic.buildIdentifyEvent(body, null); + + assertFalse(event.getIdempotencyKey().isPresent()); + assertTrue(event.getSentAt().isPresent()); + } + @Test void track_OfflineMode() { Map company = Collections.singletonMap("company_id", "67890"); diff --git a/src/test/java/com/schematic/api/datastream/EntityMergeTest.java b/src/test/java/com/schematic/api/datastream/EntityMergeTest.java index 264dc18..3633556 100644 --- a/src/test/java/com/schematic/api/datastream/EntityMergeTest.java +++ b/src/test/java/com/schematic/api/datastream/EntityMergeTest.java @@ -837,6 +837,34 @@ void partialCompany_usageDefaultsToZeroWhenMatchedMetricHasNoValue() { assertEquals(0L, merged.getEntitlements().get().get(0).getUsage().get()); } + @Test + void partialCompany_syncsUsageForCreditAttachedEntitlement() { + // Per spec, a credit-attached entitlement (credit_id set) still has usage synced to the + // matching metric value. For credit features this is the raw event count, not credits used + // — that divergence is expected; credit consumption comes from credit_used / REST. + RulesengineCompany existing = companyWithEntitlementsAndMetrics( + Collections.singletonMap("credit-1", 100.0), + List.of(entitlement( + "feat-1", + "f1", + "credit-1", + 100.0, + "credits_used", + RulesengineMetricPeriod.ALL_TIME, + RulesengineMetricPeriodMonthReset.FIRST_OF_MONTH, + 10L)), + metric("credits_used", "all_time", "first_of_month", 10)); + + ObjectNode partial = objectMapper.createObjectNode(); + ArrayNode metrics = objectMapper.createArrayNode(); + metrics.add(metric("credits_used", "all_time", "first_of_month", 42)); + partial.set("metrics", metrics); + + RulesengineCompany merged = EntityMerge.partialCompany(existing, partial); + + assertEquals(42L, merged.getEntitlements().get().get(0).getUsage().get()); + } + // --- Helpers --- private RulesengineCompany buildCompany(String id, Map keys) {