Skip to content

[FSSDK-11455] Java - Add SDK Multi-Region Support for Data Hosting #573

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Aug 1, 2025
Merged
Show file tree
Hide file tree
Changes from 7 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
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ public class DatafileProjectConfig implements ProjectConfig {
private final boolean anonymizeIP;
private final boolean sendFlagDecisions;
private final Boolean botFiltering;
private final String region;
private final String hostForODP;
private final String publicKeyForODP;
private final List<Attribute> attributes;
Expand Down Expand Up @@ -102,19 +103,20 @@ public class DatafileProjectConfig implements ProjectConfig {
// v2 constructor
public DatafileProjectConfig(String accountId, String projectId, String version, String revision, List<Group> groups,
List<Experiment> experiments, List<Attribute> attributes, List<EventType> eventType,
List<Audience> audiences) {
this(accountId, projectId, version, revision, groups, experiments, attributes, eventType, audiences, false);
List<Audience> audiences, String region) {
this(accountId, projectId, version, revision, groups, experiments, attributes, eventType, audiences, false, region);
}

// v3 constructor
public DatafileProjectConfig(String accountId, String projectId, String version, String revision, List<Group> groups,
List<Experiment> experiments, List<Attribute> attributes, List<EventType> eventType,
List<Audience> audiences, boolean anonymizeIP) {
List<Audience> audiences, boolean anonymizeIP, String region) {
this(
accountId,
anonymizeIP,
false,
null,
region,
projectId,
revision,
null,
Expand All @@ -138,6 +140,7 @@ public DatafileProjectConfig(String accountId,
boolean anonymizeIP,
boolean sendFlagDecisions,
Boolean botFiltering,
String region,
String projectId,
String revision,
String sdkKey,
Expand All @@ -162,6 +165,7 @@ public DatafileProjectConfig(String accountId,
this.anonymizeIP = anonymizeIP;
this.sendFlagDecisions = sendFlagDecisions;
this.botFiltering = botFiltering;
this.region = region != null ? region : "US";

this.attributes = Collections.unmodifiableList(attributes);
this.audiences = Collections.unmodifiableList(audiences);
Expand Down Expand Up @@ -434,6 +438,11 @@ public Boolean getBotFiltering() {
return botFiltering;
}

@Override
public String getRegion() {
return region;
}

@Override
public List<Group> getGroups() {
return groups;
Expand Down Expand Up @@ -612,6 +621,7 @@ public String toString() {
", version='" + version + '\'' +
", anonymizeIP=" + anonymizeIP +
", botFiltering=" + botFiltering +
", region=" + region +
", attributes=" + attributes +
", audiences=" + audiences +
", typedAudiences=" + typedAudiences +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,4 +142,7 @@ public String toString() {
return version;
}
}


String getRegion();
}
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,18 @@ public ProjectConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializa
sendFlagDecisions = jsonObject.get("sendFlagDecisions").getAsBoolean();
}

String region = "US";

if (jsonObject.has("region")) {
region = jsonObject.get("region").getAsString();
}

return new DatafileProjectConfig(
accountId,
anonymizeIP,
sendFlagDecisions,
botFiltering,
region,
projectId,
revision,
sdkKey,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,18 @@ public DatafileProjectConfig deserialize(JsonParser parser, DeserializationConte
}
}

String region = "US";

if (node.hasNonNull("region")) {
region = node.get("region").textValue();
}

return new DatafileProjectConfig(
accountId,
anonymizeIP,
sendFlagDecisions,
botFiltering,
region,
projectId,
revision,
sdkKey,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,20 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse
sendFlagDecisions = rootObject.getBoolean("sendFlagDecisions");
}

String region = "US"; // Default to US
if (rootObject.has("region")) {
String regionString = rootObject.getString("region");
if ("EU".equalsIgnoreCase(regionString)) {
region = "EU";
}
}

return new DatafileProjectConfig(
accountId,
anonymizeIP,
sendFlagDecisions,
botFiltering,
region,
projectId,
revision,
sdkKey,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,20 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse
sendFlagDecisions = (Boolean) rootObject.get("sendFlagDecisions");
}

String region = "US"; // Default to US
if (rootObject.containsKey("region")) {
String regionString = (String) rootObject.get("region");
if ("EU".equalsIgnoreCase(regionString)) {
region = "EU";
}
}

return new DatafileProjectConfig(
accountId,
anonymizeIP,
sendFlagDecisions,
botFiltering,
region,
projectId,
revision,
sdkKey,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
*
* Copyright 2016-2020, 2022, Optimizely and contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.optimizely.ab.event.internal;

import java.util.HashMap;
import java.util.Map;

/**
* EventEndpoints provides region-specific endpoint URLs for Optimizely events.
* Similar to the TypeScript logxEndpoint configuration.
*/
public class EventEndpoints {

private static final Map<String, String> LOGX_ENDPOINTS = new HashMap<>();

static {
LOGX_ENDPOINTS.put("US", "https://logx.optimizely.com/v1/events");
LOGX_ENDPOINTS.put("EU", "https://eu.logx.optimizely.com/v1/events");
}

/**
* Get the event endpoint URL for the specified region.
* Defaults to US region endpoint if region is null.
*
* @param region the region for which to get the endpoint
* @return the endpoint URL for the specified region, or US endpoint if region is null
*/
public static String getEndpointForRegion(String region) {
if (region == null) {
return LOGX_ENDPOINTS.get("US");
}
return LOGX_ENDPOINTS.get(region);
}

/**
* Get the default event endpoint URL (US region).
*
* @return the default endpoint URL
*/
public static String getDefaultEndpoint() {
return LOGX_ENDPOINTS.get("US");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@
*/
public class EventFactory {
private static final Logger logger = LoggerFactory.getLogger(EventFactory.class);
public static final String EVENT_ENDPOINT = "https://logx.optimizely.com/v1/events"; // Should be part of the datafile
private static final String ACTIVATE_EVENT_KEY = "campaign_activated";

public static LogEvent createLogEvent(UserEvent userEvent) {
Expand All @@ -52,6 +51,7 @@ public static LogEvent createLogEvent(UserEvent userEvent) {
public static LogEvent createLogEvent(List<UserEvent> userEvents) {
EventBatch.Builder builder = new EventBatch.Builder();
List<Visitor> visitors = new ArrayList<>(userEvents.size());
String eventEndpoint = "https://logx.optimizely.com/v1/events";

for (UserEvent userEvent: userEvents) {

Expand All @@ -71,6 +71,8 @@ public static LogEvent createLogEvent(List<UserEvent> userEvents) {
UserContext userContext = userEvent.getUserContext();
ProjectConfig projectConfig = userContext.getProjectConfig();

eventEndpoint = EventEndpoints.getEndpointForRegion(projectConfig.getRegion());

builder
.setClientName(ClientEngineInfo.getClientEngineName())
.setClientVersion(BuildVersionInfo.getClientVersion())
Expand All @@ -85,7 +87,7 @@ public static LogEvent createLogEvent(List<UserEvent> userEvents) {
}

builder.setVisitors(visitors);
return new LogEvent(LogEvent.RequestMethod.POST, EVENT_ENDPOINT, Collections.emptyMap(), builder.build());
return new LogEvent(LogEvent.RequestMethod.POST, eventEndpoint, Collections.emptyMap(), builder.build());
}

private static Visitor createVisitor(ImpressionEvent impressionEvent) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ private static ProjectConfig generateValidProjectConfigV2() {
Collections.<TrafficAllocation>emptyList());
List<Group> groups = asList(randomPolicyGroup, overlappingPolicyGroup);

return new DatafileProjectConfig("789", "1234", "2", "42", groups, experiments, attributes, events, audiences);
return new DatafileProjectConfig("789", "1234", "2", "42", groups, experiments, attributes, events, audiences, "US");
}

private static final ProjectConfig NO_AUDIENCE_PROJECT_CONFIG_V2 = generateNoAudienceProjectConfigV2();
Expand Down Expand Up @@ -209,7 +209,7 @@ private static ProjectConfig generateNoAudienceProjectConfigV2() {
);

return new DatafileProjectConfig("789", "1234", "2", "42", Collections.<Group>emptyList(), experiments, attributes,
events, Collections.<Audience>emptyList());
events, Collections.<Audience>emptyList(), "US");
}

private static final ProjectConfig VALID_PROJECT_CONFIG_V3 = generateValidProjectConfigV3();
Expand Down Expand Up @@ -326,7 +326,7 @@ private static ProjectConfig generateValidProjectConfigV3() {
List<Group> groups = asList(randomPolicyGroup, overlappingPolicyGroup);

return new DatafileProjectConfig("789", "1234", "3", "42", groups, experiments, attributes, events, audiences,
true);
true, "US");
}

private static final ProjectConfig NO_AUDIENCE_PROJECT_CONFIG_V3 = generateNoAudienceProjectConfigV3();
Expand Down Expand Up @@ -379,7 +379,7 @@ private static ProjectConfig generateNoAudienceProjectConfigV3() {
);

return new DatafileProjectConfig("789", "1234", "3", "42", Collections.<Group>emptyList(), experiments, attributes,
events, Collections.<Audience>emptyList(), true);
events, Collections.<Audience>emptyList(), true, "US");
}

private static final ProjectConfig VALID_PROJECT_CONFIG_V4 = generateValidProjectConfigV4();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public class ValidProjectConfigV4 {
private static final String ENVIRONMENT_KEY = "production";
private static final String VERSION = "4";
private static final Boolean SEND_FLAG_DECISIONS = true;
private static final String REGION = "US";

// attributes
private static final String ATTRIBUTE_HOUSE_ID = "553339214";
Expand Down Expand Up @@ -1564,6 +1565,7 @@ public static ProjectConfig generateValidProjectConfigV4() {
ANONYMIZE_IP,
SEND_FLAG_DECISIONS,
BOT_FILTERING,
REGION,
PROJECT_ID,
REVISION,
SDK_KEY,
Expand Down Expand Up @@ -1666,6 +1668,7 @@ public static ProjectConfig generateValidProjectConfigV4_holdout() {
ANONYMIZE_IP,
SEND_FLAG_DECISIONS,
BOT_FILTERING,
REGION,
PROJECT_ID,
REVISION,
SDK_KEY,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public void setUp() throws Exception {
eventProcessor = new ForwardingEventProcessor(logEvent -> {
assertNotNull(logEvent.getEventBatch());
assertEquals(logEvent.getRequestMethod(), LogEvent.RequestMethod.POST);
assertEquals(logEvent.getEndpointUrl(), EventFactory.EVENT_ENDPOINT);
assertEquals(logEvent.getEndpointUrl(), EventEndpoints.getDefaultEndpoint());
atomicBoolean.set(true);
}, notificationCenter);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
*
* Copyright 2025, Optimizely and contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.optimizely.ab.event.internal;

import org.junit.Test;
import static org.junit.Assert.*;

/**
* Tests for EventEndpoints class to test event endpoints
*/
public class EventEndpointsTest {

@Test
public void testGetEndpointForUSRegion() {
String endpoint = EventEndpoints.getEndpointForRegion("US");
assertEquals("https://logx.optimizely.com/v1/events", endpoint);
}

@Test
public void testGetEndpointForEURegion() {
String endpoint = EventEndpoints.getEndpointForRegion("EU");
assertEquals("https://eu.logx.optimizely.com/v1/events", endpoint);
}

@Test
public void testGetDefaultEndpoint() {
String defaultEndpoint = EventEndpoints.getDefaultEndpoint();
assertEquals("https://logx.optimizely.com/v1/events", defaultEndpoint);
}

@Test
public void testGetEndpointForNullRegion() {
String endpoint = EventEndpoints.getEndpointForRegion(null);
assertEquals("https://logx.optimizely.com/v1/events", endpoint);
}

@Test
public void testDefaultBehaviorAlwaysReturnsUS() {
// Test that both null region and default endpoint return the same US endpoint
String nullRegionEndpoint = EventEndpoints.getEndpointForRegion(null);
String defaultEndpoint = EventEndpoints.getDefaultEndpoint();
String usEndpoint = EventEndpoints.getEndpointForRegion("US");

assertEquals("All should return US endpoint", usEndpoint, nullRegionEndpoint);
assertEquals("All should return US endpoint", usEndpoint, defaultEndpoint);
assertEquals("Should be US endpoint", "https://logx.optimizely.com/v1/events", nullRegionEndpoint);
}
}
Loading
Loading