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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .fernignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,21 @@ 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
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
Expand Down
66 changes: 43 additions & 23 deletions src/main/java/com/schematic/api/HttpEventSender.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@
* by posting to https://c.schematichq.com/batch.
*
* <p>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";
Expand Down Expand Up @@ -50,29 +51,9 @@ public void sendBatch(List<CreateEventRequestBody> 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);
}
Expand Down Expand Up @@ -104,6 +85,45 @@ public void sendBatch(List<CreateEventRequestBody> 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.
*
* <p>Package-private for unit testing of the wire mapping.
*/
String serializeBatch(List<CreateEventRequestBody> 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();
Expand Down
40 changes: 40 additions & 0 deletions src/main/java/com/schematic/api/IdentifyOptions.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.schematic.api;

/**
* Optional metadata for an {@link Schematic#identify} event.
*
* <p>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);
}
}
}
97 changes: 81 additions & 16 deletions src/main/java/com/schematic/api/Schematic.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -150,6 +153,7 @@ public static Builder builder() {
public static class Builder {
private String apiKey;
private SchematicLogger logger;
private LogLevel logLevel;
private Map<String, Boolean> flagDefaults;
private List<CacheProvider<RulesengineCheckFlagResult>> cacheProviders;
private boolean offline;
Expand All @@ -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<String, Boolean> flagDefaults) {
this.flagDefaults = flagDefaults;
return this;
Expand Down Expand Up @@ -556,6 +570,15 @@ private RulesengineCheckFlagResult checkFlagViaApi(

public void identify(
Map<String, String> keys, EventBodyIdentifyCompany company, String name, Map<String, Object> traits) {
identify(keys, company, name, traits, null);
}

public void identify(
Map<String, String> keys,
EventBodyIdentifyCompany company,
String name,
Map<String, Object> traits,
IdentifyOptions options) {
if (offline) return;

try {
Expand All @@ -566,21 +589,15 @@ public void identify(
.traits(objectMapToJsonNode(traits))
.build();

CreateEventRequestBody event = CreateEventRequestBody.builder()
.eventType(EventType.IDENTIFY)
.body(EventBody.of(body))
.sentAt(OffsetDateTime.now())
.build();

eventBuffer.push(event);
eventBuffer.push(buildIdentifyEvent(EventBody.of(body), options));
} catch (Exception e) {
logger.error("Error sending identify event: " + e.getMessage());
}
}

public void track(
String eventName, Map<String, String> company, Map<String, String> user, Map<String, Object> traits) {
track(eventName, company, user, traits, 1);
track(eventName, company, user, traits, 1, null);
}

public void track(
Expand All @@ -589,6 +606,25 @@ public void track(
Map<String, String> user,
Map<String, Object> traits,
Integer quantity) {
track(eventName, company, user, traits, quantity, null);
}

public void track(
String eventName,
Map<String, String> company,
Map<String, String> user,
Map<String, Object> traits,
TrackOptions options) {
track(eventName, company, user, traits, 1, options);
}

public void track(
String eventName,
Map<String, String> company,
Map<String, String> user,
Map<String, Object> traits,
Integer quantity,
TrackOptions options) {
if (offline) return;

try {
Expand All @@ -600,13 +636,7 @@ public void track(
.quantity(quantity)
.build();

CreateEventRequestBody event = CreateEventRequestBody.builder()
.eventType(EventType.TRACK)
.body(EventBody.of(body))
.sentAt(OffsetDateTime.now())
.build();

eventBuffer.push(event);
eventBuffer.push(buildTrackEvent(EventBody.of(body), options));

// Update cached company metrics if datastream is active
if (company != null && !company.isEmpty() && dataStreamClient != null && dataStreamClient.isConnected()) {
Expand All @@ -621,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 {
Expand Down
Loading
Loading