From de720a25a576b1c22fec1e7808819fc3f65bdb54 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Mon, 2 Nov 2026 09:58:57 -0500 Subject: [PATCH 01/17] feat(analytics): hierarchical groups support Co-Authored-By: Claude Opus 5.5 --- .../demo/HierarchicalFlagsDemo.java | 150 ++++ .../mixpanel/mixpanelapi/ClientDelivery.java | 2 +- .../mixpanel/mixpanelapi/MessageBuilder.java | 364 ++++++++- .../mixpanelapi/MixpanelIdentity.java | 194 +++++ .../mixpanelapi/featureflags/FlagContext.java | 96 +++ .../featureflags/config/BaseFlagsConfig.java | 41 +- .../featureflags/config/LocalFlagsConfig.java | 6 +- .../config/RemoteFlagsConfig.java | 6 +- .../model/ExperimentationFlag.java | 17 + .../provider/BaseFlagsProvider.java | 37 +- .../provider/LocalFlagsProvider.java | 111 ++- .../provider/RemoteFlagsProvider.java | 5 +- .../HierarchicalGroupsScenariosTest.java | 642 ++++++++++++++++ .../mixpanelapi/HierarchicalGroupsTest.java | 709 ++++++++++++++++++ .../provider/LocalFlagsProviderTest.java | 276 +++++++ .../provider/RemoteFlagsProviderTest.java | 29 + 16 files changed, 2653 insertions(+), 32 deletions(-) create mode 100644 src/demo/java/com/mixpanel/mixpanelapi/demo/HierarchicalFlagsDemo.java create mode 100644 src/main/java/com/mixpanel/mixpanelapi/MixpanelIdentity.java create mode 100644 src/main/java/com/mixpanel/mixpanelapi/featureflags/FlagContext.java create mode 100644 src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsScenariosTest.java create mode 100644 src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java diff --git a/src/demo/java/com/mixpanel/mixpanelapi/demo/HierarchicalFlagsDemo.java b/src/demo/java/com/mixpanel/mixpanelapi/demo/HierarchicalFlagsDemo.java new file mode 100644 index 0000000..da84f76 --- /dev/null +++ b/src/demo/java/com/mixpanel/mixpanelapi/demo/HierarchicalFlagsDemo.java @@ -0,0 +1,150 @@ +package com.mixpanel.mixpanelapi.demo; + +import com.mixpanel.mixpanelapi.MixpanelAPI; +import com.mixpanel.mixpanelapi.MixpanelIdentity; +import com.mixpanel.mixpanelapi.ServiceAccountCredential; +import com.mixpanel.mixpanelapi.featureflags.FlagContext; +import com.mixpanel.mixpanelapi.featureflags.config.LocalFlagsConfig; +import com.mixpanel.mixpanelapi.featureflags.config.RemoteFlagsConfig; +import com.mixpanel.mixpanelapi.featureflags.model.SelectedVariant; +import com.mixpanel.mixpanelapi.featureflags.provider.BaseFlagsProvider; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.logging.ConsoleHandler; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Evaluates one feature flag against a real Mixpanel project, once per identity shape, + * so the hierarchical groups changes can be seen end to end rather than only in tests. + * + * Reads credentials from the environment so nothing lands in the shell history: + * + *
+ *   MIXPANEL_TOKEN          required, the project token
+ *   MIXPANEL_FLAG_KEY       optional, defaults to hierchical-group-testing
+ *   MIXPANEL_COMPANY_ID     optional, defaults to acme
+ *   MIXPANEL_USER_ID        optional, defaults to user_123
+ *   MIXPANEL_FLAGS_MODE     local or remote, defaults to remote
+ *   MIXPANEL_SA_USERNAME    service account, optional for local evaluation
+ *   MIXPANEL_SA_SECRET      service account
+ *   MIXPANEL_PROJECT_ID     service account
+ * 
+ * + * With a service account it evaluates locally; with only a token it evaluates remotely. + */ +public class HierarchicalFlagsDemo { + + public static void main(String[] args) throws Exception { + String token = env("MIXPANEL_TOKEN", null); + if (token == null) { + System.err.println("Set MIXPANEL_TOKEN to the project token first."); + System.exit(2); + } + String flagKey = env("MIXPANEL_FLAG_KEY", "hierchical-group-testing"); + String companyId = env("MIXPANEL_COMPANY_ID", "acme"); + String userId = env("MIXPANEL_USER_ID", "user_123"); + boolean local = "local".equalsIgnoreCase(env("MIXPANEL_FLAGS_MODE", "remote")); + + showSdkWarnings(); + + System.out.println("flag " + flagKey); + System.out.println("evaluation " + (local ? "local" : "remote")); + System.out.println("company " + companyId); + System.out.println("user " + userId); + System.out.println(); + + MixpanelAPI mixpanel = build(token, local); + BaseFlagsProvider flags = local ? mixpanel.getLocalFlags() : mixpanel.getRemoteFlags(); + if (local) { + mixpanel.getLocalFlags().startPollingForDefinitions(); + Thread.sleep(3000); // let the first definition fetch land + } + + evaluate(flags, flagKey, "hierarchical: user + company", + FlagContext.builder() + .identity(MixpanelIdentity.builder().userId(userId).companyId(companyId).build()) + .build()); + + evaluate(flags, flagKey, "hierarchical: company only", + FlagContext.builder() + .identity(MixpanelIdentity.builder().companyId(companyId).build()) + .build()); + + evaluate(flags, flagKey, "anonymous: device only", + FlagContext.builder() + .identity(MixpanelIdentity.builder().deviceId("device_abc").build()) + .build()); + + evaluate(flags, flagKey, "combined distinct id", + FlagContext.builder() + .identity(MixpanelIdentity.builder() + .distinctId("$company:" + companyId + "|$user:" + userId).build()) + .build()); + + evaluate(flags, flagKey, "combined distinct id + hierarchical keys", + FlagContext.builder() + .identity(MixpanelIdentity.builder() + .distinctId("$company:" + companyId + "|$user:" + userId) + .userId(userId).companyId(companyId).build()) + .build()); + + mixpanel.close(); + } + + private static MixpanelAPI build(String token, boolean local) { + if (!local) { + return new MixpanelAPI(RemoteFlagsConfig.builder() + .projectToken(token) + .hierarchicalGroups(true) + .build()); + } + LocalFlagsConfig config = LocalFlagsConfig.builder() + .projectToken(token) + .hierarchicalGroups(true) + .enablePolling(false) + .build(); + MixpanelAPI.Builder builder = new MixpanelAPI.Builder().flagsConfig(config); + // The definitions endpoint accepts the project token, so a service account is optional. + if (env("MIXPANEL_SA_USERNAME", null) != null) { + builder.credentials(new ServiceAccountCredential( + Long.parseLong(env("MIXPANEL_PROJECT_ID", "0")), + env("MIXPANEL_SA_USERNAME", null), + env("MIXPANEL_SA_SECRET", null))); + } + return builder.build(); + } + + private static void evaluate(BaseFlagsProvider flags, String flagKey, String label, + Map context) { + System.out.println(label); + System.out.println(" context " + new LinkedHashMap<>(context)); + try { + SelectedVariant variant = + flags.getVariant(flagKey, new SelectedVariant((Object) false), + context); + System.out.println(" variant " + variant.getVariantKey() + + " value=" + variant.getVariantValue() + + " source=" + variant.getSource()); + } catch (Throwable t) { + System.out.println(" THREW " + t.getClass().getSimpleName() + ": " + t.getMessage()); + } + System.out.println(); + } + + /** The SDK reports dropped exposures and missing context through java.util.logging. */ + private static void showSdkWarnings() { + Logger root = Logger.getLogger("com.mixpanel"); + ConsoleHandler handler = new ConsoleHandler(); + handler.setLevel(Level.ALL); + root.addHandler(handler); + root.setLevel(Level.ALL); + root.setUseParentHandlers(false); + } + + private static String env(String name, String fallback) { + String value = System.getenv(name); + return value == null || value.isEmpty() ? fallback : value; + } +} diff --git a/src/main/java/com/mixpanel/mixpanelapi/ClientDelivery.java b/src/main/java/com/mixpanel/mixpanelapi/ClientDelivery.java index 6ba467e..bdda682 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/ClientDelivery.java +++ b/src/main/java/com/mixpanel/mixpanelapi/ClientDelivery.java @@ -71,7 +71,7 @@ else if (!messageType.equals("event") && !messageType.equals("people") && !messa ret = false; } } - } catch (JSONException e) { + } catch (Exception e) { ret = false; } diff --git a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java index 45ed07d..ac1d0a8 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java +++ b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java @@ -2,12 +2,18 @@ import java.text.DateFormat; import java.text.SimpleDateFormat; +import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.Date; import java.util.Iterator; +import java.util.LinkedHashSet; import java.util.Map; +import java.util.Set; import java.util.TimeZone; import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; import org.json.JSONArray; import org.json.JSONException; @@ -23,11 +29,75 @@ public class MessageBuilder { private static final String ENGAGE_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss"; + private static final Logger logger = Logger.getLogger(MessageBuilder.class.getName()); + private static final String USER_ID_GROUP_KEY = "$user_id"; + // /groups has no $add, $append or $merge + private static final Set GROUP_OPERATIONS = Collections.unmodifiableSet(new LinkedHashSet<>( + Arrays.asList("$set", "$set_once", "$unset", "$union", "$remove", "$delete"))); private final String mToken; + private final boolean mHierarchicalGroups; + private final String mCompanyId; public MessageBuilder(String token) { + this(token, false); + } + + /** + * Creates a MessageBuilder for a project that may use hierarchical groups. + * + * When hierarchicalGroups is true, Mixpanel builds the distinct id from {@code $user_id} and + * {@code $company_id}. Prefer the {@link MixpanelIdentity} overloads, such as + * {@link #event(MixpanelIdentity, String, JSONObject)}, which send those properties. + * {@link #event(String, String, JSONObject)} still works, and Mixpanel accepts a distinct id + * it built itself, but a distinct id that is not in that form names a different user that + * nothing reconciles later. See {@link MixpanelIdentity.Builder#distinctId(String)}. + * + * User profiles are group profiles keyed by {@code $user_id}, so they support only what + * {@code /groups} supports. {@link #increment}, {@link #append} and {@link #trackCharge} have + * no equivalent there and throw {@link UnsupportedOperationException}; set, setOnce, unset, + * union, remove and delete work, and {@link #set(MixpanelIdentity, JSONObject)} and its + * siblings address a profile without needing a distinct id. No user profile update goes to + * {@code /engage}: the distinct id overloads, such as {@link #set(String, JSONObject)}, accept + * only a distinct id of the form {@code $company:{company}|$user:{user}}, send it to + * {@code /groups} as that user's profile within the company, and throw + * {@link IllegalArgumentException} for any other distinct id. Group profile messages require a + * known company, either through {@link #forCompany(String)} or a {@code $company_id} modifier. + * + * @param token the Mixpanel project token + * @param hierarchicalGroups true if the project uses hierarchical groups + */ + public MessageBuilder(String token, boolean hierarchicalGroups) { + this(token, hierarchicalGroups, null); + } + + private MessageBuilder(String token, boolean hierarchicalGroups, String companyId) { mToken = token; + mHierarchicalGroups = hierarchicalGroups; + mCompanyId = companyId; + } + + /** + * Returns a MessageBuilder that adds {@code $company_id} to every group profile + * message it builds. Hierarchical group projects require {@code $company_id} on + * every {@code /groups} write, including the company's own profile and user profiles: + *
+     * {@code
+     *     MessageBuilder acme = messageBuilder.forCompany("acme");
+     *     acme.groupSet("$company_id", "acme", companyProperties);
+     *     acme.groupSet("$user_id", "user_123", userProperties);
+     *     acme.groupSet("department", "finance", departmentProperties);
+     * }
+     * 
+ * + * @param companyId the id of the top-level company + * @return a new MessageBuilder bound to the given company + */ + public MessageBuilder forCompany(String companyId) { + if (companyId == null || companyId.isEmpty()) { + throw new IllegalArgumentException("companyId must be non-empty"); + } + return new MessageBuilder(mToken, mHierarchicalGroups, companyId); } /*** @@ -40,7 +110,10 @@ public MessageBuilder(String token) { * to get the most out of your metrics. * * @param distinctId a string uniquely identifying the individual cause associated with this event - * (for example, the user id of a signing-in user, or the hostname of a server) + * (for example, the user id of a signing-in user, or the hostname of a server). + * On a hierarchical groups project this must be a distinct id Mixpanel built, of the + * form {@code $company:{company}|$user:{user}}, which ingestion parses back into + * {@code $company_id} and {@code $user_id}. Any other value names a separate user. * @param eventName a human readable name for the event, for example "Purchase", or "Threw Exception" * @param properties a JSONObject associating properties with the event. These are useful * for reporting and segmentation of events. It is often useful not only to include @@ -84,6 +157,73 @@ public JSONObject event(String distinctId, String eventName, JSONObject properti } } + /** + * Creates a message tracking an event for the given identity. In hierarchical + * group mode the message carries {@code $user_id} and {@code $company_id} instead + * of {@code distinct_id}; Mixpanel constructs the distinct id from them. + * Identity keys replace any values for the same keys in properties. + * + * @param identity who the event belongs to + * @param eventName a human readable name for the event + * @param properties a JSONObject associating properties with the event, or null + * @return event message for consumption by MixpanelAPI + * @see #event(String, String, JSONObject) + */ + public JSONObject event(MixpanelIdentity identity, String eventName, JSONObject properties) { + requireIdentityMatchesProject(identity); + return event(identity.getDistinctId(), eventName, withIdentity(identity, properties)); + } + + /** + * Creates a message importing a historical event for the given identity. + * + * @param identity who the event belongs to + * @param eventName a human readable name for the event + * @param properties a JSONObject associating properties with the event, or null + * @return import event message for consumption by MixpanelAPI + * @see #importEvent(String, String, JSONObject) + * @see #event(MixpanelIdentity, String, JSONObject) + */ + public JSONObject importEvent(MixpanelIdentity identity, String eventName, JSONObject properties) { + requireIdentityMatchesProject(identity); + return importEvent(identity.getDistinctId(), eventName, withIdentity(identity, properties)); + } + + /** + * Hierarchical groups is a project setting, so a hierarchical identity is only valid on a + * builder created for a hierarchical groups project. Ingestion accepts the keys on any + * project but ignores them, so a mismatch would be silently mis-attributed. + */ + private void requireIdentityMatchesProject(MixpanelIdentity identity) { + if (identity == null) { + throw new IllegalArgumentException("identity must be non-null"); + } + if (identity.isHierarchical() && !mHierarchicalGroups) { + throw new IllegalArgumentException(identity + " requires a hierarchical groups MessageBuilder: " + + "new MessageBuilder(token, true)"); + } + // The company is part of a hierarchical user's identity, not a one-time association. A + // $user_id sent without one resolves to a separate user that nothing reconciles later. + // A device id rescues that case: Mixpanel ignores the user id, keeps the event under the + // device, and links it to the company-scoped user once a later event carries all three. + if (mHierarchicalGroups && identity.getUserId() != null && identity.getCompanyId() == null + && identity.getDeviceId() == null) { + throw new IllegalArgumentException(identity + " needs a company on a hierarchical groups project. " + + "A user id sent without one, and without a device id, names a separate user that cannot " + + "be merged afterwards."); + } + } + + private static JSONObject withIdentity(MixpanelIdentity identity, JSONObject properties) { + try { + JSONObject merged = properties == null ? new JSONObject() : new JSONObject(properties.toString()); + identity.applyTo(merged); + return merged; + } catch (JSONException e) { + throw new RuntimeException("Can't construct a Mixpanel message", e); + } + } + /** * Creates a message for importing historical events (events older than 5 days) to Mixpanel via the /import endpoint. * This method is similar to event(), but is designed for the import endpoint which requires: @@ -465,6 +605,149 @@ public JSONObject unset(String distinctId, Collection propertyNames, JSO return peopleMessage(distinctId, "$unset", propNamesArray, modifiers); } + /** + * Sets properties on the user profile for the given identity. For a hierarchical + * groups identity the update is sent to {@code /groups} as the {@code $user_id} + * group profile within the identity's company; for a distinct id identity it is + * the same as {@link #set(String, JSONObject)}. + * + *
+     * {@code
+     *     JSONObject message = messageBuilder.set(MixpanelIdentity.builder().userId("user_123").companyId("acme").build(),
+     *             userProperties);
+     *     mixpanelApi.sendMessage(message);
+     * }
+     * 
+ * + * Hierarchical groups projects support set, setOnce, unset, union, remove and delete. + * {@code $add} and {@code $append} have no group profile equivalent, so increment, + * append and trackCharge have no identity overload. + * + * @param identity whose profile to change + * @param properties a collection of properties to set on the profile + * @return profile set message for consumption by MixpanelAPI + * + * @throws IllegalArgumentException if the identity cannot name a user profile in this project, + * for example a company-only identity, or on a hierarchical groups builder a distinct id + * not of the form {@code $company:{company}|$user:{user}} + */ + public JSONObject set(MixpanelIdentity identity, JSONObject properties) { + return set(identity, properties, null); + } + + /** + * @see #set(MixpanelIdentity, JSONObject) + */ + public JSONObject set(MixpanelIdentity identity, JSONObject properties, JSONObject modifiers) { + return profileMessage(identity, "$set", properties, modifiers); + } + + /** + * Sets properties on the user profile for the given identity only if they are not already set. + * + * @see #set(MixpanelIdentity, JSONObject) + */ + public JSONObject setOnce(MixpanelIdentity identity, JSONObject properties) { + return setOnce(identity, properties, null); + } + + /** + * @see #setOnce(MixpanelIdentity, JSONObject) + */ + public JSONObject setOnce(MixpanelIdentity identity, JSONObject properties, JSONObject modifiers) { + return profileMessage(identity, "$set_once", properties, modifiers); + } + + /** + * Deletes the user profile for the given identity. + * + * @see #set(MixpanelIdentity, JSONObject) + */ + public JSONObject delete(MixpanelIdentity identity) { + return delete(identity, null); + } + + /** + * @see #delete(MixpanelIdentity) + */ + public JSONObject delete(MixpanelIdentity identity, JSONObject modifiers) { + return profileMessage(identity, "$delete", new JSONObject(), modifiers); + } + + /** + * Removes values from list properties on the user profile for the given identity. + * + * @see #set(MixpanelIdentity, JSONObject) + */ + public JSONObject remove(MixpanelIdentity identity, JSONObject properties) { + return remove(identity, properties, null); + } + + /** + * @see #remove(MixpanelIdentity, JSONObject) + */ + public JSONObject remove(MixpanelIdentity identity, JSONObject properties, JSONObject modifiers) { + return profileMessage(identity, "$remove", properties, modifiers); + } + + /** + * Merges values into list properties on the user profile for the given identity, + * ignoring values already present. + * + * @see #set(MixpanelIdentity, JSONObject) + */ + public JSONObject union(MixpanelIdentity identity, Map properties) { + return union(identity, properties, null); + } + + /** + * @see #union(MixpanelIdentity, Map) + */ + public JSONObject union(MixpanelIdentity identity, Map properties, JSONObject modifiers) { + return profileMessage(identity, "$union", new JSONObject(properties), modifiers); + } + + /** + * Removes properties from the user profile for the given identity. + * + * @see #set(MixpanelIdentity, JSONObject) + */ + public JSONObject unset(MixpanelIdentity identity, Collection propertyNames) { + return unset(identity, propertyNames, null); + } + + /** + * @see #unset(MixpanelIdentity, Collection) + */ + public JSONObject unset(MixpanelIdentity identity, Collection propertyNames, JSONObject modifiers) { + return profileMessage(identity, "$unset", new JSONArray(propertyNames), modifiers); + } + + private JSONObject profileMessage(MixpanelIdentity identity, String actionType, Object properties, + JSONObject modifiers) { + requireIdentityMatchesProject(identity); + if (identity.getDistinctId() != null) { + // On a hierarchical groups builder peopleMessage parses a distinct id Mixpanel built into + // its company and user, so this reaches the same /groups profile as the hierarchical form. + return peopleMessage(identity.getDistinctId(), actionType, properties, modifiers); + } + if (!mHierarchicalGroups) { + throw new IllegalArgumentException(identity + " cannot identify a profile on this project. " + + "User profiles are addressed by distinct id unless the project uses hierarchical groups."); + } + if (identity.getUserId() == null) { + if (identity.getCompanyId() == null) { + throw new IllegalArgumentException(identity + " names no user, so it cannot identify a profile. " + + "Device ids identify anonymous events, not profiles."); + } + throw new IllegalArgumentException("User profile updates require a user id, and " + identity + + " has none. Use groupSet(\"$company_id\", companyId, properties) to update the company's " + + "profile."); + } + return buildGroupMessage(USER_ID_GROUP_KEY, identity.getUserId(), actionType, properties, modifiers, + identity.getCompanyId()); + } + /** * Tracks revenue associated with the given distinctId. * @@ -525,8 +808,15 @@ public JSONObject trackCharge(String distinctId, double amount, JSONObject prope * The underlying API is documented at https://developer.mixpanel.com/docs/http * * @param distinctId a string uniquely identifying the individual cause associated with this event - * (for example, the user id of a signing-in user, or the hostname of a server) - * @param actionType a string associated in the HTTP api with the operation (for example, $set or $add) + * (for example, the user id of a signing-in user, or the hostname of a server). + * On a hierarchical groups builder this must be of the form + * {@code $company:{company}|$user:{user}}, and the message is a {@code /groups} update of + * the {@code $user_id} profile within that company, without {@code $distinct_id}. A + * {@code $distinct_id} modifier replaces this argument and is held to the same rule. + * @param actionType a string associated in the HTTP api with the operation (for example, $set or $add). + * On a hierarchical groups builder user profiles are group profiles, so only $set, $set_once, + * $unset, $union, $remove and $delete are supported; $add and $append throw + * UnsupportedOperationException. * @param properties a payload of the operation. Will be converted to JSON, and should be of types * Boolean, Double, Integer, Long, String, JSONArray, JSONObject, the JSONObject.NULL object, or null. * NaN and negative/positive infinity will throw an IllegalArgumentException @@ -534,13 +824,27 @@ public JSONObject trackCharge(String distinctId, double amount, JSONObject prope * be merged as modifiers associated with the update message (for example, "$time" or "$ignore_time") * @return generic user profile message for consumption by MixpanelAPI * - * @throws IllegalArgumentException if properties is not intelligible as a JSONObject property + * @throws IllegalArgumentException if properties is not intelligible as a JSONObject property, or on a + * hierarchical groups builder if the distinct id does not name both a company and a user + * @throws UnsupportedOperationException on a hierarchical groups builder, for $add, $append or any other + * operation /groups does not support * * @see MessageBuilder#set(String distinctId, JSONObject properties) * @see MessageBuilder#delete(String distinctId) * @see MessageBuilder#append(String distinctId, JSONObject properties, JSONObject modifiers) */ public JSONObject peopleMessage(String distinctId, String actionType, Object properties, JSONObject modifiers) { + if (mHierarchicalGroups && !GROUP_OPERATIONS.contains(actionType)) { + // A hierarchical groups user profile is a group profile, so it supports what /groups + // supports. $add and $append have no equivalent there, and no other call reaches them. + throw new UnsupportedOperationException(actionType + " is not supported for hierarchical groups " + + "projects, whose user profiles are group profiles keyed by $user_id. Supported operations " + + "are " + GROUP_OPERATIONS + ", so increment, append and trackCharge have no hierarchical " + + "groups equivalent."); + } + if (mHierarchicalGroups) { + return hierarchicalUserProfileMessage(distinctId, actionType, properties, modifiers); + } JSONObject dataObj = new JSONObject(); if (null == properties) { throw new IllegalArgumentException("Cannot send null properties, use JSONObject.NULL instead"); @@ -575,6 +879,32 @@ public JSONObject peopleMessage(String distinctId, String actionType, Object pro } } + /** + * A hierarchical groups user profile is the {@code $user_id} group profile within the user's + * company, so a user profile update never goes to {@code /engage}. The message's distinct id + * (a {@code $distinct_id} modifier, if given, replaces the argument just as it would in the + * message) must be one Mixpanel built, which names both the user and the company. + */ + private JSONObject hierarchicalUserProfileMessage(String distinctId, String actionType, Object properties, + JSONObject modifiers) { + JSONObject groupModifiers = modifiers; + if (modifiers != null && modifiers.has("$distinct_id")) { + Object override = modifiers.opt("$distinct_id"); + distinctId = override instanceof String ? (String) override : null; + groupModifiers = new JSONObject(modifiers, JSONObject.getNames(modifiers)); + groupModifiers.remove("$distinct_id"); + } + MixpanelIdentity identity = MixpanelIdentity.fromCombinedDistinctId(distinctId); + if (identity == null) { + throw new IllegalArgumentException("User profile updates on hierarchical groups projects need a user " + + "and a company, and the distinct id \"" + distinctId + "\" does not name both. Pass " + + "MixpanelIdentity.builder().userId(userId).companyId(companyId).build(), or a distinct id " + + "of the form $company:{company}|$user:{user}."); + } + return buildGroupMessage(USER_ID_GROUP_KEY, identity.getUserId(), actionType, properties, groupModifiers, + identity.getCompanyId()); + } + /** * Sets properties on the group profile identified by the given groupKey * and groupId, creating the profile if needed. Existing values for the @@ -808,7 +1138,9 @@ public JSONObject groupUnset(String groupKey, String groupId, Collection * * @param groupKey string identifier for the type of group, e.g. 'Company' * @param groupId unique string identifier for the group, e.g. 'Acme Inc.' - * @param actionType a string associated in the HTTP api with the operation (for example, $set or $add) + * @param actionType a string associated in the HTTP api with the operation (for example, $set or $union). + * /groups has no $add, $append or $merge; on a hierarchical groups builder an unsupported + * operation is logged and still built, since Mixpanel, not this library, decides what it accepts. * @param properties a payload of the operation. Will be converted to JSON, and should be of types * Boolean, Double, Integer, Long, String, JSONArray, JSONObject, the JSONObject.NULL object, or null. * NaN and negative/positive infinity will throw an IllegalArgumentException @@ -817,6 +1149,7 @@ public JSONObject groupUnset(String groupKey, String groupId, Collection * @return generic group profile message for consumption by MixpanelAPI * * @throws IllegalArgumentException if properties is not intelligible as a JSONObject property + * @throws IllegalStateException for hierarchical groups projects when no company is known * * @see MessageBuilder#groupSet(String groupKey, String groupId, JSONObject properties) * @see MessageBuilder#groupSetOnce(String groupKey, String groupId, JSONObject properties) @@ -825,6 +1158,23 @@ public JSONObject groupUnset(String groupKey, String groupId, Collection */ public JSONObject groupMessage(String groupKey, String groupId, String actionType, Object properties, JSONObject modifiers) { + JSONObject message = buildGroupMessage(groupKey, groupId, actionType, properties, modifiers, mCompanyId); + // Only the generic entry point can carry an arbitrary actionType; the groupSet family passes constants. + // Warn once the message exists, so a call that threw or built nothing does not claim a dropped update. + if (mHierarchicalGroups && message != null && !GROUP_OPERATIONS.contains(actionType)) { + logger.log(Level.WARNING, "Group profiles do not support " + actionType + " and Mixpanel will drop " + + "the update. Supported operations are " + GROUP_OPERATIONS); + } + return message; + } + + private JSONObject buildGroupMessage(String groupKey, String groupId, String actionType, Object properties, + JSONObject modifiers, String companyId) { + if (mHierarchicalGroups && companyId == null && (modifiers == null || !modifiers.has("$company_id"))) { + throw new IllegalStateException("Group profile updates for hierarchical groups projects require a " + + "company. Use forCompany(companyId) to build them. Nothing to send for group " + + groupKey + "=" + groupId); + } JSONObject dataObj = new JSONObject(); if (null == properties) { throw new IllegalArgumentException("Cannot send null properties, use JSONObject.NULL instead"); @@ -850,6 +1200,10 @@ public JSONObject groupMessage(String groupKey, String groupId, String actionTyp } } } + // $company_id must be top-level, not inside the operation payload + if (companyId != null) { + dataObj.put("$company_id", companyId); + } JSONObject envelope = new JSONObject(); envelope.put("envelope_version", 1); envelope.put("message_type", "group"); diff --git a/src/main/java/com/mixpanel/mixpanelapi/MixpanelIdentity.java b/src/main/java/com/mixpanel/mixpanelapi/MixpanelIdentity.java new file mode 100644 index 0000000..1ef6939 --- /dev/null +++ b/src/main/java/com/mixpanel/mixpanelapi/MixpanelIdentity.java @@ -0,0 +1,194 @@ +package com.mixpanel.mixpanelapi; + +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Identifies who an event or profile update belongs to. + * + * Mixpanel resolves an identity from whichever of {@code distinct_id}, {@code $user_id}, + * {@code $company_id} and {@code $device_id} an event carries, so this type sets any + * combination of them and leaves the rest unset. + * + *
+ * {@code
+ *     MixpanelIdentity.builder().distinctId("user_123").build();               // a plain distinct id
+ *     MixpanelIdentity.builder().userId("user_123").deviceId("device_abc").build();  // simplified id merge
+ *     MixpanelIdentity.builder().userId("user_123").companyId("acme").build();  // hierarchical groups
+ *     MixpanelIdentity.builder().deviceId("device_abc").build();                // anonymous, no user yet
+ *     MixpanelIdentity.builder().companyId("acme").build();                     // a company, no user
+ * }
+ * 
+ * + * Which combinations are valid depends on the project, so {@link MessageBuilder} rather than + * this type decides. On a hierarchical groups project the company is part of the user's + * identity: a {@code $user_id} sent without a {@code $company_id} resolves to a different + * user that nothing reconciles later, so the builder rejects it. A user who belongs to two + * companies is two users, by design. + * + * Instances are immutable. + */ +public final class MixpanelIdentity { + + private final String mDistinctId; + private final String mUserId; + private final String mCompanyId; + private final String mDeviceId; + + private MixpanelIdentity(Builder builder) { + mDistinctId = builder.mDistinctId; + mUserId = builder.mUserId; + mCompanyId = builder.mCompanyId; + mDeviceId = builder.mDeviceId; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private String mDistinctId; + private String mUserId; + private String mCompanyId; + private String mDeviceId; + + private Builder() { + } + + /** + * A distinct id Mixpanel resolves directly, or one it built itself. + * + * On a hierarchical groups project Mixpanel builds the distinct id from the user and + * company, so a raw distinct id names a different, unrelated user. The two are counted + * separately, never appear in the same company report, and cannot be merged afterwards: + * correcting it means re-importing the events. Set one here only when it is one Mixpanel + * itself built, of the form {@code $company:{company}|$user:{user}}. User profile updates + * on a hierarchical groups project accept only that form, and send it to {@code /groups} + * as the user's profile within the company. + */ + public Builder distinctId(String distinctId) { + mDistinctId = requireNonEmptyOrNull(distinctId, "distinctId"); + return this; + } + + /** The user's id. On a hierarchical groups project it only needs to be unique within the company. */ + public Builder userId(String userId) { + mUserId = requireNonEmptyOrNull(userId, "userId"); + return this; + } + + /** The id of the top-level company, on a hierarchical groups project. */ + public Builder companyId(String companyId) { + mCompanyId = requireNonEmptyOrNull(companyId, "companyId"); + return this; + } + + /** + * The client's device id. Events carrying one are attributed to the device until a + * later event names the user, at which point that earlier activity follows the user. + */ + public Builder deviceId(String deviceId) { + mDeviceId = requireNonEmptyOrNull(deviceId, "deviceId"); + return this; + } + + public MixpanelIdentity build() { + if (mDistinctId == null && mUserId == null && mCompanyId == null && mDeviceId == null) { + throw new IllegalArgumentException("An identity needs at least one of distinctId, userId, " + + "companyId or deviceId"); + } + return new MixpanelIdentity(this); + } + + /** Null leaves the value unset; an empty string is a caller error rather than an absent value. */ + private static String requireNonEmptyOrNull(String value, String name) { + if (value != null && value.isEmpty()) { + throw new IllegalArgumentException(name + " must be non-empty"); + } + return value; + } + } + + private static final String COMBINED_COMPANY_PREFIX = "$company:"; + private static final String COMBINED_USER_SEPARATOR = "|$user:"; + + /** + * Parses a distinct id Mixpanel built for a hierarchical groups user, of the form + * {@code $company:{company}|$user:{user}}. The company is the text before the first + * {@code |$user:}; the user is everything after it. + * + * @return an identity with the parsed user and company, or null if the distinct id is not + * in that form or either part is empty + */ + /* package */ static MixpanelIdentity fromCombinedDistinctId(String distinctId) { + if (distinctId == null || !distinctId.startsWith(COMBINED_COMPANY_PREFIX)) { + return null; + } + int separator = distinctId.indexOf(COMBINED_USER_SEPARATOR, COMBINED_COMPANY_PREFIX.length()); + if (separator < 0) { + return null; + } + String companyId = distinctId.substring(COMBINED_COMPANY_PREFIX.length(), separator); + String userId = distinctId.substring(separator + COMBINED_USER_SEPARATOR.length()); + if (companyId.isEmpty() || userId.isEmpty()) { + return null; + } + return builder().userId(userId).companyId(companyId).build(); + } + + public String getDistinctId() { + return mDistinctId; + } + + public String getUserId() { + return mUserId; + } + + public String getCompanyId() { + return mCompanyId; + } + + public String getDeviceId() { + return mDeviceId; + } + + /** + * @return true if this identity names a company, which is what sends it down Mixpanel's + * hierarchical groups resolution + */ + public boolean isHierarchical() { + return mCompanyId != null; + } + + /** + * Writes this identity's keys into the given event properties, replacing any + * caller-supplied values for the same keys. + */ + /* package */ void applyTo(JSONObject properties) throws JSONException { + if (mDistinctId != null) properties.put("distinct_id", mDistinctId); + if (mUserId != null) properties.put("$user_id", mUserId); + if (mCompanyId != null) properties.put("$company_id", mCompanyId); + if (mDeviceId != null) properties.put("$device_id", mDeviceId); + } + + @Override + public String toString() { + StringBuilder out = new StringBuilder("MixpanelIdentity{"); + append(out, "distinctId", mDistinctId); + append(out, "userId", mUserId); + append(out, "companyId", mCompanyId); + append(out, "deviceId", mDeviceId); + return out.append("}").toString(); + } + + private static void append(StringBuilder out, String name, String value) { + if (value == null) { + return; + } + if (out.charAt(out.length() - 1) != '{') { + out.append(", "); + } + out.append(name).append("=").append(value); + } +} diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/FlagContext.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/FlagContext.java new file mode 100644 index 0000000..bd641df --- /dev/null +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/FlagContext.java @@ -0,0 +1,96 @@ +package com.mixpanel.mixpanelapi.featureflags; + +import com.mixpanel.mixpanelapi.MixpanelIdentity; + +import java.util.HashMap; +import java.util.Map; + +/** + * Builds a flag evaluation context with the keys Mixpanel expects, so callers do not + * hand-write reserved keys such as {@code $company_id}. + * + *
+ * {@code
+ *     Map context = FlagContext.builder()
+ *         .identity(MixpanelIdentity.builder().userId("user_123").companyId("acme").build())
+ *         .group("department", "finance")
+ *         .customProperty("plan", "enterprise")
+ *         .build();
+ *     boolean enabled = mixpanel.getLocalFlags().isEnabled("new-checkout", context);
+ * }
+ * 
+ */ +public final class FlagContext { + + private FlagContext() { + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private final Map mContext = new HashMap<>(); + private final Map mCustomProperties = new HashMap<>(); + + private Builder() { + } + + /** + * Sets the identity keys from a {@link MixpanelIdentity}: {@code distinct_id} for a + * legacy identity, or {@code $user_id}, {@code $company_id} and {@code device_id} + * for a hierarchical groups identity. + */ + public Builder identity(MixpanelIdentity identity) { + if (identity.getDistinctId() != null) distinctId(identity.getDistinctId()); + if (identity.getUserId() != null) userId(identity.getUserId()); + if (identity.getCompanyId() != null) companyId(identity.getCompanyId()); + if (identity.getDeviceId() != null) deviceId(identity.getDeviceId()); + return this; + } + + public Builder distinctId(String distinctId) { + mContext.put("distinct_id", distinctId); + return this; + } + + /** Device ids are a valid assignment key and are never namespaced by company. */ + public Builder deviceId(String deviceId) { + mContext.put("device_id", deviceId); + return this; + } + + public Builder userId(String userId) { + mContext.put("$user_id", userId); + return this; + } + + public Builder companyId(String companyId) { + mContext.put("$company_id", companyId); + return this; + } + + /** + * Sets a group the evaluation is for. Flags whose assignment key is this group + * key are assigned on its value, namespaced by company when one is set. + */ + public Builder group(String groupKey, Object groupValue) { + mContext.put(groupKey, groupValue); + return this; + } + + /** Adds a property used by runtime targeting rules. */ + public Builder customProperty(String name, Object value) { + mCustomProperties.put(name, value); + return this; + } + + public Map build() { + Map context = new HashMap<>(mContext); + if (!mCustomProperties.isEmpty()) { + context.put("custom_properties", new HashMap<>(mCustomProperties)); + } + return context; + } + } +} diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/BaseFlagsConfig.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/BaseFlagsConfig.java index 9d3aa9f..d927b82 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/BaseFlagsConfig.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/BaseFlagsConfig.java @@ -13,6 +13,7 @@ public class BaseFlagsConfig { private final String apiHost; private final int requestTimeoutSeconds; private final Executor exposureExecutor; + private final boolean hierarchicalGroups; /** * Creates a new BaseFlagsConfig with specified settings. @@ -23,10 +24,25 @@ public class BaseFlagsConfig { * @param exposureExecutor executor used to dispatch exposure event HTTP sends; may be null */ protected BaseFlagsConfig(String projectToken, String apiHost, int requestTimeoutSeconds, Executor exposureExecutor) { + this(projectToken, apiHost, requestTimeoutSeconds, exposureExecutor, false); + } + + /** + * Creates a new BaseFlagsConfig with specified settings. + * + * @param projectToken the Mixpanel project token + * @param apiHost the API endpoint host + * @param requestTimeoutSeconds HTTP request timeout in seconds + * @param exposureExecutor executor used to dispatch exposure event HTTP sends; may be null + * @param hierarchicalGroups true if the project uses hierarchical groups + */ + protected BaseFlagsConfig(String projectToken, String apiHost, int requestTimeoutSeconds, Executor exposureExecutor, + boolean hierarchicalGroups) { this.projectToken = projectToken; this.apiHost = apiHost; this.requestTimeoutSeconds = requestTimeoutSeconds; this.exposureExecutor = exposureExecutor; + this.hierarchicalGroups = hierarchicalGroups; } /** @@ -57,6 +73,13 @@ public Executor getExposureExecutor() { return exposureExecutor; } + /** + * @return true if the project uses hierarchical groups + */ + public boolean isHierarchicalGroups() { + return hierarchicalGroups; + } + /** * Builder for BaseFlagsConfig. * @@ -68,6 +91,7 @@ public static class Builder> { protected String apiHost = "api.mixpanel.com"; protected int requestTimeoutSeconds = 10; protected Executor exposureExecutor; + protected boolean hierarchicalGroups; /** * Sets the project token. @@ -123,13 +147,28 @@ public T exposureExecutor(Executor exposureExecutor) { return (T) this; } + /** + * Sets whether the project uses hierarchical groups. When true, user and group + * assignment keys are namespaced by the context's {@code $company_id}, and exposure + * events are attributed with {@code $user_id} and {@code $company_id} instead of + * {@code distinct_id}. When false, {@code $company_id} in the context has no effect + * on assignment. + * + * @param hierarchicalGroups true if the project uses hierarchical groups + * @return this builder + */ + public T hierarchicalGroups(boolean hierarchicalGroups) { + this.hierarchicalGroups = hierarchicalGroups; + return (T) this; + } + /** * Builds the BaseFlagsConfig instance. * * @return a new BaseFlagsConfig */ public BaseFlagsConfig build() { - return new BaseFlagsConfig(projectToken, apiHost, requestTimeoutSeconds, exposureExecutor); + return new BaseFlagsConfig(projectToken, apiHost, requestTimeoutSeconds, exposureExecutor, hierarchicalGroups); } } diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/LocalFlagsConfig.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/LocalFlagsConfig.java index f139dce..a1bcc93 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/LocalFlagsConfig.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/LocalFlagsConfig.java @@ -23,8 +23,8 @@ public final class LocalFlagsConfig extends BaseFlagsConfig { * @param enablePolling whether to periodically refresh flag definitions * @param pollingIntervalSeconds time between refresh cycles in seconds */ - private LocalFlagsConfig(String projectToken, String apiHost, int requestTimeoutSeconds, Executor exposureExecutor, boolean enablePolling, int pollingIntervalSeconds) { - super(projectToken, apiHost, requestTimeoutSeconds, exposureExecutor); + private LocalFlagsConfig(String projectToken, String apiHost, int requestTimeoutSeconds, Executor exposureExecutor, boolean hierarchicalGroups, boolean enablePolling, int pollingIntervalSeconds) { + super(projectToken, apiHost, requestTimeoutSeconds, exposureExecutor, hierarchicalGroups); this.enablePolling = enablePolling; this.pollingIntervalSeconds = pollingIntervalSeconds; } @@ -79,7 +79,7 @@ public Builder pollingIntervalSeconds(int pollingIntervalSeconds) { */ @Override public LocalFlagsConfig build() { - return new LocalFlagsConfig(projectToken, apiHost, requestTimeoutSeconds, exposureExecutor, enablePolling, pollingIntervalSeconds); + return new LocalFlagsConfig(projectToken, apiHost, requestTimeoutSeconds, exposureExecutor, hierarchicalGroups, enablePolling, pollingIntervalSeconds); } } diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/RemoteFlagsConfig.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/RemoteFlagsConfig.java index a454e89..2c50e08 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/RemoteFlagsConfig.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/RemoteFlagsConfig.java @@ -19,8 +19,8 @@ public final class RemoteFlagsConfig extends BaseFlagsConfig { * @param requestTimeoutSeconds HTTP request timeout in seconds * @param exposureExecutor executor used to dispatch exposure event HTTP sends; may be null */ - private RemoteFlagsConfig(String projectToken, String apiHost, int requestTimeoutSeconds, Executor exposureExecutor) { - super(projectToken, apiHost, requestTimeoutSeconds, exposureExecutor); + private RemoteFlagsConfig(String projectToken, String apiHost, int requestTimeoutSeconds, Executor exposureExecutor, boolean hierarchicalGroups) { + super(projectToken, apiHost, requestTimeoutSeconds, exposureExecutor, hierarchicalGroups); } /** @@ -35,7 +35,7 @@ public static final class Builder extends BaseFlagsConfig.Builder { */ @Override public RemoteFlagsConfig build() { - return new RemoteFlagsConfig(projectToken, apiHost, requestTimeoutSeconds, exposureExecutor); + return new RemoteFlagsConfig(projectToken, apiHost, requestTimeoutSeconds, exposureExecutor, hierarchicalGroups); } } diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/ExperimentationFlag.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/ExperimentationFlag.java index 1a796ae..1f95f1d 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/ExperimentationFlag.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/ExperimentationFlag.java @@ -23,6 +23,7 @@ public final class ExperimentationFlag { private final UUID experimentId; private final Boolean isExperimentActive; private final String hashSalt; + private final String contextTemplate; /** * Creates a new ExperimentationFlag. @@ -39,6 +40,11 @@ public final class ExperimentationFlag { * @param hashSalt the hash salt for this flag (may be null for legacy flags) */ public ExperimentationFlag(String id, String name, String key, String status, int projectId, RuleSet ruleset, String context, UUID experimentId, Boolean isExperimentActive, String hashSalt) { + this(id, name, key, status, projectId, ruleset, context, experimentId, isExperimentActive, hashSalt, ""); + } + + public ExperimentationFlag(String id, String name, String key, String status, int projectId, RuleSet ruleset, String context, UUID experimentId, Boolean isExperimentActive, String hashSalt, String contextTemplate) { + this.contextTemplate = contextTemplate == null ? "" : contextTemplate; this.id = id; this.name = name; this.key = key; @@ -51,6 +57,17 @@ public ExperimentationFlag(String id, String name, String key, String status, in this.hashSalt = hashSalt; } + /** + * How to build this flag's assignment key when the caller's context does not carry + * {@link #getContext()} directly, as published by the service, for example + * {@code $company:{$company_id}|$user:{$user_id}}. Empty when the service sends none. + * + * @return the template, or an empty string + */ + public String getContextTemplate() { + return contextTemplate; + } + /** * @return the unique identifier for this flag */ diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProvider.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProvider.java index 89f05c4..e6b9937 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProvider.java @@ -207,13 +207,46 @@ public boolean isEnabled(String flagKey, Map context) { // #region Exposure Tracking /** - * Common helper method for tracking exposure events. + * Returns true if an exposure event for this context can be attributed: by distinct_id, + * or on a hierarchical groups project by $company_id as well (plus $user_id when present), + * from which Mixpanel constructs the distinct id. */ - protected void trackExposure(String distinctId, String flagKey, String variantKey, + protected boolean canAttributeExposure(Map context) { + if (config.isHierarchicalGroups() && isPresent(context.get("$company_id"))) { + return true; + } + // A distinct id attributes on any project, including one Mixpanel built from a + // company and user, which ingestion parses back into its parts. + return isPresent(context.get("distinct_id")); + } + + private static boolean isPresent(Object value) { + return value != null && !value.toString().isEmpty(); + } + + /** + * Common helper method for tracking exposure events. For hierarchical groups the + * event carries $user_id and $company_id instead of distinct_id. + */ + protected void trackExposure(Map context, String flagKey, String variantKey, String evaluationMode, Consumer addTimingProperties, UUID experimentId, Boolean isExperimentActive, Boolean isQaTester) { try { JSONObject properties = new JSONObject(); + if (config.isHierarchicalGroups()) { + // Carry whichever identity keys the caller sent; Mixpanel resolves the user + // from the pair, or from a distinct id it built itself. + Object userId = context.get("$user_id"); + if (isPresent(userId)) { + properties.put("$user_id", userId.toString()); + } + Object companyId = context.get("$company_id"); + if (isPresent(companyId)) { + properties.put("$company_id", companyId.toString()); + } + } + Object distinctIdObj = context.get("distinct_id"); + String distinctId = isPresent(distinctIdObj) ? distinctIdObj.toString() : null; properties.put("Experiment name", flagKey); properties.put("Variant name", variantKey); properties.put("$experiment_type", "feature_flag"); diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java index f6c1aff..3a0b14f 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java @@ -18,6 +18,8 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Level; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.logging.Logger; @@ -221,6 +223,12 @@ private Map parseDefinitions(String jsonResponse) { for (int i = 0; i < flags.length(); i++) { JSONObject flagJson = flags.getJSONObject(i); ExperimentationFlag flag = parseFlag(flagJson); + if (flag == null) { + // A definition this SDK cannot fully construct is dropped rather than + // half-evaluated: a flag it does not understand would assign a variant + // the service never chose. + continue; + } definitions.put(flag.getKey(), flag); } } catch (Exception e) { @@ -230,16 +238,79 @@ private Map parseDefinitions(String jsonResponse) { return definitions; } + /** The highest {@code context_template_version} this SDK knows how to apply. */ + private static final int SUPPORTED_CONTEXT_TEMPLATE_VERSION = 1; + + /** + * A template is usable when it names at least one property and every placeholder is named. + * An empty template means the service published none, which is not a defect. + */ + private static boolean isUsableContextTemplate(String template) { + if (template == null || template.isEmpty()) { + return true; + } + Matcher matcher = CONTEXT_PLACEHOLDER.matcher(template); + boolean found = false; + while (matcher.find()) { + if (matcher.group(1).trim().isEmpty()) { + return false; + } + found = true; + } + return found; + } + + /** A {@code {property}} placeholder in a context template. */ + private static final Pattern CONTEXT_PLACEHOLDER = Pattern.compile("\\{([^}]+)\\}"); + + /** + * Fills a context template the service published with values from the evaluation context. + * The template names the properties it needs, so this carries no knowledge of how Mixpanel + * composes an identity: change the format service side and the next fetch carries it. + * + * @return the resolved value, or null when the context cannot produce one + */ + private static String applyContextTemplate(String template, Map context) { + if (template == null || template.isEmpty()) { + return null; + } + StringBuffer out = new StringBuffer(); + Matcher matcher = CONTEXT_PLACEHOLDER.matcher(template); + while (matcher.find()) { + Object value = context.get(matcher.group(1)); + if (value == null || value.toString().isEmpty()) { + // The service rejects a partial identity too, so there is nothing to resolve. + return null; + } + matcher.appendReplacement(out, Matcher.quoteReplacement(value.toString())); + } + matcher.appendTail(out); + return out.toString(); + } + /** * Parses a single flag from JSON. */ - private ExperimentationFlag parseFlag(JSONObject json) { + private ExperimentationFlag parseFlag(JSONObject json) { // null when the definition cannot be fully constructed String id = json.optString("id", ""); String name = json.optString("name", ""); String key = json.optString("key", ""); String status = json.optString("status", ""); int projectId = json.optInt("project_id", 0); String context = json.optString("context", "distinct_id"); + String contextTemplate = json.optString("context_template", ""); + int templateVersion = json.optInt("context_template_version", SUPPORTED_CONTEXT_TEMPLATE_VERSION); + if (templateVersion > SUPPORTED_CONTEXT_TEMPLATE_VERSION) { + logger.log(Level.WARNING, "Dropping flag ''{0}'': context_template_version {1} is newer than this " + + "SDK understands ({2}). Evaluate it remotely, or upgrade the SDK.", + new Object[] {key, templateVersion, SUPPORTED_CONTEXT_TEMPLATE_VERSION}); + return null; + } + if (!isUsableContextTemplate(contextTemplate)) { + logger.log(Level.WARNING, "Dropping flag ''{0}'': context_template ''{1}'' has no usable " + + "placeholders.", new Object[] {key, contextTemplate}); + return null; + } // Parse experiment metadata UUID experimentId = null; @@ -262,7 +333,7 @@ private ExperimentationFlag parseFlag(JSONObject json) { RuleSet ruleset = parseRuleSet(json.optJSONObject("ruleset")); - return new ExperimentationFlag(id, name, key, status, projectId, ruleset, context, experimentId, isExperimentActive, hashSalt); + return new ExperimentationFlag(id, name, key, status, projectId, ruleset, context, experimentId, isExperimentActive, hashSalt, contextTemplate); } /** @@ -390,19 +461,32 @@ public SelectedVariant getVariant(String flagKey, SelectedVariant fall return fallback.withSource(Source.fallback(Source.Fallback.Reason.FLAG_NOT_FOUND)); } - // Extract context value + // The context value is used as sent. Where the caller did not send it, the service + // may publish a template saying how to build it from properties they did send; this + // SDK never composes an identity of its own. String contextProperty = flag.getContext(); Object contextValueObj = context.get(contextProperty); - if (contextValueObj == null) { + String contextValue = contextValueObj != null + ? contextValueObj.toString() + : applyContextTemplate(flag.getContextTemplate(), context); + if (contextValue == null) { logger.log(Level.WARNING, "Variant assignment key property '" + contextProperty + "' not found for flag: " + flagKey); return fallback.withSource(Source.fallback(Source.Fallback.Reason.MISSING_CONTEXT_KEY)); } - String contextValue = contextValueObj.toString(); // Check test user overrides RuleSet ruleset = flag.getRuleset(); if (ruleset.hasTestUserOverrides()) { - String distinctId = context.get("distinct_id") != null ? context.get("distinct_id").toString() : null; + // Overrides are keyed by distinct id. When the flag assigns on distinct_id its + // template builds exactly that, so the same resolution serves both. + String distinctId = context.get("distinct_id") != null + ? context.get("distinct_id").toString() + : ("distinct_id".equals(contextProperty) ? contextValue : null); + if (distinctId == null) { + logger.log(Level.WARNING, "Flag ''{0}'' has test users, but they are keyed by distinct_id " + + "and this context has none. Pinned users will be assigned by rollout instead.", + flagKey); + } if (distinctId != null) { String testVariantKey = ruleset.getTestUserOverrides().get(distinctId); if (testVariantKey != null) { @@ -713,20 +797,19 @@ private void trackLocalExposure(Map context, String flagKey, Str return; } - Object distinctIdObj = context.get("distinct_id"); - if (distinctIdObj == null) { + if (!canAttributeExposure(context)) { // Local eval succeeds when the flag's Variant Assignment Key is // something other than distinct_id (e.g., device_id), but the - // exposure event still needs distinct_id to attribute the user. - // Surface the drop instead of silently returning so callers can - // see they need to include distinct_id in the context. + // exposure event still needs distinct_id ($company_id for hierarchical + // groups) to attribute the user. Surface the drop instead of silently + // returning so callers can see what to include in the context. logger.log(Level.WARNING, - "Cannot track exposure event for flag ''{0}'' without a distinct_id in the context", - flagKey); + "Cannot track exposure event for flag ''{0}'' without a {1} in the context", + new Object[] {flagKey, config.isHierarchicalGroups() ? "$company_id" : "distinct_id"}); return; } - trackExposure(distinctIdObj.toString(), flagKey, variantKey, "local", properties -> { + trackExposure(context, flagKey, variantKey, "local", properties -> { properties.put("Variant fetch latency (ms)", latencyMs); }, experimentId, isExperimentActive, isQaTester); } diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProvider.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProvider.java index d2a84b9..e7fbce2 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProvider.java @@ -185,12 +185,11 @@ private void trackRemoteExposure(Map context, String flagKey, St return; } - Object distinctIdObj = context.get("distinct_id"); - if (distinctIdObj == null) { + if (!canAttributeExposure(context)) { return; } - trackExposure(distinctIdObj.toString(), flagKey, variantKey, "remote", properties -> { + trackExposure(context, flagKey, variantKey, "remote", properties -> { properties.put("Variant fetch start time", startTime); properties.put("Variant fetch complete time", completeTime); }, experimentId, isExperimentActive, isQaTester); diff --git a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsScenariosTest.java b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsScenariosTest.java new file mode 100644 index 0000000..75680a2 --- /dev/null +++ b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsScenariosTest.java @@ -0,0 +1,642 @@ +package com.mixpanel.mixpanelapi; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Before; +import org.junit.Test; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; + +/** + * One test per hierarchical groups scenario. Each builds its messages, delivers them through + * {@link ClientDelivery} and {@link MixpanelAPI}, and asserts the endpoint and exact body sent. + */ +public class HierarchicalGroupsScenariosTest { + + private static final String TOKEN = "test-token"; + private static final String USER_ID = "my_user_id"; + private static final String COMPANY_ID = "my_company_id"; + private static final String TEAM_ID = "my_team_id"; + private static final String DEVICE_ID = "my_device_id"; + private static final String COMBINED_DISTINCT_ID = "$company:" + COMPANY_ID + "|$user:" + USER_ID; + + private static final String TRACK = "/track"; + private static final String ENGAGE = "/engage"; + private static final String GROUPS = "/groups"; + private static final String IMPORT = "/import"; + + private static final Set EVENT_KEYS = setOf("event", "properties"); + private static final Set EVENT_PROPERTY_KEYS = setOf("token", "time", "mp_lib"); + private static final Set IMPORT_PROPERTY_KEYS = setOf("token", "time", "mp_lib", "$insert_id"); + private static final Set GROUP_MESSAGE_KEYS = setOf("$token", "$group_key", "$group_id", "$time", + "$company_id"); + + private static final MixpanelIdentity IDENTIFIED = + MixpanelIdentity.builder().userId(USER_ID).companyId(COMPANY_ID).build(); + private static final MixpanelIdentity IDENTIFIED_WITH_DEVICE = + MixpanelIdentity.builder().userId(USER_ID).companyId(COMPANY_ID).deviceId(DEVICE_ID).build(); + + private static final class Sent { + final String endpoint; + final JSONObject body; + + Sent(String endpoint, JSONObject body) { + this.endpoint = endpoint; + this.body = body; + } + } + + private final List mSent = new ArrayList<>(); + private MixpanelAPI mApi; + private MessageBuilder mBuilder; + private MessageBuilder mCompany; + private MessageBuilder mLegacyBuilder; + + @Before + public void setUp() { + mSent.clear(); + mApi = new MixpanelAPI() { + @Override + boolean sendData(String dataString, String endpointUrl) { + record(dataString, endpointUrl); + return true; + } + + @Override + boolean sendImportData(String dataString, String endpointUrl, String token) { + record(dataString, endpointUrl); + return true; + } + }; + mBuilder = new MessageBuilder(TOKEN, true); + mCompany = mBuilder.forCompany(COMPANY_ID); + mLegacyBuilder = new MessageBuilder(TOKEN); + } + + private void record(String dataString, String endpointUrl) { + String path = endpointUrl.substring(Config.BASE_ENDPOINT.length(), endpointUrl.indexOf('?')); + JSONArray messages = new JSONArray(dataString); + for (int i = 0; i < messages.length(); i++) { + mSent.add(new Sent(path, messages.getJSONObject(i))); + } + } + + private void send(JSONObject... messages) { + ClientDelivery delivery = new ClientDelivery(); + for (JSONObject message : messages) { + delivery.addMessage(message); + } + try { + mApi.deliver(delivery); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private void assertNothingSent() { + send(); + assertEquals("Expected no requests", 0, mSent.size()); + } + + private Sent onlySent(String endpoint) { + assertEquals("Expected exactly one request", 1, mSent.size()); + Sent sent = mSent.get(0); + assertEquals(endpoint, sent.endpoint); + return sent; + } + + // Asserts the single event request and returns its properties, checking their exact key set + private JSONObject onlyEvent(String endpoint, String eventName, Set baseKeys, String... extraKeys) { + JSONObject event = onlySent(endpoint).body; + assertEquals(EVENT_KEYS, keys(event)); + assertEquals(eventName, event.getString("event")); + JSONObject props = event.getJSONObject("properties"); + Set expected = new HashSet<>(baseKeys); + expected.addAll(Arrays.asList(extraKeys)); + assertEquals(expected, keys(props)); + assertEquals(TOKEN, props.getString("token")); + return props; + } + + private JSONObject onlyTrack(String eventName, String... extraKeys) { + return onlyEvent(TRACK, eventName, EVENT_PROPERTY_KEYS, extraKeys); + } + + // Asserts the single /groups request and returns the value of its operation + private Object onlyGroupsUpdate(String groupKey, String groupId, String operation) { + JSONObject message = onlySent(GROUPS).body; + Set expected = new HashSet<>(GROUP_MESSAGE_KEYS); + expected.add(operation); + assertEquals(expected, keys(message)); + assertEquals(TOKEN, message.getString("$token")); + assertEquals(groupKey, message.getString("$group_key")); + assertEquals(groupId, message.getString("$group_id")); + assertEquals(COMPANY_ID, message.getString("$company_id")); + return message.get(operation); + } + + private Object onlyUserUpdate(String operation) { + return onlyGroupsUpdate("$user_id", USER_ID, operation); + } + + private Object onlyCompanyUpdate(String operation) { + return onlyGroupsUpdate("$company_id", COMPANY_ID, operation); + } + + private Object onlyCustomGroupUpdate(String operation) { + return onlyGroupsUpdate("team_id", TEAM_ID, operation); + } + + private static Set keys(JSONObject object) { + Set keys = new HashSet<>(); + for (Iterator iter = object.keys(); iter.hasNext(); ) { + keys.add(iter.next()); + } + return keys; + } + + private static Set setOf(String... values) { + return Collections.unmodifiableSet(new HashSet<>(Arrays.asList(values))); + } + + private static JSONObject json(Object... keyValues) { + JSONObject object = new JSONObject(); + for (int i = 0; i < keyValues.length; i += 2) { + object.put((String) keyValues[i], keyValues[i + 1]); + } + return object; + } + + private static Map unionOf(String key, Object... values) { + Map union = new HashMap<>(); + union.put(key, new JSONArray(Arrays.asList(values))); + return union; + } + + private static void assertJsonEquals(Object expected, Object actual) { + assertEquals(expected.toString(), actual.toString()); + } + + // Track (Anonymous) + + @Test + public void testTrackAnonymous() { + send(mBuilder.event(MixpanelIdentity.builder().deviceId(DEVICE_ID).build(), "Page View", null)); + + JSONObject props = onlyTrack("Page View", "$device_id"); + assertEquals(DEVICE_ID, props.getString("$device_id")); + } + + // Track (Identified) + + @Test + public void testTrackIdentified() { + send(mBuilder.event(IDENTIFIED_WITH_DEVICE, "Signup", null)); + + JSONObject props = onlyTrack("Signup", "$user_id", "$company_id", "$device_id"); + assertEquals(USER_ID, props.getString("$user_id")); + assertEquals(COMPANY_ID, props.getString("$company_id")); + assertEquals(DEVICE_ID, props.getString("$device_id")); + } + + // Track (Identified Without Device) + + @Test + public void testTrackIdentifiedWithoutDevice() { + send(mBuilder.event(IDENTIFIED, "Signup", null)); + + JSONObject props = onlyTrack("Signup", "$user_id", "$company_id"); + assertEquals(USER_ID, props.getString("$user_id")); + assertEquals(COMPANY_ID, props.getString("$company_id")); + } + + // Track With Custom Group + + @Test + public void testTrackWithCustomGroup() { + send(mBuilder.event(IDENTIFIED, "Signup", json("team_id", TEAM_ID))); + + JSONObject props = onlyTrack("Signup", "$user_id", "$company_id", "team_id"); + assertEquals(TEAM_ID, props.get("team_id")); + } + + // Import (Identified) + + @Test + public void testImportIdentified() { + send(mBuilder.importEvent(IDENTIFIED_WITH_DEVICE, "Signup", null)); + + JSONObject props = onlyEvent(IMPORT, "Signup", IMPORT_PROPERTY_KEYS, "$user_id", "$company_id", "$device_id"); + assertEquals(USER_ID, props.getString("$user_id")); + assertEquals(COMPANY_ID, props.getString("$company_id")); + assertEquals(DEVICE_ID, props.getString("$device_id")); + } + + // Identity Overrides Properties + + @Test + public void testIdentityOverridesProperties() { + JSONObject caller = json("$user_id", "other_user", "$company_id", "other_company", "plan", "pro"); + send(mBuilder.event(IDENTIFIED, "Signup", caller)); + + JSONObject props = onlyTrack("Signup", "$user_id", "$company_id", "plan"); + assertEquals(USER_ID, props.getString("$user_id")); + assertEquals(COMPANY_ID, props.getString("$company_id")); + assertEquals("pro", props.getString("plan")); + } + + // Track With Combined Distinct ID + + @Test + public void testTrackWithCombinedDistinctId() { + send(mBuilder.event(COMBINED_DISTINCT_ID, "Signup", null)); + + JSONObject props = onlyTrack("Signup", "distinct_id"); + assertEquals(COMBINED_DISTINCT_ID, props.getString("distinct_id")); + } + + // Track With Combined Distinct ID Identity + + @Test + public void testTrackWithCombinedDistinctIdIdentity() { + MixpanelIdentity identity = MixpanelIdentity.builder().distinctId(COMBINED_DISTINCT_ID).build(); + send(mBuilder.event(identity, "Signup", null)); + + JSONObject props = onlyTrack("Signup", "distinct_id"); + assertEquals(COMBINED_DISTINCT_ID, props.getString("distinct_id")); + } + + // Set User Property + + @Test + public void testSetUserProperty() { + send(mBuilder.set(IDENTIFIED, json("plan", "pro"))); + + assertJsonEquals(json("plan", "pro"), onlyUserUpdate("$set")); + } + + // Set User Property With Combined Distinct ID + + @Test + public void testSetUserPropertyWithCombinedDistinctId() { + send(mBuilder.set(COMBINED_DISTINCT_ID, json("plan", "pro"))); + + // $group_id and $company_id are parsed from the combined distinct id + assertJsonEquals(json("plan", "pro"), onlyUserUpdate("$set")); + } + + // Set User Property With Combined Distinct ID Identity + + @Test + public void testSetUserPropertyWithCombinedDistinctIdIdentity() { + MixpanelIdentity identity = MixpanelIdentity.builder().distinctId(COMBINED_DISTINCT_ID).build(); + send(mBuilder.set(identity, json("plan", "pro"))); + + assertJsonEquals(json("plan", "pro"), onlyUserUpdate("$set")); + } + + // Set User Property With Distinct ID (Unsupported) + + @Test + public void testSetUserPropertyWithDistinctIdIsUnsupported() { + assertThrows(IllegalArgumentException.class, () -> mBuilder.set(USER_ID, json("plan", "pro"))); + assertNothingSent(); + } + + // Set User Properties + + @Test + public void testSetUserProperties() { + send(mBuilder.set(IDENTIFIED, json("plan", "pro", "seats", 5))); + + JSONObject set = (JSONObject) onlyUserUpdate("$set"); + assertEquals(setOf("plan", "seats"), keys(set)); + assertEquals("pro", set.getString("plan")); + assertEquals(5, set.getInt("seats")); + } + + // Set Once User Property + + @Test + public void testSetOnceUserProperty() { + send(mBuilder.setOnce(IDENTIFIED, json("first_seen", "today"))); + + assertJsonEquals(json("first_seen", "today"), onlyUserUpdate("$set_once")); + } + + // Union User Property + + @Test + public void testUnionUserProperty() { + send(mBuilder.union(IDENTIFIED, unionOf("roles", "admin"))); + + assertJsonEquals(json("roles", new JSONArray().put("admin")), onlyUserUpdate("$union")); + } + + // Remove User Property + + @Test + public void testRemoveUserProperty() { + send(mBuilder.remove(IDENTIFIED, json("roles", "admin"))); + + assertJsonEquals(json("roles", "admin"), onlyUserUpdate("$remove")); + } + + // Unset User Property + + @Test + public void testUnsetUserProperty() { + send(mBuilder.unset(IDENTIFIED, Collections.singletonList("plan"))); + + assertJsonEquals(new JSONArray().put("plan"), onlyUserUpdate("$unset")); + } + + // Delete User + + @Test + public void testDeleteUser() { + send(mBuilder.delete(IDENTIFIED)); + + assertJsonEquals(new JSONObject(), onlyUserUpdate("$delete")); + } + + // Increment User Property (Unsupported) + + @Test + public void testIncrementUserPropertyIsUnsupported() { + Map increment = new HashMap<>(); + increment.put("logins", 1L); + + assertThrows(UnsupportedOperationException.class, () -> mBuilder.increment(USER_ID, increment)); + assertNothingSent(); + } + + // Append User Property (Unsupported) + + @Test + public void testAppendUserPropertyIsUnsupported() { + assertThrows(UnsupportedOperationException.class, + () -> mBuilder.append(USER_ID, json("roles", "admin"))); + assertNothingSent(); + } + + // Track Charge (Unsupported) + + @Test + public void testTrackChargeIsUnsupported() { + assertThrows(UnsupportedOperationException.class, () -> mBuilder.trackCharge(USER_ID, 9.99, null)); + assertNothingSent(); + } + + // Set Company Property + + @Test + public void testSetCompanyProperty() { + send(mCompany.groupSet("$company_id", COMPANY_ID, json("tier", "enterprise"))); + + assertJsonEquals(json("tier", "enterprise"), onlyCompanyUpdate("$set")); + } + + // Set Company Properties + + @Test + public void testSetCompanyProperties() { + send(mCompany.groupSet("$company_id", COMPANY_ID, json("tier", "enterprise", "seats", 50))); + + JSONObject set = (JSONObject) onlyCompanyUpdate("$set"); + assertEquals(setOf("tier", "seats"), keys(set)); + assertEquals("enterprise", set.getString("tier")); + assertEquals(50, set.getInt("seats")); + } + + // Set Once Company Property + + @Test + public void testSetOnceCompanyProperty() { + send(mCompany.groupSetOnce("$company_id", COMPANY_ID, json("founded", "2020"))); + + assertJsonEquals(json("founded", "2020"), onlyCompanyUpdate("$set_once")); + } + + // Union Company Property + + @Test + public void testUnionCompanyProperty() { + send(mCompany.groupUnion("$company_id", COMPANY_ID, unionOf("regions", "us"))); + + assertJsonEquals(json("regions", new JSONArray().put("us")), onlyCompanyUpdate("$union")); + } + + // Remove Company Property + + @Test + public void testRemoveCompanyProperty() { + send(mCompany.groupRemove("$company_id", COMPANY_ID, json("regions", "us"))); + + assertJsonEquals(json("regions", "us"), onlyCompanyUpdate("$remove")); + } + + // Unset Company Property + + @Test + public void testUnsetCompanyProperty() { + send(mCompany.groupUnset("$company_id", COMPANY_ID, Collections.singletonList("tier"))); + + assertJsonEquals(new JSONArray().put("tier"), onlyCompanyUpdate("$unset")); + } + + // Set User's Custom Group + + @Test + public void testSetUsersCustomGroup() { + send(mCompany.set(IDENTIFIED, json("team_id", TEAM_ID))); + + JSONObject set = (JSONObject) onlyUserUpdate("$set"); + assertEquals(setOf("team_id"), keys(set)); + assertEquals(TEAM_ID, set.get("team_id")); + } + + // Remove User's Custom Group + + @Test + public void testRemoveUsersCustomGroup() { + send(mCompany.unset(IDENTIFIED, Collections.singletonList("team_id"))); + + assertJsonEquals(new JSONArray().put("team_id"), onlyUserUpdate("$unset")); + } + + // Set Custom Group Property + + @Test + public void testSetCustomGroupProperty() { + send(mCompany.groupSet("team_id", TEAM_ID, json("name", "Platform"))); + + assertJsonEquals(json("name", "Platform"), onlyCustomGroupUpdate("$set")); + } + + // Set Custom Group Properties + + @Test + public void testSetCustomGroupProperties() { + send(mCompany.groupSet("team_id", TEAM_ID, json("name", "Platform", "size", 8))); + + JSONObject set = (JSONObject) onlyCustomGroupUpdate("$set"); + assertEquals(setOf("name", "size"), keys(set)); + assertEquals("Platform", set.getString("name")); + assertEquals(8, set.getInt("size")); + } + + // Set Once Custom Group Property + + @Test + public void testSetOnceCustomGroupProperty() { + send(mCompany.groupSetOnce("team_id", TEAM_ID, json("created", "today"))); + + assertJsonEquals(json("created", "today"), onlyCustomGroupUpdate("$set_once")); + } + + // Union Custom Group Property + + @Test + public void testUnionCustomGroupProperty() { + send(mCompany.groupUnion("team_id", TEAM_ID, unionOf("tools", "jira"))); + + assertJsonEquals(json("tools", new JSONArray().put("jira")), onlyCustomGroupUpdate("$union")); + } + + // Remove Custom Group Property + + @Test + public void testRemoveCustomGroupProperty() { + send(mCompany.groupRemove("team_id", TEAM_ID, json("tools", "jira"))); + + assertJsonEquals(json("tools", "jira"), onlyCustomGroupUpdate("$remove")); + } + + // Unset Custom Group Property + + @Test + public void testUnsetCustomGroupProperty() { + send(mCompany.groupUnset("team_id", TEAM_ID, Collections.singletonList("name"))); + + assertJsonEquals(new JSONArray().put("name"), onlyCustomGroupUpdate("$unset")); + } + + // Delete Company + + @Test + public void testDeleteCompany() { + send(mCompany.groupDelete("$company_id", COMPANY_ID)); + + assertJsonEquals(new JSONObject(), onlyCompanyUpdate("$delete")); + } + + // Delete Custom Group + + @Test + public void testDeleteCustomGroup() { + send(mCompany.groupDelete("team_id", TEAM_ID)); + + assertJsonEquals(new JSONObject(), onlyCustomGroupUpdate("$delete")); + } + + // Identify Without Company (Unsupported) + + @Test + public void testIdentifyWithoutCompanyIsUnsupported() { + MixpanelIdentity userOnly = MixpanelIdentity.builder().userId(USER_ID).build(); + + assertThrows(IllegalArgumentException.class, () -> mBuilder.event(userOnly, "Signup", null)); + assertThrows(IllegalArgumentException.class, () -> mBuilder.importEvent(userOnly, "Signup", null)); + assertThrows(IllegalArgumentException.class, () -> mBuilder.set(userOnly, json("plan", "pro"))); + assertNothingSent(); + } + + // Set User Property Without User (Unsupported) + + @Test + public void testSetUserPropertyWithoutUserIsUnsupported() { + MixpanelIdentity companyOnly = MixpanelIdentity.builder().companyId(COMPANY_ID).build(); + + assertThrows(IllegalArgumentException.class, () -> mBuilder.set(companyOnly, json("plan", "pro"))); + assertNothingSent(); + } + + // Set Company Property Without Company (Unsupported) + + @Test + public void testSetCompanyPropertyWithoutCompanyIsUnsupported() { + assertThrows(IllegalStateException.class, + () -> mBuilder.groupSet("$company_id", COMPANY_ID, json("tier", "enterprise"))); + assertNothingSent(); + } + + // Set Custom Group Property Without Company (Unsupported) + + @Test + public void testSetCustomGroupPropertyWithoutCompanyIsUnsupported() { + assertThrows(IllegalStateException.class, + () -> mBuilder.groupSet("team_id", TEAM_ID, json("name", "Platform"))); + assertNothingSent(); + } + + // Company Identity Without Hierarchical Groups (Unsupported) + + @Test + public void testCompanyIdentityWithoutHierarchicalGroupsIsUnsupported() { + assertThrows(IllegalArgumentException.class, () -> mLegacyBuilder.event(IDENTIFIED, "Signup", null)); + assertThrows(IllegalArgumentException.class, () -> mLegacyBuilder.importEvent(IDENTIFIED, "Signup", null)); + assertThrows(IllegalArgumentException.class, () -> mLegacyBuilder.set(IDENTIFIED, json("plan", "pro"))); + assertNothingSent(); + } + + // Track With Distinct ID (without hierarchical groups) + + @Test + public void testTrackWithDistinctId() { + send(mLegacyBuilder.event(USER_ID, "Signup", null)); + + JSONObject props = onlyTrack("Signup", "distinct_id"); + assertEquals(USER_ID, props.getString("distinct_id")); + } + + // Set User Property With Distinct ID (without hierarchical groups) + + @Test + public void testSetUserPropertyWithDistinctId() { + send(mLegacyBuilder.set(USER_ID, json("plan", "pro"))); + + JSONObject message = onlySent(ENGAGE).body; + assertEquals(setOf("$token", "$distinct_id", "$time", "$set"), keys(message)); + assertEquals(TOKEN, message.getString("$token")); + assertEquals(USER_ID, message.getString("$distinct_id")); + assertJsonEquals(json("plan", "pro"), message.get("$set")); + } + + // Set Group Property (without hierarchical groups) + + @Test + public void testSetGroupProperty() { + send(mLegacyBuilder.groupSet("team_id", TEAM_ID, json("name", "Platform"))); + + JSONObject message = onlySent(GROUPS).body; + assertEquals(setOf("$token", "$group_key", "$group_id", "$time", "$set"), keys(message)); + assertEquals("team_id", message.getString("$group_key")); + assertEquals(TEAM_ID, message.getString("$group_id")); + assertFalse(message.has("$company_id")); + assertJsonEquals(json("name", "Platform"), message.get("$set")); + } +} diff --git a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java new file mode 100644 index 0000000..a7d5e26 --- /dev/null +++ b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java @@ -0,0 +1,709 @@ +package com.mixpanel.mixpanelapi; + +import com.mixpanel.mixpanelapi.featureflags.FlagContext; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Before; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import java.util.logging.SimpleFormatter; + +import static org.junit.Assert.*; + +public class HierarchicalGroupsTest { + + private static final String TOKEN = "test-token"; + + private MessageBuilder hgBuilder; + private MessageBuilder legacyBuilder; + + @Before + public void setUp() { + hgBuilder = new MessageBuilder(TOKEN, true); + legacyBuilder = new MessageBuilder(TOKEN); + } + + private static JSONObject eventProperties(JSONObject envelope) { + return envelope.getJSONObject("message").getJSONObject("properties"); + } + + // #region Events + + @Test + public void testHierarchicalEventSendsUserAndCompanyWithoutDistinctId() { + JSONObject props = eventProperties(hgBuilder.event(MixpanelIdentity.builder().userId("user_123").companyId("acme").build(), "Signup", null)); + + assertEquals("user_123", props.getString("$user_id")); + assertEquals("acme", props.getString("$company_id")); + assertFalse(props.has("distinct_id")); + assertFalse(props.has("$device_id")); + assertEquals(TOKEN, props.getString("token")); + } + + @Test + public void testHierarchicalEventWithDeviceId() { + JSONObject props = eventProperties( + hgBuilder.event(MixpanelIdentity.builder().userId("user_123").companyId("acme").deviceId("device_abc").build(), "Signup", null)); + + assertEquals("device_abc", props.getString("$device_id")); + assertFalse(props.has("distinct_id")); + } + + @Test + public void testCompanyOnlyEvent() { + JSONObject props = eventProperties(hgBuilder.event(MixpanelIdentity.builder().companyId("acme").build(), "Renewed", null)); + + assertEquals("acme", props.getString("$company_id")); + assertFalse(props.has("$user_id")); + assertFalse(props.has("distinct_id")); + } + + @Test + public void testLegacyIdentitySendsDistinctId() { + JSONObject props = eventProperties(legacyBuilder.event(MixpanelIdentity.builder().distinctId("user_123").build(), "Signup", null)); + + assertEquals("user_123", props.getString("distinct_id")); + assertFalse(props.has("$user_id")); + assertFalse(props.has("$company_id")); + } + + @Test + public void testIdentityOverridesCallerSuppliedKeysAndKeepsOtherProperties() { + JSONObject caller = new JSONObject(); + caller.put("$company_id", "wrong"); + caller.put("plan", "enterprise"); + + JSONObject props = eventProperties(hgBuilder.event(MixpanelIdentity.builder().userId("user_123").companyId("acme").build(), "Signup", caller)); + + assertEquals("acme", props.getString("$company_id")); + assertEquals("enterprise", props.getString("plan")); + assertEquals("wrong", caller.getString("$company_id")); + } + + @Test + public void testHierarchicalImportEvent() { + JSONObject envelope = hgBuilder.importEvent(MixpanelIdentity.builder().userId("user_123").companyId("acme").build(), "Signup", null); + + assertEquals("import", envelope.getString("message_type")); + JSONObject props = eventProperties(envelope); + assertEquals("user_123", props.getString("$user_id")); + assertEquals("acme", props.getString("$company_id")); + assertFalse(props.has("distinct_id")); + assertTrue(props.has("$insert_id")); + } + + @Test + public void testIdentityRejectsEmptyValues() { + // An empty string is a caller error; null simply leaves the value unset. + assertThrows(IllegalArgumentException.class, () -> MixpanelIdentity.builder().userId("")); + assertThrows(IllegalArgumentException.class, () -> MixpanelIdentity.builder().companyId("")); + assertThrows(IllegalArgumentException.class, () -> MixpanelIdentity.builder().deviceId("")); + assertThrows(IllegalArgumentException.class, () -> MixpanelIdentity.builder().distinctId("").build()); + } + + @Test + public void testIdentityNeedsAtLeastOneValue() { + assertThrows(IllegalArgumentException.class, () -> MixpanelIdentity.builder().build()); + assertThrows(IllegalArgumentException.class, + () -> MixpanelIdentity.builder().userId(null).companyId(null).build()); + } + + @Test + public void testNullLeavesAValueUnset() { + MixpanelIdentity identity = MixpanelIdentity.builder().userId("user_123").deviceId(null).build(); + + assertEquals("user_123", identity.getUserId()); + assertNull(identity.getDeviceId()); + } + + @Test + public void testUserAndDeviceWithoutCompanyIsValidForSimplifiedIdMerge() { + // $user_id with $device_id is the standard non-hierarchical server-side identity. + JSONObject props = eventProperties(legacyBuilder.event( + MixpanelIdentity.builder().userId("user_123").deviceId("device_abc").build(), "Signup", null)); + + assertEquals("user_123", props.getString("$user_id")); + assertEquals("device_abc", props.getString("$device_id")); + assertFalse(props.has("distinct_id")); + assertFalse(props.has("$company_id")); + } + + @Test + public void testUserWithoutCompanyOrDeviceIsRejectedForHierarchicalGroups() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> hgBuilder.event(MixpanelIdentity.builder().userId("user_123").build(), "Signup", null)); + + assertTrue(e.getMessage(), e.getMessage().contains("cannot be merged")); + } + + @Test + public void testUserWithDeviceButNoCompanyIsAllowedForHierarchicalGroups() { + // Mixpanel ignores the user id and keeps the event under the device, which later links to + // the company-scoped user. Recoverable, so the library does not refuse it. + JSONObject props = eventProperties(hgBuilder.event( + MixpanelIdentity.builder().userId("user_123").deviceId("device_abc").build(), "Signup", null)); + + assertEquals("device_abc", props.getString("$device_id")); + assertFalse(props.has("$company_id")); + } + + @Test + public void testAnonymousDeviceEventIsValidForHierarchicalGroups() { + JSONObject props = eventProperties(hgBuilder.event( + MixpanelIdentity.builder().deviceId("device_abc").build(), "Page View", null)); + + assertEquals("device_abc", props.getString("$device_id")); + assertFalse(props.has("distinct_id")); + assertFalse(props.has("$user_id")); + } + + @Test + public void testCombinedDistinctIdEventPassesThrough() { + // Ingestion parses $company:{company}|$user:{user} back into $company_id and $user_id, + // so a pipeline that can only carry one id resolves to the same user as the identity form. + String combined = "$company:acme|$user:user_123"; + + for (JSONObject envelope : Arrays.asList( + hgBuilder.event(combined, "Signup", null), + hgBuilder.event(MixpanelIdentity.builder().distinctId(combined).build(), "Signup", null))) { + JSONObject props = eventProperties(envelope); + assertEquals(combined, props.getString("distinct_id")); + assertFalse(props.has("$user_id")); + } + } + + @Test + public void testRawDistinctIdEventsAreNotRefused() { + // Not the recommended form, but it is valid data that Mixpanel accepts, so the library + // documents the consequence rather than refusing to build the message. + assertEquals("user_123", eventProperties(hgBuilder.event("user_123", "Signup", null)) + .getString("distinct_id")); + assertEquals("user_123", eventProperties(hgBuilder.importEvent("user_123", "Signup", null)) + .getString("distinct_id")); + assertEquals("user_123", eventProperties(legacyBuilder.event("user_123", "Signup", null)) + .getString("distinct_id")); + } + + @Test + public void testAnonymousDeviceEvent() { + JSONObject props = eventProperties(hgBuilder.event(MixpanelIdentity.builder().deviceId("device_abc").build(), "Page View", null)); + + assertEquals("device_abc", props.getString("$device_id")); + assertFalse(props.has("distinct_id")); + assertFalse(props.has("$user_id")); + assertFalse(props.has("$company_id")); + } + + @Test + public void testAnonymousDeviceEventWorksForLegacyProjects() { + JSONObject props = eventProperties(legacyBuilder.event(MixpanelIdentity.builder().deviceId("device_abc").build(), "Page View", null)); + + assertEquals("device_abc", props.getString("$device_id")); + assertFalse(props.has("distinct_id")); + } + + @Test + public void testDeviceIdentityCannotIdentifyAProfile() { + JSONObject props = new JSONObject().put("plan", "pro"); + + assertThrows(IllegalArgumentException.class, () -> hgBuilder.set(MixpanelIdentity.builder().deviceId("device_abc").build(), props)); + assertThrows(IllegalArgumentException.class, + () -> legacyBuilder.set(MixpanelIdentity.builder().deviceId("device_abc").build(), props)); + } + + // #endregion + // #region User profiles + + @Test + public void testAddAndAppendThrowForHierarchicalGroups() { + // A hierarchical groups user profile is a group profile, and /groups has no $add or $append, + // so these have no valid path at all. + JSONObject props = new JSONObject().put("plan", "pro"); + Class expected = UnsupportedOperationException.class; + + assertThrows(expected, () -> hgBuilder.increment("user_123", Collections.singletonMap("logins", 1L))); + assertThrows(expected, () -> hgBuilder.append("user_123", props)); + assertThrows(expected, () -> hgBuilder.trackCharge("user_123", 9.99, null)); + assertThrows(expected, () -> hgBuilder.peopleMessage("user_123", "$add", props, null)); + assertThrows(expected, () -> hgBuilder.peopleMessage("user_123", "$append", props, null)); + } + + @Test + public void testSupportedPeopleMethodsWorkForHierarchicalGroups() { + // A combined distinct id is parsed into its company and user and sent to /groups. + JSONObject props = new JSONObject().put("plan", "pro"); + String id = "$company:acme|$user:user_123"; + + JSONObject[] envelopes = { + hgBuilder.set(id, props), + hgBuilder.setOnce(id, props), + hgBuilder.delete(id), + hgBuilder.remove(id, props), + hgBuilder.union(id, Collections.singletonMap("tags", new JSONArray().put("a"))), + hgBuilder.unset(id, Arrays.asList("plan")), + }; + String[] operations = {"$set", "$set_once", "$delete", "$remove", "$union", "$unset"}; + for (int i = 0; i < envelopes.length; i++) { + assertUserGroupMessage(envelopes[i], "acme", "user_123"); + assertTrue(operations[i], envelopes[i].getJSONObject("message").has(operations[i])); + } + } + + private static void assertUserGroupMessage(JSONObject envelope, String companyId, String userId) { + assertEquals("group", envelope.getString("message_type")); + JSONObject message = envelope.getJSONObject("message"); + assertEquals(TOKEN, message.getString("$token")); + assertEquals("$user_id", message.getString("$group_key")); + assertEquals(userId, message.getString("$group_id")); + assertEquals(companyId, message.getString("$company_id")); + assertFalse(message.has("$distinct_id")); + } + + @Test + public void testRawDistinctIdProfileUpdateIsRejectedForHierarchicalGroups() { + JSONObject props = new JSONObject().put("plan", "pro"); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> hgBuilder.set("user_123", props)); + assertTrue(e.getMessage(), e.getMessage().contains("$company:{company}|$user:{user}")); + assertTrue(e.getMessage(), e.getMessage().contains("MixpanelIdentity")); + + assertThrows(IllegalArgumentException.class, () -> hgBuilder.setOnce("user_123", props)); + assertThrows(IllegalArgumentException.class, () -> hgBuilder.delete("user_123")); + assertThrows(IllegalArgumentException.class, () -> hgBuilder.remove("user_123", props)); + assertThrows(IllegalArgumentException.class, + () -> hgBuilder.union("user_123", Collections.singletonMap("tags", new JSONArray().put("a")))); + assertThrows(IllegalArgumentException.class, () -> hgBuilder.unset("user_123", Arrays.asList("plan"))); + assertThrows(IllegalArgumentException.class, + () -> hgBuilder.set(MixpanelIdentity.builder().distinctId("user_123").build(), props)); + assertThrows(IllegalArgumentException.class, + () -> hgBuilder.forCompany("acme").set("user_123", props)); + } + + @Test + public void testMalformedCombinedDistinctIdIsRejected() { + JSONObject props = new JSONObject().put("plan", "pro"); + String[] malformed = { + "$company:acme|$user:", // missing user + "$company:|$user:user_123", // missing company + "$company:acme", // no user part + "$org:acme|$user:user_123", // wrong prefix + "company:acme|$user:user_123", + "$user:user_123|$company:acme", + "", + }; + for (String id : malformed) { + assertThrows(id, IllegalArgumentException.class, () -> hgBuilder.set(id, props)); + assertThrows(id, IllegalArgumentException.class, + () -> hgBuilder.peopleMessage(id, "$set", props, null)); + } + assertThrows(IllegalArgumentException.class, () -> hgBuilder.set((String) null, props)); + } + + @Test + public void testCombinedDistinctIdSplitsOnFirstUserSeparator() { + JSONObject envelope = hgBuilder.set("$company:acme|$user:user|$user:123", new JSONObject().put("a", 1)); + + assertUserGroupMessage(envelope, "acme", "user|$user:123"); + } + + @Test + public void testCombinedDistinctIdCompanyWinsOverForCompany() { + JSONObject envelope = hgBuilder.forCompany("other").set("$company:acme|$user:user_123", + new JSONObject().put("a", 1)); + + assertUserGroupMessage(envelope, "acme", "user_123"); + } + + @Test + public void testGenericPeopleMessageRewritesCombinedDistinctId() { + JSONObject modifiers = new JSONObject().put("$ip", "1.2.3.4").put("$ignore_time", true); + JSONObject envelope = hgBuilder.peopleMessage("$company:acme|$user:user_123", "$set", + new JSONObject().put("plan", "pro"), modifiers); + + assertUserGroupMessage(envelope, "acme", "user_123"); + JSONObject message = envelope.getJSONObject("message"); + assertEquals("pro", message.getJSONObject("$set").getString("plan")); + assertEquals("1.2.3.4", message.getString("$ip")); + assertTrue(message.getBoolean("$ignore_time")); + assertTrue(message.has("$time")); + } + + @Test + public void testGenericPeopleMessageAppliesRuleToDistinctIdModifier() { + JSONObject props = new JSONObject().put("plan", "pro"); + JSONObject modifiers = new JSONObject().put("$distinct_id", "$company:acme|$user:user_123"); + + JSONObject envelope = hgBuilder.peopleMessage("ignored", "$set", props, modifiers); + assertUserGroupMessage(envelope, "acme", "user_123"); + // The caller's modifiers are not changed + assertEquals("$company:acme|$user:user_123", modifiers.getString("$distinct_id")); + + assertThrows(IllegalArgumentException.class, () -> hgBuilder.peopleMessage("$company:acme|$user:user_123", + "$set", props, new JSONObject().put("$distinct_id", "user_123"))); + } + + @Test + public void testUnsupportedOperationIsReportedBeforeDistinctId() { + JSONObject props = new JSONObject().put("plan", "pro"); + + assertThrows(UnsupportedOperationException.class, + () -> hgBuilder.peopleMessage("$company:acme|$user:user_123", "$add", props, null)); + assertThrows(UnsupportedOperationException.class, + () -> hgBuilder.peopleMessage("not_combined", "$append", props, null)); + } + + @Test + public void testLegacyGenericPeopleMessageKeepsDistinctId() { + JSONObject envelope = legacyBuilder.peopleMessage("$company:acme|$user:user_123", "$set", + new JSONObject().put("plan", "pro"), null); + + assertEquals("people", envelope.getString("message_type")); + JSONObject message = envelope.getJSONObject("message"); + assertEquals("$company:acme|$user:user_123", message.getString("$distinct_id")); + assertFalse(message.has("$group_key")); + assertFalse(message.has("$company_id")); + } + + @Test + public void testAddAndAppendErrorNamesWhatIsSupported() { + UnsupportedOperationException e = assertThrows(UnsupportedOperationException.class, + () -> hgBuilder.increment("user_123", Collections.singletonMap("logins", 1L))); + + // The whole point of throwing is that the message reaches whoever wrote the call. + assertTrue(e.getMessage(), e.getMessage().contains("$add")); + assertTrue(e.getMessage(), e.getMessage().contains("increment, append and trackCharge")); + } + + @Test + public void testPeopleMethodsUnchangedForLegacy() { + JSONObject envelope = legacyBuilder.set("user_123", new JSONObject().put("plan", "pro")); + + assertNotNull(envelope); + assertEquals("people", envelope.getString("message_type")); + assertEquals("user_123", envelope.getJSONObject("message").getString("$distinct_id")); + } + + @Test + public void testDeliveryRejectsNullMessages() { + ClientDelivery delivery = new ClientDelivery(); + + assertFalse(delivery.isValidMessage(null)); + assertThrows(MixpanelMessageException.class, () -> delivery.addMessage(null)); + + assertTrue(delivery.getPeopleMessages().isEmpty()); + assertTrue(delivery.getEventsMessages().isEmpty()); + } + + @Test + public void testIdentitySetReroutesToUserGroupProfile() { + JSONObject envelope = hgBuilder.set(MixpanelIdentity.builder().userId("user_123").companyId("acme").build(), new JSONObject().put("role", "admin")); + + assertEquals("group", envelope.getString("message_type")); + JSONObject message = envelope.getJSONObject("message"); + assertEquals("$user_id", message.getString("$group_key")); + assertEquals("user_123", message.getString("$group_id")); + assertEquals("acme", message.getString("$company_id")); + assertEquals("admin", message.getJSONObject("$set").getString("role")); + assertFalse(message.has("$distinct_id")); + } + + @Test + public void testEveryIdentityProfileOperationReroutes() { + MixpanelIdentity identity = MixpanelIdentity.builder().userId("user_123").companyId("acme").build(); + JSONObject props = new JSONObject().put("plan", "pro"); + + JSONObject[] envelopes = { + hgBuilder.set(identity, props), + hgBuilder.setOnce(identity, props), + hgBuilder.delete(identity), + hgBuilder.remove(identity, props), + hgBuilder.union(identity, Collections.singletonMap("tags", new JSONArray().put("a"))), + hgBuilder.unset(identity, Arrays.asList("plan")), + }; + String[] operations = {"$set", "$set_once", "$delete", "$remove", "$union", "$unset"}; + for (int i = 0; i < envelopes.length; i++) { + assertEquals("group", envelopes[i].getString("message_type")); + JSONObject message = envelopes[i].getJSONObject("message"); + assertTrue(operations[i], message.has(operations[i])); + assertEquals("acme", message.getString("$company_id")); + assertEquals("user_123", message.getString("$group_id")); + } + } + + @Test + public void testHierarchicalIdentityRequiresHierarchicalBuilder() { + MixpanelIdentity identity = MixpanelIdentity.builder().userId("user_123").companyId("acme").build(); + JSONObject props = new JSONObject().put("role", "admin"); + + assertThrows(IllegalArgumentException.class, () -> legacyBuilder.set(identity, props)); + assertThrows(IllegalArgumentException.class, () -> legacyBuilder.event(identity, "Signup", null)); + assertThrows(IllegalArgumentException.class, () -> legacyBuilder.importEvent(identity, "Signup", null)); + assertThrows(IllegalArgumentException.class, + () -> legacyBuilder.event(MixpanelIdentity.builder().companyId("acme").build(), "Renewed", null)); + } + + @Test + public void testIdentityCompanyWinsOverForCompany() { + JSONObject envelope = hgBuilder.forCompany("other") + .set(MixpanelIdentity.builder().userId("user_123").companyId("acme").build(), new JSONObject().put("role", "admin")); + + assertEquals("acme", envelope.getJSONObject("message").getString("$company_id")); + } + + @Test + public void testLegacyIdentityUsesEngage() { + JSONObject envelope = legacyBuilder.set(MixpanelIdentity.builder().distinctId("user_123").build(), new JSONObject().put("role", "admin")); + + assertEquals("people", envelope.getString("message_type")); + assertEquals("user_123", envelope.getJSONObject("message").getString("$distinct_id")); + } + + @Test + public void testCombinedDistinctIdIdentityProfileUpdateGoesToGroups() { + JSONObject envelope = hgBuilder.set(MixpanelIdentity.builder().distinctId("$company:acme|$user:user_123").build(), + new JSONObject().put("role", "admin")); + + assertUserGroupMessage(envelope, "acme", "user_123"); + assertEquals("admin", envelope.getJSONObject("message").getJSONObject("$set").getString("role")); + } + + @Test + public void testCompanyOnlyIdentityProfileUpdateIsRejected() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> hgBuilder.set(MixpanelIdentity.builder().companyId("acme").build(), new JSONObject().put("tier", "ent"))); + + assertTrue(e.getMessage(), e.getMessage().contains("groupSet")); + } + + @Test + public void testIdentityProfileUpdateRejectsNullIdentity() { + assertThrows(IllegalArgumentException.class, () -> hgBuilder.set((MixpanelIdentity) null, new JSONObject())); + } + + // #endregion + // #region Group profiles + + @Test + public void testForCompanyAddsTopLevelCompanyId() { + JSONObject message = hgBuilder.forCompany("acme") + .groupSet("department", "finance", new JSONObject().put("plan", "enterprise")) + .getJSONObject("message"); + + assertEquals("acme", message.getString("$company_id")); + assertEquals("department", message.getString("$group_key")); + assertEquals("finance", message.getString("$group_id")); + assertFalse(message.getJSONObject("$set").has("$company_id")); + } + + @Test + public void testUserAndCompanyProfilesHaveBareGroupIds() { + MessageBuilder acme = hgBuilder.forCompany("acme"); + + JSONObject user = acme.groupSet("$user_id", "user_123", new JSONObject().put("role", "admin")) + .getJSONObject("message"); + assertEquals("$user_id", user.getString("$group_key")); + assertEquals("user_123", user.getString("$group_id")); + assertEquals("acme", user.getString("$company_id")); + + JSONObject company = acme.groupSet("$company_id", "acme", new JSONObject().put("tier", "ent")) + .getJSONObject("message"); + assertEquals("$company_id", company.getString("$group_key")); + assertEquals("acme", company.getString("$group_id")); + assertEquals("acme", company.getString("$company_id")); + } + + @Test + public void testEveryGroupOperationCarriesCompany() { + MessageBuilder acme = hgBuilder.forCompany("acme"); + JSONObject props = new JSONObject().put("plan", "enterprise"); + + JSONObject[] messages = { + acme.groupSet("department", "finance", props), + acme.groupSetOnce("department", "finance", props), + acme.groupDelete("department", "finance"), + acme.groupRemove("department", "finance", props), + acme.groupUnion("department", "finance", Collections.singletonMap("tags", new JSONArray().put("a"))), + acme.groupUnset("department", "finance", Arrays.asList("plan")), + }; + for (JSONObject envelope : messages) { + assertEquals("acme", envelope.getJSONObject("message").getString("$company_id")); + } + } + + @Test + public void testHierarchicalGroupWithoutCompanyIsRejected() { + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> hgBuilder.groupSet("department", "finance", new JSONObject().put("plan", "enterprise"))); + + assertTrue(e.getMessage(), e.getMessage().contains("forCompany")); + } + + @Test + public void testCompanyIdModifierIsAccepted() { + JSONObject modifiers = new JSONObject().put("$company_id", "acme"); + JSONObject envelope = hgBuilder.groupSet("department", "finance", new JSONObject().put("plan", "x"), modifiers); + + assertNotNull(envelope); + assertEquals("acme", envelope.getJSONObject("message").getString("$company_id")); + } + + @Test + public void testForCompanyWinsOverModifier() { + JSONObject modifiers = new JSONObject().put("$company_id", "other"); + JSONObject envelope = hgBuilder.forCompany("acme") + .groupSet("department", "finance", new JSONObject().put("plan", "x"), modifiers); + + assertEquals("acme", envelope.getJSONObject("message").getString("$company_id")); + } + + @Test + public void testLegacyGroupUnchanged() { + JSONObject message = legacyBuilder.groupSet("company", "Acme Inc.", new JSONObject().put("plan", "x")) + .getJSONObject("message"); + + assertFalse(message.has("$company_id")); + assertEquals("company", message.getString("$group_key")); + } + + @Test + public void testUnsupportedGroupOperationStillBuildsMessage() { + JSONObject envelope = legacyBuilder.groupMessage("company", "Acme Inc.", "$add", + new JSONObject().put("seats", 1), null); + + assertNotNull(envelope); + assertTrue(envelope.getJSONObject("message").has("$add")); + } + + @Test + public void testForCompanyRejectsEmpty() { + assertThrows(IllegalArgumentException.class, () -> hgBuilder.forCompany("")); + assertThrows(IllegalArgumentException.class, () -> hgBuilder.forCompany(null)); + } + + @Test + public void testForCompanyKeepsAddAndAppendRejected() { + assertThrows(UnsupportedOperationException.class, + () -> hgBuilder.forCompany("acme").increment("user_123", Collections.singletonMap("logins", 1L))); + } + + @Test + public void testUnsupportedGroupOperationWarnsAndStillBuilds() { + List warnings = new ArrayList<>(); + JSONObject message = captureWarnings(warnings, () -> hgBuilder.forCompany("acme") + .groupMessage("company", "acme", "$add", new JSONObject().put("seats", 1), null)); + + // Mixpanel decides what it accepts, so the message is still built, just not silently. + assertNotNull(message); + assertEquals(1, warnings.size()); + assertTrue(warnings.get(0).contains("$add")); + } + + @Test + public void testUnsupportedGroupOperationDoesNotWarnWithoutHierarchicalGroups() { + List warnings = new ArrayList<>(); + JSONObject message = captureWarnings(warnings, + () -> legacyBuilder.groupMessage("company", "acme", "$add", new JSONObject().put("seats", 1), null)); + + assertNotNull(message); + assertTrue(warnings.toString(), warnings.isEmpty()); + } + + @Test + public void testUnsupportedGroupOperationDoesNotWarnWhenNothingWasBuilt() { + List warnings = new ArrayList<>(); + MessageBuilder acme = hgBuilder.forCompany("acme"); + + // Null properties throws before a message exists, so nothing was dropped to warn about. + assertThrows(IllegalArgumentException.class, () -> captureWarnings(warnings, + () -> acme.groupMessage("company", "acme", "$add", null, null))); + assertTrue(warnings.toString(), warnings.isEmpty()); + } + + @Test + public void testSupportedGroupOperationsDoNotWarn() { + List warnings = new ArrayList<>(); + JSONObject properties = new JSONObject().put("seats", 1); + MessageBuilder acme = hgBuilder.forCompany("acme"); + + captureWarnings(warnings, () -> { + acme.groupSet("department", "finance", properties); + acme.groupSetOnce("department", "finance", properties); + acme.groupUnset("department", "finance", Arrays.asList("seats")); + acme.groupUnion("department", "finance", Collections.singletonMap("tags", new JSONArray().put("a"))); + acme.groupRemove("department", "finance", properties); + acme.groupDelete("department", "finance"); + return null; + }); + + assertTrue(warnings.toString(), warnings.isEmpty()); + } + + /** Runs the given builder call with MessageBuilder's warnings collected into the given list. */ + private static JSONObject captureWarnings(List warnings, Supplier call) { + Logger logger = Logger.getLogger(MessageBuilder.class.getName()); + SimpleFormatter formatter = new SimpleFormatter(); + Handler handler = new Handler() { + public void publish(LogRecord record) { + if (record.getLevel().intValue() >= Level.WARNING.intValue()) { + warnings.add(formatter.formatMessage(record)); + } + } + public void flush() {} + public void close() {} + }; + handler.setLevel(Level.ALL); + Level originalLevel = logger.getLevel(); + logger.addHandler(handler); + logger.setLevel(Level.ALL); + try { + return call.get(); + } finally { + logger.removeHandler(handler); + logger.setLevel(originalLevel); + } + } + + // #endregion + // #region Flag context + + @Test + public void testFlagContextFromHierarchicalIdentity() { + Map context = FlagContext.builder() + .identity(MixpanelIdentity.builder().userId("user_123").companyId("acme").deviceId("device_abc").build()) + .group("department", "finance") + .customProperty("plan", "enterprise") + .build(); + + assertEquals("user_123", context.get("$user_id")); + assertEquals("acme", context.get("$company_id")); + assertEquals("device_abc", context.get("device_id")); + assertEquals("finance", context.get("department")); + assertFalse(context.containsKey("distinct_id")); + assertEquals("enterprise", ((Map) context.get("custom_properties")).get("plan")); + } + + @Test + public void testFlagContextFromLegacyIdentity() { + Map context = FlagContext.builder() + .identity(MixpanelIdentity.builder().distinctId("user_123").build()) + .build(); + + assertEquals("user_123", context.get("distinct_id")); + assertFalse(context.containsKey("$company_id")); + assertFalse(context.containsKey("custom_properties")); + } + + // #endregion +} diff --git a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java index 4b747f7..a87a2ad 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java @@ -1864,4 +1864,280 @@ public void testLegacyFlagsWithoutHashSaltUseVariantSalt() { // #endregion // #endregion + + // #region Hierarchical Groups + + private void useHierarchicalGroupsConfig() { + config = LocalFlagsConfig.builder() + .projectToken(TEST_TOKEN) + .enablePolling(false) + .hierarchicalGroups(true) + .build(); + } + + private Map hierarchicalContext(String userId, String companyId) { + Map context = new HashMap<>(); + context.put("$user_id", userId); + context.put("$company_id", companyId); + return context; + } + + private String hashedContextValue(TestableHashingLocalFlagsProvider hashingProvider) { + for (TestableHashingLocalFlagsProvider.HashCall call : hashingProvider.getHashCalls()) { + if ("rollout".equals(call.type)) { + return call.contextValue; + } + } + return null; + } + + @Test + public void testHierarchicalContextWithoutDistinctIdCannotEvaluateLocally() { + // Mixpanel derives a hierarchical distinct id service side, so a context carrying only + // $user_id and $company_id has nothing for a distinct_id keyed flag to hash on. + useHierarchicalGroupsConfig(); + List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); + String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, Arrays.asList(new Rollout(1.0f)), null); + + TestableHashingLocalFlagsProvider hashingProvider = new TestableHashingLocalFlagsProvider(config, SDK_VERSION, eventSender); + hashingProvider.setMockResponse("/flags/definitions", response); + hashingProvider.startPollingForDefinitions(); + + Object result = hashingProvider.getVariantValue(flagKey, fallbackVariantValue, hierarchicalContext("user-123", "acme")); + + assertEquals(fallbackVariantValue, result); + assertNull(hashedContextValue(hashingProvider)); + } + + @Test + public void testFlagWithNewerTemplateVersionIsDropped() { + useHierarchicalGroupsConfig(); + List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); + String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, Arrays.asList(new Rollout(1.0f)), null) + .replace("\"context\":", "\"context_template_version\":99,\"context\":"); + + provider = createProviderWithResponse(response); + provider.startPollingForDefinitions(); + + // Dropped at parse time, so it reads as absent rather than assigning a variant + // this SDK does not know how to choose. + assertEquals(fallbackVariantValue, + provider.getVariantValue(flagKey, fallbackVariantValue, hierarchicalContext("user-123", "acme"))); + } + + @Test + public void testFlagWithMalformedTemplateIsDropped() { + useHierarchicalGroupsConfig(); + List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); + String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, Arrays.asList(new Rollout(1.0f)), null) + .replace("\"context\":", "\"context_template\":\"$company:no-placeholders\",\"context\":"); + + provider = createProviderWithResponse(response); + provider.startPollingForDefinitions(); + + assertEquals(fallbackVariantValue, + provider.getVariantValue(flagKey, fallbackVariantValue, hierarchicalContext("user-123", "acme"))); + } + + @Test + public void testOtherFlagsSurviveADroppedOne() { + // One unusable definition must not take the rest of the payload with it. + useHierarchicalGroupsConfig(); + List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); + String good = buildFlagsResponse("good-flag", distinctIdContextKey, variants, Arrays.asList(new Rollout(1.0f)), null); + String bad = buildFlagsResponse(flagKey, distinctIdContextKey, variants, Arrays.asList(new Rollout(1.0f)), null) + .replace("\"context\":", "\"context_template_version\":99,\"context\":"); + String merged = good.substring(0, good.lastIndexOf("]")) + "," + + bad.substring(bad.indexOf("[") + 1, bad.lastIndexOf("]")) + "]}"; + + provider = createProviderWithResponse(merged); + provider.startPollingForDefinitions(); + + Map ctx = new HashMap<>(); + ctx.put("distinct_id", "user-123"); + assertEquals("value-a", provider.getVariantValue("good-flag", fallbackVariantValue, ctx)); + assertEquals(fallbackVariantValue, provider.getVariantValue(flagKey, fallbackVariantValue, ctx)); + } + + @Test + public void testContextTemplateResolvesTheAssignmentKey() { + // With a template the service published, a pair-only context evaluates and buckets + // exactly as if the caller had sent the distinct id Mixpanel builds from it. + useHierarchicalGroupsConfig(); + List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); + String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, Arrays.asList(new Rollout(1.0f)), null) + .replace("\"context\":", "\"context_template\":\"$company:{$company_id}|$user:{$user_id}\",\"context\":"); + + TestableHashingLocalFlagsProvider hashingProvider = new TestableHashingLocalFlagsProvider(config, SDK_VERSION, eventSender); + hashingProvider.setMockResponse("/flags/definitions", response); + hashingProvider.startPollingForDefinitions(); + + Object result = hashingProvider.getVariantValue(flagKey, fallbackVariantValue, hierarchicalContext("user-123", "acme")); + + assertEquals("value-a", result); + assertEquals("$company:acme|$user:user-123", hashedContextValue(hashingProvider)); + } + + @Test + public void testContextTemplateNeedsEveryPlaceholder() { + useHierarchicalGroupsConfig(); + List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); + String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, Arrays.asList(new Rollout(1.0f)), null) + .replace("\"context\":", "\"context_template\":\"$company:{$company_id}|$user:{$user_id}\",\"context\":"); + + provider = createProviderWithResponse(response); + provider.startPollingForDefinitions(); + Map companyOnly = new HashMap<>(); + companyOnly.put("$company_id", "acme"); + + assertEquals(fallbackVariantValue, provider.getVariantValue(flagKey, fallbackVariantValue, companyOnly)); + } + + @Test + public void testContextTemplateResolvesTestUserOverrides() { + useHierarchicalGroupsConfig(); + List variants = Arrays.asList( + new Variant("control", "control-value", false, 1.0f), + new Variant("treatment", "treatment-value", false, 0.0f) + ); + Map testUsers = new HashMap<>(); + testUsers.put("$company:acme|$user:user-123", "treatment"); + String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, Arrays.asList(new Rollout(1.0f)), testUsers) + .replace("\"context\":", "\"context_template\":\"$company:{$company_id}|$user:{$user_id}\",\"context\":"); + + provider = createProviderWithResponse(response); + provider.startPollingForDefinitions(); + Object result = provider.getVariantValue(flagKey, fallbackVariantValue, hierarchicalContext("user-123", "acme")); + + assertEquals("treatment-value", result); + } + + @Test + public void testHierarchicalDistinctIdIsHashedAsSent() { + useHierarchicalGroupsConfig(); + List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); + String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, Arrays.asList(new Rollout(1.0f)), null); + + TestableHashingLocalFlagsProvider hashingProvider = new TestableHashingLocalFlagsProvider(config, SDK_VERSION, eventSender); + hashingProvider.setMockResponse("/flags/definitions", response); + hashingProvider.startPollingForDefinitions(); + + Map context = hierarchicalContext("user-123", "acme"); + context.put("distinct_id", "$company:acme|$user:user-123"); + Object result = hashingProvider.getVariantValue(flagKey, fallbackVariantValue, context); + + assertEquals("value-a", result); + assertEquals("$company:acme|$user:user-123", hashedContextValue(hashingProvider)); + } + + @Test + public void testHierarchicalGroupFlagHashesOnRawGroupValue() { + useHierarchicalGroupsConfig(); + List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); + String response = buildFlagsResponse(flagKey, "department", variants, Arrays.asList(new Rollout(1.0f)), null); + + TestableHashingLocalFlagsProvider hashingProvider = new TestableHashingLocalFlagsProvider(config, SDK_VERSION, eventSender); + hashingProvider.setMockResponse("/flags/definitions", response); + hashingProvider.startPollingForDefinitions(); + + Map context = hierarchicalContext("user-123", "acme"); + context.put("department", "finance"); + hashingProvider.getVariantValue(flagKey, fallbackVariantValue, context); + + assertEquals("finance", hashedContextValue(hashingProvider)); + } + + @Test + public void testHierarchicalDeviceFlagHashesOnRawDeviceId() { + useHierarchicalGroupsConfig(); + List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); + String response = buildFlagsResponse(flagKey, "device_id", variants, Arrays.asList(new Rollout(1.0f)), null); + + TestableHashingLocalFlagsProvider hashingProvider = new TestableHashingLocalFlagsProvider(config, SDK_VERSION, eventSender); + hashingProvider.setMockResponse("/flags/definitions", response); + hashingProvider.startPollingForDefinitions(); + + Map context = hierarchicalContext("user-123", "acme"); + context.put("device_id", "device-abc"); + hashingProvider.getVariantValue(flagKey, fallbackVariantValue, context); + + assertEquals("device-abc", hashedContextValue(hashingProvider)); + } + + @Test + public void testHierarchicalExposureCarriesUserAndCompanyWithoutDistinctId() { + useHierarchicalGroupsConfig(); + List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); + String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, Arrays.asList(new Rollout(1.0f)), null); + + provider = createProviderWithResponse(response); + provider.startPollingForDefinitions(); + Map context = hierarchicalContext("user-123", "acme"); + context.put("distinct_id", "$company:acme|$user:user-123"); + provider.getVariantValue(flagKey, fallbackVariantValue, context); + + assertEquals(1, eventSender.getEvents().size()); + MockEventSender.ExposureEvent event = eventSender.getEvents().get(0); + assertEquals("user-123", event.properties.getString("$user_id")); + assertEquals("acme", event.properties.getString("$company_id")); + } + + @Test + public void testHierarchicalTestUserOverrideMatchesDistinctIdAsSent() { + useHierarchicalGroupsConfig(); + List variants = Arrays.asList( + new Variant("control", "control-value", false, 1.0f), + new Variant("treatment", "treatment-value", false, 0.0f) + ); + Map testUsers = new HashMap<>(); + testUsers.put("$company:acme|$user:user-123", "treatment"); + String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, Arrays.asList(new Rollout(1.0f)), testUsers); + + provider = createProviderWithResponse(response); + provider.startPollingForDefinitions(); + Map context = hierarchicalContext("user-123", "acme"); + context.put("distinct_id", "$company:acme|$user:user-123"); + Object result = provider.getVariantValue(flagKey, fallbackVariantValue, context); + + assertEquals("treatment-value", result); + } + + @Test + public void testHierarchicalProjectEvaluatesOnDistinctIdAlone() { + // The company no longer changes what is hashed, so a distinct id on its own evaluates + // the same way it does on any other project. + useHierarchicalGroupsConfig(); + List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); + String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, Arrays.asList(new Rollout(1.0f)), null); + + provider = createProviderWithResponse(response); + provider.startPollingForDefinitions(); + Map context = new HashMap<>(); + context.put("distinct_id", "user-123"); + Object result = provider.getVariantValue(flagKey, fallbackVariantValue, context); + + assertEquals("value-a", result); + } + + @Test + public void testLegacyProjectIgnoresCompanyIdInContext() { + List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); + String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, Arrays.asList(new Rollout(1.0f)), null); + + TestableHashingLocalFlagsProvider hashingProvider = new TestableHashingLocalFlagsProvider(config, SDK_VERSION, eventSender); + hashingProvider.setMockResponse("/flags/definitions", response); + hashingProvider.startPollingForDefinitions(); + + Map context = hierarchicalContext("user-123", "acme"); + context.put("distinct_id", "user-123"); + hashingProvider.getVariantValue(flagKey, fallbackVariantValue, context); + + assertEquals("user-123", hashedContextValue(hashingProvider)); + MockEventSender.ExposureEvent event = eventSender.getEvents().get(0); + assertEquals("user-123", event.distinctId); + assertFalse(event.properties.has("$company_id")); + assertFalse(event.properties.has("$user_id")); + } + + // #endregion } diff --git a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProviderTest.java b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProviderTest.java index bc41ccf..1d0c3b8 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProviderTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProviderTest.java @@ -287,5 +287,34 @@ public void testIsEnabledReturnsFalseForBooleanFalseVariant() { } // #endregion + + // #region Hierarchical Groups + + @Test + public void testHierarchicalContextIsForwardedAndExposureCarriesUserAndCompany() { + config = RemoteFlagsConfig.builder() + .projectToken(TEST_TOKEN) + .requestTimeoutSeconds(5) + .hierarchicalGroups(true) + .build(); + String response = buildRemoteFlagsResponse("test-flag", "variant-a", "value-a"); + provider = new TestableRemoteFlagsProvider(config, SDK_VERSION, eventSender); + // Only respond when the serialized context carries $company_id + provider.setMockResponse("%22%24company_id%22%3A%22acme%22", response); + + Map context = new HashMap<>(); + context.put("$user_id", "user-123"); + context.put("$company_id", "acme"); + String result = provider.getVariantValue("test-flag", "fallback", context); + + assertEquals("value-a", result); + assertEquals(1, eventSender.getEvents().size()); + MockEventSender.ExposureEvent event = eventSender.getEvents().get(0); + assertNull(event.distinctId); + assertEquals("user-123", event.properties.getString("$user_id")); + assertEquals("acme", event.properties.getString("$company_id")); + } + + // #endregion } From 9b0bbc91622b53a80c07bb66477ce5e92f32d51a Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Mon, 2 Nov 2026 13:48:12 -0500 Subject: [PATCH 02/17] feat(flags): resolve local assignment keys from context_value_template Co-Authored-By: Claude Opus 5.5 --- .../mixpanelapi/featureflags/FlagContext.java | 6 +- .../featureflags/config/BaseFlagsConfig.java | 11 +- .../model/ExperimentationFlag.java | 19 +- .../provider/LocalFlagsProvider.java | 95 +++----- .../provider/LocalFlagsProviderTest.java | 208 +++++++++++++----- 5 files changed, 195 insertions(+), 144 deletions(-) diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/FlagContext.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/FlagContext.java index bd641df..d63391e 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/FlagContext.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/FlagContext.java @@ -54,7 +54,7 @@ public Builder distinctId(String distinctId) { return this; } - /** Device ids are a valid assignment key and are never namespaced by company. */ + /** Sets the device id, used as the assignment key by flags that assign on {@code device_id}. */ public Builder deviceId(String deviceId) { mContext.put("device_id", deviceId); return this; @@ -72,7 +72,9 @@ public Builder companyId(String companyId) { /** * Sets a group the evaluation is for. Flags whose assignment key is this group - * key are assigned on its value, namespaced by company when one is set. + * key are assigned on the key their server-published {@code context_value_template} + * builds from the context (for example {@code $company:{$company_id}|department:{department}}), + * or on the raw group value when the template is absent or cannot be filled. */ public Builder group(String groupKey, Object groupValue) { mContext.put(groupKey, groupValue); diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/BaseFlagsConfig.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/BaseFlagsConfig.java index d927b82..0edd292 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/BaseFlagsConfig.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/BaseFlagsConfig.java @@ -148,11 +148,12 @@ public T exposureExecutor(Executor exposureExecutor) { } /** - * Sets whether the project uses hierarchical groups. When true, user and group - * assignment keys are namespaced by the context's {@code $company_id}, and exposure - * events are attributed with {@code $user_id} and {@code $company_id} instead of - * {@code distinct_id}. When false, {@code $company_id} in the context has no effect - * on assignment. + * Sets whether the project uses hierarchical groups. When true, exposure events are + * attributed with {@code $user_id} and {@code $company_id} instead of + * {@code distinct_id}. Assignment keys do not depend on this setting: locally + * evaluated flags build them from the {@code context_value_template} the server + * publishes for each flag, falling back to the raw context value when the template + * is absent or the context lacks one of its placeholders. * * @param hierarchicalGroups true if the project uses hierarchical groups * @return this builder diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/ExperimentationFlag.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/ExperimentationFlag.java index 1f95f1d..78bf871 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/ExperimentationFlag.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/ExperimentationFlag.java @@ -23,7 +23,7 @@ public final class ExperimentationFlag { private final UUID experimentId; private final Boolean isExperimentActive; private final String hashSalt; - private final String contextTemplate; + private final String contextValueTemplate; /** * Creates a new ExperimentationFlag. @@ -43,8 +43,8 @@ public ExperimentationFlag(String id, String name, String key, String status, in this(id, name, key, status, projectId, ruleset, context, experimentId, isExperimentActive, hashSalt, ""); } - public ExperimentationFlag(String id, String name, String key, String status, int projectId, RuleSet ruleset, String context, UUID experimentId, Boolean isExperimentActive, String hashSalt, String contextTemplate) { - this.contextTemplate = contextTemplate == null ? "" : contextTemplate; + public ExperimentationFlag(String id, String name, String key, String status, int projectId, RuleSet ruleset, String context, UUID experimentId, Boolean isExperimentActive, String hashSalt, String contextValueTemplate) { + this.contextValueTemplate = contextValueTemplate == null ? "" : contextValueTemplate; this.id = id; this.name = name; this.key = key; @@ -58,14 +58,15 @@ public ExperimentationFlag(String id, String name, String key, String status, in } /** - * How to build this flag's assignment key when the caller's context does not carry - * {@link #getContext()} directly, as published by the service, for example - * {@code $company:{$company_id}|$user:{$user_id}}. Empty when the service sends none. + * The template the service publishes for building this flag's assignment key from the + * evaluation context, for example {@code $company:{$company_id}|$user:{$user_id}}. Each + * {@code {key}} placeholder is replaced with that context value; when the template is + * empty or a placeholder is missing, the raw {@link #getContext()} value is used instead. * - * @return the template, or an empty string + * @return the template, or an empty string when the service sends none */ - public String getContextTemplate() { - return contextTemplate; + public String getContextValueTemplate() { + return contextValueTemplate; } /** diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java index 3a0b14f..35015e2 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java @@ -223,12 +223,6 @@ private Map parseDefinitions(String jsonResponse) { for (int i = 0; i < flags.length(); i++) { JSONObject flagJson = flags.getJSONObject(i); ExperimentationFlag flag = parseFlag(flagJson); - if (flag == null) { - // A definition this SDK cannot fully construct is dropped rather than - // half-evaluated: a flag it does not understand would assign a variant - // the service never chose. - continue; - } definitions.put(flag.getKey(), flag); } } catch (Exception e) { @@ -238,39 +232,18 @@ private Map parseDefinitions(String jsonResponse) { return definitions; } - /** The highest {@code context_template_version} this SDK knows how to apply. */ - private static final int SUPPORTED_CONTEXT_TEMPLATE_VERSION = 1; - - /** - * A template is usable when it names at least one property and every placeholder is named. - * An empty template means the service published none, which is not a defect. - */ - private static boolean isUsableContextTemplate(String template) { - if (template == null || template.isEmpty()) { - return true; - } - Matcher matcher = CONTEXT_PLACEHOLDER.matcher(template); - boolean found = false; - while (matcher.find()) { - if (matcher.group(1).trim().isEmpty()) { - return false; - } - found = true; - } - return found; - } - - /** A {@code {property}} placeholder in a context template. */ + /** A {@code {key}} placeholder in a context value template. */ private static final Pattern CONTEXT_PLACEHOLDER = Pattern.compile("\\{([^}]+)\\}"); /** - * Fills a context template the service published with values from the evaluation context. - * The template names the properties it needs, so this carries no knowledge of how Mixpanel - * composes an identity: change the format service side and the next fetch carries it. + * Fills a flag's {@code context_value_template} with values from the evaluation context. + * Each {@code {key}} placeholder is replaced with the string value of {@code key}; text + * outside the braces is literal. * - * @return the resolved value, or null when the context cannot produce one + * @return the filled template, or null when there is no template or any placeholder key + * is missing (absent or null) from the context */ - private static String applyContextTemplate(String template, Map context) { + private static String applyContextValueTemplate(String template, Map context) { if (template == null || template.isEmpty()) { return null; } @@ -278,8 +251,7 @@ private static String applyContextTemplate(String template, Map Matcher matcher = CONTEXT_PLACEHOLDER.matcher(template); while (matcher.find()) { Object value = context.get(matcher.group(1)); - if (value == null || value.toString().isEmpty()) { - // The service rejects a partial identity too, so there is nothing to resolve. + if (value == null) { return null; } matcher.appendReplacement(out, Matcher.quoteReplacement(value.toString())); @@ -291,26 +263,14 @@ private static String applyContextTemplate(String template, Map /** * Parses a single flag from JSON. */ - private ExperimentationFlag parseFlag(JSONObject json) { // null when the definition cannot be fully constructed + private ExperimentationFlag parseFlag(JSONObject json) { String id = json.optString("id", ""); String name = json.optString("name", ""); String key = json.optString("key", ""); String status = json.optString("status", ""); int projectId = json.optInt("project_id", 0); String context = json.optString("context", "distinct_id"); - String contextTemplate = json.optString("context_template", ""); - int templateVersion = json.optInt("context_template_version", SUPPORTED_CONTEXT_TEMPLATE_VERSION); - if (templateVersion > SUPPORTED_CONTEXT_TEMPLATE_VERSION) { - logger.log(Level.WARNING, "Dropping flag ''{0}'': context_template_version {1} is newer than this " - + "SDK understands ({2}). Evaluate it remotely, or upgrade the SDK.", - new Object[] {key, templateVersion, SUPPORTED_CONTEXT_TEMPLATE_VERSION}); - return null; - } - if (!isUsableContextTemplate(contextTemplate)) { - logger.log(Level.WARNING, "Dropping flag ''{0}'': context_template ''{1}'' has no usable " - + "placeholders.", new Object[] {key, contextTemplate}); - return null; - } + String contextValueTemplate = json.optString("context_value_template", ""); // Parse experiment metadata UUID experimentId = null; @@ -333,7 +293,7 @@ private ExperimentationFlag parseFlag(JSONObject json) { // null when the defi RuleSet ruleset = parseRuleSet(json.optJSONObject("ruleset")); - return new ExperimentationFlag(id, name, key, status, projectId, ruleset, context, experimentId, isExperimentActive, hashSalt, contextTemplate); + return new ExperimentationFlag(id, name, key, status, projectId, ruleset, context, experimentId, isExperimentActive, hashSalt, contextValueTemplate); } /** @@ -461,32 +421,27 @@ public SelectedVariant getVariant(String flagKey, SelectedVariant fall return fallback.withSource(Source.fallback(Source.Fallback.Reason.FLAG_NOT_FOUND)); } - // The context value is used as sent. Where the caller did not send it, the service - // may publish a template saying how to build it from properties they did send; this - // SDK never composes an identity of its own. + // Resolve the assignment key: the flag's context_value_template when every + // placeholder resolves, otherwise the raw context value, as the server does. String contextProperty = flag.getContext(); - Object contextValueObj = context.get(contextProperty); - String contextValue = contextValueObj != null - ? contextValueObj.toString() - : applyContextTemplate(flag.getContextTemplate(), context); + String contextValue = applyContextValueTemplate(flag.getContextValueTemplate(), context); if (contextValue == null) { - logger.log(Level.WARNING, "Variant assignment key property '" + contextProperty + "' not found for flag: " + flagKey); - return fallback.withSource(Source.fallback(Source.Fallback.Reason.MISSING_CONTEXT_KEY)); + Object contextValueObj = context.get(contextProperty); + if (contextValueObj == null) { + logger.log(Level.WARNING, "Variant assignment key property '" + contextProperty + "' not found for flag: " + flagKey); + return fallback.withSource(Source.fallback(Source.Fallback.Reason.MISSING_CONTEXT_KEY)); + } + contextValue = contextValueObj.toString(); } // Check test user overrides RuleSet ruleset = flag.getRuleset(); if (ruleset.hasTestUserOverrides()) { - // Overrides are keyed by distinct id. When the flag assigns on distinct_id its - // template builds exactly that, so the same resolution serves both. - String distinctId = context.get("distinct_id") != null - ? context.get("distinct_id").toString() - : ("distinct_id".equals(contextProperty) ? contextValue : null); - if (distinctId == null) { - logger.log(Level.WARNING, "Flag ''{0}'' has test users, but they are keyed by distinct_id " - + "and this context has none. Pinned users will be assigned by rollout instead.", - flagKey); - } + // Overrides are keyed by distinct id, which is the resolved assignment key + // when the flag assigns on distinct_id. + String distinctId = "distinct_id".equals(contextProperty) + ? contextValue + : (context.get("distinct_id") != null ? context.get("distinct_id").toString() : null); if (distinctId != null) { String testVariantKey = ruleset.getTestUserOverrides().get(distinctId); if (testVariantKey != null) { diff --git a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java index a87a2ad..64c4b51 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java @@ -1909,107 +1909,199 @@ public void testHierarchicalContextWithoutDistinctIdCannotEvaluateLocally() { assertNull(hashedContextValue(hashingProvider)); } + private String buildTemplatedFlagsResponse(String flagKey, String context, List variants, + Map testUsers, String contextValueTemplate) { + return buildFlagsResponse(flagKey, context, variants, Arrays.asList(new Rollout(1.0f)), testUsers) + .replace("\"context\":", "\"context_value_template\":" + JSONObject.quote(contextValueTemplate) + ",\"context\":"); + } + + /** The evaluation context from the spec's examples. */ + private Map specContext() { + Map context = new HashMap<>(); + context.put("distinct_id", "user_123"); + context.put("device_id", "device_abc"); + context.put("$user_id", "user_123"); + context.put("$company_id", "company_123"); + context.put("department", "finance"); + return context; + } + + private String hashedAssignmentKey(String response, Map context) { + TestableHashingLocalFlagsProvider hashingProvider = new TestableHashingLocalFlagsProvider(config, SDK_VERSION, eventSender); + hashingProvider.setMockResponse("/flags/definitions", response); + hashingProvider.startPollingForDefinitions(); + assertEquals("value-a", hashingProvider.getVariantValue(flagKey, fallbackVariantValue, context)); + return hashedContextValue(hashingProvider); + } + + private String hashedTemplatedAssignmentKey(String contextKey, String template, Map context) { + List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); + return hashedAssignmentKey(buildTemplatedFlagsResponse(flagKey, contextKey, variants, null, template), context); + } + @Test - public void testFlagWithNewerTemplateVersionIsDropped() { + public void testContextValueTemplateForDistinctIdFlag() { useHierarchicalGroupsConfig(); - List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); - String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, Arrays.asList(new Rollout(1.0f)), null) - .replace("\"context\":", "\"context_template_version\":99,\"context\":"); + assertEquals("$company:company_123|$user:user_123", + hashedTemplatedAssignmentKey("distinct_id", "$company:{$company_id}|$user:{$user_id}", specContext())); + } - provider = createProviderWithResponse(response); - provider.startPollingForDefinitions(); + @Test + public void testContextValueTemplateForDeviceIdFlag() { + useHierarchicalGroupsConfig(); + assertEquals("device_abc", + hashedTemplatedAssignmentKey("device_id", "{device_id}", specContext())); + } - // Dropped at parse time, so it reads as absent rather than assigning a variant - // this SDK does not know how to choose. - assertEquals(fallbackVariantValue, - provider.getVariantValue(flagKey, fallbackVariantValue, hierarchicalContext("user-123", "acme"))); + @Test + public void testContextValueTemplateForUserIdFlag() { + useHierarchicalGroupsConfig(); + assertEquals("$company:company_123|$user:user_123", + hashedTemplatedAssignmentKey("$user_id", "$company:{$company_id}|$user:{$user_id}", specContext())); } @Test - public void testFlagWithMalformedTemplateIsDropped() { + public void testContextValueTemplateForCompanyIdFlag() { useHierarchicalGroupsConfig(); - List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); - String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, Arrays.asList(new Rollout(1.0f)), null) - .replace("\"context\":", "\"context_template\":\"$company:no-placeholders\",\"context\":"); + assertEquals("$company:company_123", + hashedTemplatedAssignmentKey("$company_id", "$company:{$company_id}", specContext())); + } - provider = createProviderWithResponse(response); - provider.startPollingForDefinitions(); + @Test + public void testContextValueTemplateForGroupFlag() { + useHierarchicalGroupsConfig(); + assertEquals("$company:company_123|department:finance", + hashedTemplatedAssignmentKey("department", "$company:{$company_id}|department:{department}", specContext())); + } - assertEquals(fallbackVariantValue, - provider.getVariantValue(flagKey, fallbackVariantValue, hierarchicalContext("user-123", "acme"))); + @Test + public void testContextValueTemplateKeepsLiteralTextOutsideBraces() { + useHierarchicalGroupsConfig(); + assertEquals("org=company_123;dept=finance!", + hashedTemplatedAssignmentKey("department", "org={$company_id};dept={department}!", specContext())); } @Test - public void testOtherFlagsSurviveADroppedOne() { - // One unusable definition must not take the rest of the payload with it. + public void testMissingPlaceholderFallsBackToRawContextValue() { useHierarchicalGroupsConfig(); - List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); - String good = buildFlagsResponse("good-flag", distinctIdContextKey, variants, Arrays.asList(new Rollout(1.0f)), null); - String bad = buildFlagsResponse(flagKey, distinctIdContextKey, variants, Arrays.asList(new Rollout(1.0f)), null) - .replace("\"context\":", "\"context_template_version\":99,\"context\":"); - String merged = good.substring(0, good.lastIndexOf("]")) + "," - + bad.substring(bad.indexOf("[") + 1, bad.lastIndexOf("]")) + "]}"; + Map context = specContext(); + context.remove("$company_id"); - provider = createProviderWithResponse(merged); - provider.startPollingForDefinitions(); + assertEquals("finance", + hashedTemplatedAssignmentKey("department", "$company:{$company_id}|department:{department}", context)); + assertEquals("user_123", + hashedTemplatedAssignmentKey("distinct_id", "$company:{$company_id}|$user:{$user_id}", context)); + } - Map ctx = new HashMap<>(); - ctx.put("distinct_id", "user-123"); - assertEquals("value-a", provider.getVariantValue("good-flag", fallbackVariantValue, ctx)); - assertEquals(fallbackVariantValue, provider.getVariantValue(flagKey, fallbackVariantValue, ctx)); + @Test + public void testNullPlaceholderValueFallsBackToRawContextValue() { + useHierarchicalGroupsConfig(); + Map context = specContext(); + context.put("$company_id", null); + + assertEquals("finance", + hashedTemplatedAssignmentKey("department", "$company:{$company_id}|department:{department}", context)); + } + + @Test + public void testNoTemplateFallsBackToRawContextValue() { + useHierarchicalGroupsConfig(); + List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); + String response = buildFlagsResponse(flagKey, "department", variants, Arrays.asList(new Rollout(1.0f)), null); + + assertEquals("finance", hashedAssignmentKey(response, specContext())); } @Test - public void testContextTemplateResolvesTheAssignmentKey() { - // With a template the service published, a pair-only context evaluates and buckets - // exactly as if the caller had sent the distinct id Mixpanel builds from it. + public void testMissingPlaceholderAndMissingRawValueReturnsFallback() { useHierarchicalGroupsConfig(); List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); - String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, Arrays.asList(new Rollout(1.0f)), null) - .replace("\"context\":", "\"context_template\":\"$company:{$company_id}|$user:{$user_id}\",\"context\":"); + String response = buildTemplatedFlagsResponse(flagKey, "department", variants, null, + "$company:{$company_id}|department:{department}"); TestableHashingLocalFlagsProvider hashingProvider = new TestableHashingLocalFlagsProvider(config, SDK_VERSION, eventSender); hashingProvider.setMockResponse("/flags/definitions", response); hashingProvider.startPollingForDefinitions(); + Map context = specContext(); + context.remove("department"); + SelectedVariant result = hashingProvider.getVariant(flagKey, new SelectedVariant<>(fallbackVariantValue), context, true); - Object result = hashingProvider.getVariantValue(flagKey, fallbackVariantValue, hierarchicalContext("user-123", "acme")); - - assertEquals("value-a", result); - assertEquals("$company:acme|$user:user-123", hashedContextValue(hashingProvider)); + assertEquals(fallbackVariantValue, result.getVariantValue()); + assertEquals(Source.Fallback.Reason.MISSING_CONTEXT_KEY, ((Source.Fallback) result.getSource()).reason); + assertNull(hashedContextValue(hashingProvider)); } @Test - public void testContextTemplateNeedsEveryPlaceholder() { + public void testTestUserOverrideUsesResolvedKeyForDistinctIdFlag() { useHierarchicalGroupsConfig(); - List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); - String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, Arrays.asList(new Rollout(1.0f)), null) - .replace("\"context\":", "\"context_template\":\"$company:{$company_id}|$user:{$user_id}\",\"context\":"); - - provider = createProviderWithResponse(response); + List variants = Arrays.asList( + new Variant("control", "control-value", false, 1.0f), + new Variant("treatment", "treatment-value", false, 0.0f) + ); + Map resolvedKeyUsers = new HashMap<>(); + resolvedKeyUsers.put("$company:company_123|$user:user_123", "treatment"); + provider = createProviderWithResponse(buildTemplatedFlagsResponse(flagKey, "distinct_id", variants, resolvedKeyUsers, + "$company:{$company_id}|$user:{$user_id}")); provider.startPollingForDefinitions(); - Map companyOnly = new HashMap<>(); - companyOnly.put("$company_id", "acme"); + assertEquals("treatment-value", provider.getVariantValue(flagKey, fallbackVariantValue, specContext())); - assertEquals(fallbackVariantValue, provider.getVariantValue(flagKey, fallbackVariantValue, companyOnly)); + // The raw distinct_id is not the override key once the template resolves. + Map rawKeyUsers = new HashMap<>(); + rawKeyUsers.put("user_123", "treatment"); + provider = createProviderWithResponse(buildTemplatedFlagsResponse(flagKey, "distinct_id", variants, rawKeyUsers, + "$company:{$company_id}|$user:{$user_id}")); + provider.startPollingForDefinitions(); + assertEquals("control-value", provider.getVariantValue(flagKey, fallbackVariantValue, specContext())); } @Test - public void testContextTemplateResolvesTestUserOverrides() { + public void testTestUserOverrideUsesRawDistinctIdForOtherFlags() { useHierarchicalGroupsConfig(); List variants = Arrays.asList( new Variant("control", "control-value", false, 1.0f), new Variant("treatment", "treatment-value", false, 0.0f) ); Map testUsers = new HashMap<>(); - testUsers.put("$company:acme|$user:user-123", "treatment"); - String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, Arrays.asList(new Rollout(1.0f)), testUsers) - .replace("\"context\":", "\"context_template\":\"$company:{$company_id}|$user:{$user_id}\",\"context\":"); - - provider = createProviderWithResponse(response); + testUsers.put("user_123", "treatment"); + provider = createProviderWithResponse(buildTemplatedFlagsResponse(flagKey, "department", variants, testUsers, + "$company:{$company_id}|department:{department}")); provider.startPollingForDefinitions(); - Object result = provider.getVariantValue(flagKey, fallbackVariantValue, hierarchicalContext("user-123", "acme")); - assertEquals("treatment-value", result); + assertEquals("treatment-value", provider.getVariantValue(flagKey, fallbackVariantValue, specContext())); + } + + @Test + public void testNonHierarchicalContextMatchesMasterForTemplatedFlag() { + // Without $company_id the template cannot resolve, so the flag assigns on the raw + // distinct_id exactly as a flag without a template does. + List variants = Arrays.asList( + new Variant("control", "control-value", false, 0.5f), + new Variant("treatment", "treatment-value", false, 0.5f) + ); + String templated = buildTemplatedFlagsResponse(flagKey, "distinct_id", variants, null, + "$company:{$company_id}|$user:{$user_id}"); + String untemplated = buildFlagsResponse(flagKey, "distinct_id", variants, Arrays.asList(new Rollout(1.0f)), null); + + TestableHashingLocalFlagsProvider templatedProvider = new TestableHashingLocalFlagsProvider(config, SDK_VERSION, eventSender); + templatedProvider.setMockResponse("/flags/definitions", templated); + templatedProvider.startPollingForDefinitions(); + LocalFlagsProvider untemplatedProvider = createProviderWithResponse(untemplated); + untemplatedProvider.startPollingForDefinitions(); + + Set seen = new HashSet<>(); + for (int i = 0; i < 50; i++) { + Map context = new HashMap<>(); + context.put("distinct_id", "user-" + i); + context.put("$user_id", "user-" + i); + Object expected = untemplatedProvider.getVariantValue(flagKey, fallbackVariantValue, context); + assertEquals(expected, templatedProvider.getVariantValue(flagKey, fallbackVariantValue, context)); + seen.add(expected); + } + for (TestableHashingLocalFlagsProvider.HashCall call : templatedProvider.getHashCalls()) { + assertTrue(call.contextValue.startsWith("user-")); + } + // Both variants were exercised, so the comparison covers the split. + assertEquals(2, seen.size()); } @Test From 72127bed2fe6489a33ab4f2790c569d0a5b61855 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Mon, 2 Nov 2026 15:02:11 -0500 Subject: [PATCH 03/17] feat(analytics): throw for unsupported groupMessage operations on hierarchical groups builders $add, $append and $merge were logged and still built. They now throw UnsupportedOperationException, like the people operations. Co-Authored-By: Claude Opus 5.5 --- .../mixpanel/mixpanelapi/MessageBuilder.java | 18 ++-- .../mixpanelapi/HierarchicalGroupsTest.java | 91 ++++--------------- 2 files changed, 25 insertions(+), 84 deletions(-) diff --git a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java index ac1d0a8..700f3b1 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java +++ b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java @@ -12,8 +12,6 @@ import java.util.Set; import java.util.TimeZone; import java.util.UUID; -import java.util.logging.Level; -import java.util.logging.Logger; import org.json.JSONArray; import org.json.JSONException; @@ -29,7 +27,6 @@ public class MessageBuilder { private static final String ENGAGE_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss"; - private static final Logger logger = Logger.getLogger(MessageBuilder.class.getName()); private static final String USER_ID_GROUP_KEY = "$user_id"; // /groups has no $add, $append or $merge private static final Set GROUP_OPERATIONS = Collections.unmodifiableSet(new LinkedHashSet<>( @@ -1139,8 +1136,7 @@ public JSONObject groupUnset(String groupKey, String groupId, Collection * @param groupKey string identifier for the type of group, e.g. 'Company' * @param groupId unique string identifier for the group, e.g. 'Acme Inc.' * @param actionType a string associated in the HTTP api with the operation (for example, $set or $union). - * /groups has no $add, $append or $merge; on a hierarchical groups builder an unsupported - * operation is logged and still built, since Mixpanel, not this library, decides what it accepts. + * /groups has no $add, $append or $merge; a hierarchical groups builder rejects them. * @param properties a payload of the operation. Will be converted to JSON, and should be of types * Boolean, Double, Integer, Long, String, JSONArray, JSONObject, the JSONObject.NULL object, or null. * NaN and negative/positive infinity will throw an IllegalArgumentException @@ -1150,6 +1146,8 @@ public JSONObject groupUnset(String groupKey, String groupId, Collection * * @throws IllegalArgumentException if properties is not intelligible as a JSONObject property * @throws IllegalStateException for hierarchical groups projects when no company is known + * @throws UnsupportedOperationException on a hierarchical groups builder, for an operation /groups lacks + * ($add, $append or $merge) * * @see MessageBuilder#groupSet(String groupKey, String groupId, JSONObject properties) * @see MessageBuilder#groupSetOnce(String groupKey, String groupId, JSONObject properties) @@ -1158,14 +1156,12 @@ public JSONObject groupUnset(String groupKey, String groupId, Collection */ public JSONObject groupMessage(String groupKey, String groupId, String actionType, Object properties, JSONObject modifiers) { - JSONObject message = buildGroupMessage(groupKey, groupId, actionType, properties, modifiers, mCompanyId); // Only the generic entry point can carry an arbitrary actionType; the groupSet family passes constants. - // Warn once the message exists, so a call that threw or built nothing does not claim a dropped update. - if (mHierarchicalGroups && message != null && !GROUP_OPERATIONS.contains(actionType)) { - logger.log(Level.WARNING, "Group profiles do not support " + actionType + " and Mixpanel will drop " - + "the update. Supported operations are " + GROUP_OPERATIONS); + if (mHierarchicalGroups && !GROUP_OPERATIONS.contains(actionType)) { + throw new UnsupportedOperationException(actionType + " is not supported for group profiles on " + + "hierarchical groups projects. Supported operations are " + GROUP_OPERATIONS + "."); } - return message; + return buildGroupMessage(groupKey, groupId, actionType, properties, modifiers, mCompanyId); } private JSONObject buildGroupMessage(String groupKey, String groupId, String actionType, Object properties, diff --git a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java index a7d5e26..f313044 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java @@ -7,17 +7,9 @@ import org.junit.Before; import org.junit.Test; -import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.List; import java.util.Map; -import java.util.function.Supplier; -import java.util.logging.Handler; -import java.util.logging.Level; -import java.util.logging.LogRecord; -import java.util.logging.Logger; -import java.util.logging.SimpleFormatter; import static org.junit.Assert.*; @@ -599,80 +591,33 @@ public void testForCompanyKeepsAddAndAppendRejected() { } @Test - public void testUnsupportedGroupOperationWarnsAndStillBuilds() { - List warnings = new ArrayList<>(); - JSONObject message = captureWarnings(warnings, () -> hgBuilder.forCompany("acme") - .groupMessage("company", "acme", "$add", new JSONObject().put("seats", 1), null)); - - // Mixpanel decides what it accepts, so the message is still built, just not silently. - assertNotNull(message); - assertEquals(1, warnings.size()); - assertTrue(warnings.get(0).contains("$add")); - } - - @Test - public void testUnsupportedGroupOperationDoesNotWarnWithoutHierarchicalGroups() { - List warnings = new ArrayList<>(); - JSONObject message = captureWarnings(warnings, - () -> legacyBuilder.groupMessage("company", "acme", "$add", new JSONObject().put("seats", 1), null)); - - assertNotNull(message); - assertTrue(warnings.toString(), warnings.isEmpty()); + public void testUnsupportedGroupOperationThrows() { + MessageBuilder acme = hgBuilder.forCompany("acme"); + for (String operation : Arrays.asList("$add", "$append", "$merge")) { + UnsupportedOperationException e = assertThrows(UnsupportedOperationException.class, + () -> acme.groupMessage("company", "acme", operation, new JSONObject().put("seats", 1), null)); + assertTrue(e.getMessage(), e.getMessage().startsWith(operation + " is not supported")); + } } @Test - public void testUnsupportedGroupOperationDoesNotWarnWhenNothingWasBuilt() { - List warnings = new ArrayList<>(); - MessageBuilder acme = hgBuilder.forCompany("acme"); - - // Null properties throws before a message exists, so nothing was dropped to warn about. - assertThrows(IllegalArgumentException.class, () -> captureWarnings(warnings, - () -> acme.groupMessage("company", "acme", "$add", null, null))); - assertTrue(warnings.toString(), warnings.isEmpty()); + public void testUnsupportedGroupOperationThrowsBeforeCompanyCheck() { + assertThrows(UnsupportedOperationException.class, + () -> hgBuilder.groupMessage("company", "acme", "$add", new JSONObject().put("seats", 1), null)); } @Test - public void testSupportedGroupOperationsDoNotWarn() { - List warnings = new ArrayList<>(); + public void testSupportedGroupOperationsBuild() { JSONObject properties = new JSONObject().put("seats", 1); MessageBuilder acme = hgBuilder.forCompany("acme"); - captureWarnings(warnings, () -> { - acme.groupSet("department", "finance", properties); - acme.groupSetOnce("department", "finance", properties); - acme.groupUnset("department", "finance", Arrays.asList("seats")); - acme.groupUnion("department", "finance", Collections.singletonMap("tags", new JSONArray().put("a"))); - acme.groupRemove("department", "finance", properties); - acme.groupDelete("department", "finance"); - return null; - }); - - assertTrue(warnings.toString(), warnings.isEmpty()); - } - - /** Runs the given builder call with MessageBuilder's warnings collected into the given list. */ - private static JSONObject captureWarnings(List warnings, Supplier call) { - Logger logger = Logger.getLogger(MessageBuilder.class.getName()); - SimpleFormatter formatter = new SimpleFormatter(); - Handler handler = new Handler() { - public void publish(LogRecord record) { - if (record.getLevel().intValue() >= Level.WARNING.intValue()) { - warnings.add(formatter.formatMessage(record)); - } - } - public void flush() {} - public void close() {} - }; - handler.setLevel(Level.ALL); - Level originalLevel = logger.getLevel(); - logger.addHandler(handler); - logger.setLevel(Level.ALL); - try { - return call.get(); - } finally { - logger.removeHandler(handler); - logger.setLevel(originalLevel); - } + assertNotNull(acme.groupSet("department", "finance", properties)); + assertNotNull(acme.groupSetOnce("department", "finance", properties)); + assertNotNull(acme.groupUnset("department", "finance", Arrays.asList("seats"))); + assertNotNull(acme.groupUnion("department", "finance", + Collections.singletonMap("tags", new JSONArray().put("a")))); + assertNotNull(acme.groupRemove("department", "finance", properties)); + assertNotNull(acme.groupDelete("department", "finance")); } // #endregion From f96f5a3d87b6c60ac4897f0c5a2425a04689d1de Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Tue, 3 Nov 2026 09:58:48 -0500 Subject: [PATCH 04/17] feat(analytics): add company* profile methods to forCompany builders Co-Authored-By: Claude Opus 5.5 --- .../mixpanel/mixpanelapi/MessageBuilder.java | 194 +++++++++++++++++- .../mixpanelapi/HierarchicalGroupsTest.java | 74 +++++++ 2 files changed, 266 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java index 700f3b1..f89548a 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java +++ b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java @@ -77,11 +77,13 @@ private MessageBuilder(String token, boolean hierarchicalGroups, String companyI /** * Returns a MessageBuilder that adds {@code $company_id} to every group profile * message it builds. Hierarchical group projects require {@code $company_id} on - * every {@code /groups} write, including the company's own profile and user profiles: + * every {@code /groups} write, including the company's own profile and user profiles. The + * company methods, such as {@link #companySet(JSONObject)}, build messages for the bound + * company's own profile: *
      * {@code
      *     MessageBuilder acme = messageBuilder.forCompany("acme");
-     *     acme.groupSet("$company_id", "acme", companyProperties);
+     *     acme.companySet(companyProperties);
      *     acme.groupSet("$user_id", "user_123", userProperties);
      *     acme.groupSet("department", "finance", departmentProperties);
      * }
@@ -1124,6 +1126,194 @@ public JSONObject groupUnset(String groupKey, String groupId, Collection
         return groupMessage(groupKey, groupId, "$unset", propNamesArray, modifiers);
     }
 
+    /**
+     * Sets properties on the profile of the company bound with {@link #forCompany(String)},
+     * creating the profile if needed. Existing values for the given properties are replaced.
+     * Builds the same message as {@code groupSet("$company_id", companyId, properties)} on this builder.
+     *
+     * @param properties a collection of properties to set on the company profile. Each key
+     *            in the properties argument will be updated on the profile.
+     * @return company profile set message for consumption by MixpanelAPI
+     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
+     */
+    public JSONObject companySet(JSONObject properties) {
+        return companySet(properties, null);
+    }
+
+    /**
+     * Sets properties on the profile of the company bound with {@link #forCompany(String)},
+     * creating the profile if needed. Existing values for the given properties are replaced.
+     * Builds the same message as {@code groupSet("$company_id", companyId, properties, modifiers)}
+     * on this builder.
+     *
+     * @param properties a collection of properties to set on the company profile. Each key
+     *            in the properties argument will be updated on the profile.
+     * @param modifiers Modifiers associated with the update message. (for example "$time" or "$ignore_time").
+     *            this can be null- if non-null, the keys and values in the modifiers
+     *            object will be associated directly with the update.
+     * @return company profile set message for consumption by MixpanelAPI
+     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
+     */
+    public JSONObject companySet(JSONObject properties, JSONObject modifiers) {
+        return groupSet("$company_id", requireBoundCompanyId("companySet"), properties, modifiers);
+    }
+
+    /**
+     * Sets properties if they do not already exist on the profile of the company bound with
+     * {@link #forCompany(String)}.
+     * Builds the same message as {@code groupSetOnce("$company_id", companyId, properties)} on this builder.
+     *
+     * @param properties a collection of properties to set on the company profile. Each key
+     *            in the properties argument will be updated on the profile.
+     * @return company profile setOnce message for consumption by MixpanelAPI
+     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
+     */
+    public JSONObject companySetOnce(JSONObject properties) {
+        return companySetOnce(properties, null);
+    }
+
+    /**
+     * Sets properties if they do not already exist on the profile of the company bound with
+     * {@link #forCompany(String)}.
+     * Builds the same message as {@code groupSetOnce("$company_id", companyId, properties, modifiers)}
+     * on this builder.
+     *
+     * @param properties a collection of properties to set on the company profile. Each key
+     *            in the properties argument will be updated on the profile.
+     * @param modifiers Modifiers associated with the update message. (for example "$time" or "$ignore_time").
+     *            this can be null- if non-null, the keys and values in the modifiers
+     *            object will be associated directly with the update.
+     * @return company profile setOnce message for consumption by MixpanelAPI
+     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
+     */
+    public JSONObject companySetOnce(JSONObject properties, JSONObject modifiers) {
+        return groupSetOnce("$company_id", requireBoundCompanyId("companySetOnce"), properties, modifiers);
+    }
+
+    /**
+     * Deletes the profile of the company bound with {@link #forCompany(String)}.
+     * Builds the same message as {@code groupDelete("$company_id", companyId)} on this builder.
+     *
+     * @return company profile delete message for consumption by MixpanelAPI
+     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
+     */
+    public JSONObject companyDelete() {
+        return companyDelete(null);
+    }
+
+    /**
+     * Deletes the profile of the company bound with {@link #forCompany(String)}.
+     * Builds the same message as {@code groupDelete("$company_id", companyId, modifiers)} on this builder.
+     *
+     * @param modifiers Modifiers associated with the update message. (for example "$time" or "$ignore_time").
+     *            this can be null- if non-null, the keys and values in the modifiers
+     *            object will be associated directly with the update.
+     * @return company profile delete message for consumption by MixpanelAPI
+     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
+     */
+    public JSONObject companyDelete(JSONObject modifiers) {
+        return groupDelete("$company_id", requireBoundCompanyId("companyDelete"), modifiers);
+    }
+
+    /**
+     * For each key and value in the properties argument, attempts to remove that value from a list
+     * associated with the key in the profile of the company bound with {@link #forCompany(String)}.
+     * Builds the same message as {@code groupRemove("$company_id", companyId, properties)} on this builder.
+     *
+     * @param properties properties for the remove operation
+     * @return company profile remove message for consumption by MixpanelAPI
+     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
+     */
+    public JSONObject companyRemove(JSONObject properties) {
+        return companyRemove(properties, null);
+    }
+
+    /**
+     * For each key and value in the properties argument, attempts to remove that value from a list
+     * associated with the key in the profile of the company bound with {@link #forCompany(String)}.
+     * Builds the same message as {@code groupRemove("$company_id", companyId, properties, modifiers)}
+     * on this builder.
+     *
+     * @param properties properties for the remove operation
+     * @param modifiers Modifiers associated with the update message. (for example "$time" or "$ignore_time").
+     *            this can be null- if non-null, the keys and values in the modifiers
+     *            object will be associated directly with the update.
+     * @return company profile remove message for consumption by MixpanelAPI
+     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
+     */
+    public JSONObject companyRemove(JSONObject properties, JSONObject modifiers) {
+        return groupRemove("$company_id", requireBoundCompanyId("companyRemove"), properties, modifiers);
+    }
+
+    /**
+     * Merges list-valued properties into the profile of the company bound with
+     * {@link #forCompany(String)}, ignoring duplicate list values.
+     * Builds the same message as {@code groupUnion("$company_id", companyId, properties)} on this builder.
+     *
+     * @param properties properties for the union operation
+     * @return company profile union message for consumption by MixpanelAPI
+     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
+     */
+    public JSONObject companyUnion(Map properties) {
+        return companyUnion(properties, null);
+    }
+
+    /**
+     * Merges list-valued properties into the profile of the company bound with
+     * {@link #forCompany(String)}, ignoring duplicate list values.
+     * Builds the same message as {@code groupUnion("$company_id", companyId, properties, modifiers)}
+     * on this builder.
+     *
+     * @param properties properties for the union operation
+     * @param modifiers Modifiers associated with the update message. (for example "$time" or "$ignore_time").
+     *            this can be null- if non-null, the keys and values in the modifiers
+     *            object will be associated directly with the update.
+     * @return company profile union message for consumption by MixpanelAPI
+     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
+     */
+    public JSONObject companyUnion(Map properties, JSONObject modifiers) {
+        return groupUnion("$company_id", requireBoundCompanyId("companyUnion"), properties, modifiers);
+    }
+
+    /**
+     * Removes the properties named in propertyNames from the profile of the company bound with
+     * {@link #forCompany(String)}.
+     * Builds the same message as {@code groupUnset("$company_id", companyId, propertyNames)} on this builder.
+     *
+     * @param propertyNames properties for the unset operation
+     * @return company profile unset message for consumption by MixpanelAPI
+     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
+     */
+    public JSONObject companyUnset(Collection propertyNames) {
+        return companyUnset(propertyNames, null);
+    }
+
+    /**
+     * Removes the properties named in propertyNames from the profile of the company bound with
+     * {@link #forCompany(String)}.
+     * Builds the same message as {@code groupUnset("$company_id", companyId, propertyNames, modifiers)}
+     * on this builder.
+     *
+     * @param propertyNames properties for the unset operation
+     * @param modifiers Modifiers associated with the update message. (for example "$time" or "$ignore_time").
+     *            this can be null- if non-null, the keys and values in the modifiers
+     *            object will be associated directly with the update.
+     * @return company profile unset message for consumption by MixpanelAPI
+     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
+     */
+    public JSONObject companyUnset(Collection propertyNames, JSONObject modifiers) {
+        return groupUnset("$company_id", requireBoundCompanyId("companyUnset"), propertyNames, modifiers);
+    }
+
+    private String requireBoundCompanyId(String method) {
+        if (mCompanyId == null) {
+            throw new IllegalStateException(method + " builds a message for the profile of the company bound with "
+                    + "forCompany, and this builder has none. Use forCompany(companyId)." + method
+                    + ". Nothing to send");
+        }
+        return mCompanyId;
+    }
+
     /**
      * Formats a generic group profile message.
      * Use of this method requires familiarity with the underlying Mixpanel HTTP API,
diff --git a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java
index f313044..3ab7938 100644
--- a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java
+++ b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java
@@ -9,7 +9,9 @@
 
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.List;
 import java.util.Map;
+import java.util.function.Function;
 
 import static org.junit.Assert.*;
 
@@ -620,6 +622,78 @@ public void testSupportedGroupOperationsBuild() {
         assertNotNull(acme.groupDelete("department", "finance"));
     }
 
+    /** A company method call, followed by the equivalent group method call with the company named twice. */
+    private static List> companyAndGroupCalls(JSONObject modifiers) {
+        JSONObject props = new JSONObject().put("plan", "enterprise");
+        Map union = Collections.singletonMap("tags", new JSONArray().put("a"));
+        List unset = Arrays.asList("plan");
+        if (modifiers == null) {
+            return Arrays.asList(
+                b -> b.companySet(props), b -> b.groupSet("$company_id", "acme", props),
+                b -> b.companySetOnce(props), b -> b.groupSetOnce("$company_id", "acme", props),
+                b -> b.companyDelete(), b -> b.groupDelete("$company_id", "acme"),
+                b -> b.companyRemove(props), b -> b.groupRemove("$company_id", "acme", props),
+                b -> b.companyUnion(union), b -> b.groupUnion("$company_id", "acme", union),
+                b -> b.companyUnset(unset), b -> b.groupUnset("$company_id", "acme", unset));
+        }
+        return Arrays.asList(
+            b -> b.companySet(props, modifiers), b -> b.groupSet("$company_id", "acme", props, modifiers),
+            b -> b.companySetOnce(props, modifiers), b -> b.groupSetOnce("$company_id", "acme", props, modifiers),
+            b -> b.companyDelete(modifiers), b -> b.groupDelete("$company_id", "acme", modifiers),
+            b -> b.companyRemove(props, modifiers), b -> b.groupRemove("$company_id", "acme", props, modifiers),
+            b -> b.companyUnion(union, modifiers), b -> b.groupUnion("$company_id", "acme", union, modifiers),
+            b -> b.companyUnset(unset, modifiers), b -> b.groupUnset("$company_id", "acme", unset, modifiers));
+    }
+
+    private static void assertCompanyMethodsMatchGroupMethods(MessageBuilder acme, JSONObject modifiers) {
+        List> calls = companyAndGroupCalls(modifiers);
+        for (int i = 0; i < calls.size(); i += 2) {
+            JSONObject company = calls.get(i).apply(acme);
+            JSONObject group = calls.get(i + 1).apply(acme);
+            if (modifiers == null) {
+                // $time is the build time, so it can differ between the two calls.
+                company.getJSONObject("message").remove("$time");
+                group.getJSONObject("message").remove("$time");
+            }
+            assertEquals(group.toString(), company.toString());
+
+            JSONObject message = company.getJSONObject("message");
+            assertEquals("$company_id", message.getString("$group_key"));
+            assertEquals("acme", message.getString("$group_id"));
+            assertEquals("acme", message.getString("$company_id"));
+        }
+    }
+
+    @Test
+    public void testCompanyMethodsMatchGroupMethodsWithBoundCompany() {
+        assertCompanyMethodsMatchGroupMethods(hgBuilder.forCompany("acme"), null);
+        assertCompanyMethodsMatchGroupMethods(legacyBuilder.forCompany("acme"), null);
+    }
+
+    @Test
+    public void testCompanyMethodsWithModifiersMatchGroupMethodsWithBoundCompany() {
+        JSONObject modifiers = new JSONObject().put("$time", 1234567890L).put("$ignore_time", true);
+        assertCompanyMethodsMatchGroupMethods(hgBuilder.forCompany("acme"), modifiers);
+        assertCompanyMethodsMatchGroupMethods(legacyBuilder.forCompany("acme"), modifiers);
+    }
+
+    @Test
+    public void testCompanyMethodsWithoutBoundCompanyAreRejected() {
+        String[] names = {"companySet", "companySetOnce", "companyDelete", "companyRemove", "companyUnion",
+            "companyUnset"};
+        for (JSONObject modifiers : Arrays.asList(null, new JSONObject().put("$company_id", "acme"))) {
+            List> calls = companyAndGroupCalls(modifiers);
+            for (MessageBuilder unbound : Arrays.asList(hgBuilder, legacyBuilder)) {
+                for (int i = 0; i < calls.size(); i += 2) {
+                    Function call = calls.get(i);
+                    IllegalStateException e = assertThrows(IllegalStateException.class, () -> call.apply(unbound));
+                    assertTrue(e.getMessage(), e.getMessage().contains("forCompany"));
+                    assertTrue(e.getMessage(), e.getMessage().startsWith(names[i / 2]));
+                }
+            }
+        }
+    }
+
     // #endregion
     // #region Flag context
 

From 94895107bf70acc4b8838bf9913cc016919e4168 Mon Sep 17 00:00:00 2001
From: Tyler Roach 
Date: Tue, 3 Nov 2026 10:00:45 -0500
Subject: [PATCH 05/17] chore(analytics): point the company-only identity error
 at the company profile API

Co-Authored-By: Claude Opus 5.5 
---
 src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java      | 2 +-
 .../java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java   | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java
index f89548a..45e8d47 100644
--- a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java
+++ b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java
@@ -740,7 +740,7 @@ private JSONObject profileMessage(MixpanelIdentity identity, String actionType,
                         + "Device ids identify anonymous events, not profiles.");
             }
             throw new IllegalArgumentException("User profile updates require a user id, and " + identity
-                    + " has none. Use groupSet(\"$company_id\", companyId, properties) to update the company's "
+                    + " has none. Use forCompany(companyId).companySet(properties) to update the company's "
                     + "profile.");
         }
         return buildGroupMessage(USER_ID_GROUP_KEY, identity.getUserId(), actionType, properties, modifiers,
diff --git a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java
index 3ab7938..1e8af45 100644
--- a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java
+++ b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java
@@ -478,7 +478,7 @@ public void testCompanyOnlyIdentityProfileUpdateIsRejected() {
         IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
                 () -> hgBuilder.set(MixpanelIdentity.builder().companyId("acme").build(), new JSONObject().put("tier", "ent")));
 
-        assertTrue(e.getMessage(), e.getMessage().contains("groupSet"));
+        assertTrue(e.getMessage(), e.getMessage().contains("companySet"));
     }
 
     @Test

From 1a677347f118d9c2dfa800981e261643a66fe806 Mon Sep 17 00:00:00 2001
From: Tyler Roach 
Date: Tue, 3 Nov 2026 10:09:05 -0500
Subject: [PATCH 06/17] revert(analytics): drop the company profile methods
 from for_company clients

The company profile is updated through the existing group methods instead.

Co-Authored-By: Claude Opus 5.5 
---
 .../mixpanel/mixpanelapi/MessageBuilder.java  | 196 +-----------------
 .../mixpanelapi/HierarchicalGroupsTest.java   |  76 +------
 2 files changed, 4 insertions(+), 268 deletions(-)

diff --git a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java
index 45e8d47..700f3b1 100644
--- a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java
+++ b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java
@@ -77,13 +77,11 @@ private MessageBuilder(String token, boolean hierarchicalGroups, String companyI
     /**
      * Returns a MessageBuilder that adds {@code $company_id} to every group profile
      * message it builds. Hierarchical group projects require {@code $company_id} on
-     * every {@code /groups} write, including the company's own profile and user profiles. The
-     * company methods, such as {@link #companySet(JSONObject)}, build messages for the bound
-     * company's own profile:
+     * every {@code /groups} write, including the company's own profile and user profiles:
      * 
      * {@code
      *     MessageBuilder acme = messageBuilder.forCompany("acme");
-     *     acme.companySet(companyProperties);
+     *     acme.groupSet("$company_id", "acme", companyProperties);
      *     acme.groupSet("$user_id", "user_123", userProperties);
      *     acme.groupSet("department", "finance", departmentProperties);
      * }
@@ -740,7 +738,7 @@ private JSONObject profileMessage(MixpanelIdentity identity, String actionType,
                         + "Device ids identify anonymous events, not profiles.");
             }
             throw new IllegalArgumentException("User profile updates require a user id, and " + identity
-                    + " has none. Use forCompany(companyId).companySet(properties) to update the company's "
+                    + " has none. Use groupSet(\"$company_id\", companyId, properties) to update the company's "
                     + "profile.");
         }
         return buildGroupMessage(USER_ID_GROUP_KEY, identity.getUserId(), actionType, properties, modifiers,
@@ -1126,194 +1124,6 @@ public JSONObject groupUnset(String groupKey, String groupId, Collection
         return groupMessage(groupKey, groupId, "$unset", propNamesArray, modifiers);
     }
 
-    /**
-     * Sets properties on the profile of the company bound with {@link #forCompany(String)},
-     * creating the profile if needed. Existing values for the given properties are replaced.
-     * Builds the same message as {@code groupSet("$company_id", companyId, properties)} on this builder.
-     *
-     * @param properties a collection of properties to set on the company profile. Each key
-     *            in the properties argument will be updated on the profile.
-     * @return company profile set message for consumption by MixpanelAPI
-     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
-     */
-    public JSONObject companySet(JSONObject properties) {
-        return companySet(properties, null);
-    }
-
-    /**
-     * Sets properties on the profile of the company bound with {@link #forCompany(String)},
-     * creating the profile if needed. Existing values for the given properties are replaced.
-     * Builds the same message as {@code groupSet("$company_id", companyId, properties, modifiers)}
-     * on this builder.
-     *
-     * @param properties a collection of properties to set on the company profile. Each key
-     *            in the properties argument will be updated on the profile.
-     * @param modifiers Modifiers associated with the update message. (for example "$time" or "$ignore_time").
-     *            this can be null- if non-null, the keys and values in the modifiers
-     *            object will be associated directly with the update.
-     * @return company profile set message for consumption by MixpanelAPI
-     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
-     */
-    public JSONObject companySet(JSONObject properties, JSONObject modifiers) {
-        return groupSet("$company_id", requireBoundCompanyId("companySet"), properties, modifiers);
-    }
-
-    /**
-     * Sets properties if they do not already exist on the profile of the company bound with
-     * {@link #forCompany(String)}.
-     * Builds the same message as {@code groupSetOnce("$company_id", companyId, properties)} on this builder.
-     *
-     * @param properties a collection of properties to set on the company profile. Each key
-     *            in the properties argument will be updated on the profile.
-     * @return company profile setOnce message for consumption by MixpanelAPI
-     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
-     */
-    public JSONObject companySetOnce(JSONObject properties) {
-        return companySetOnce(properties, null);
-    }
-
-    /**
-     * Sets properties if they do not already exist on the profile of the company bound with
-     * {@link #forCompany(String)}.
-     * Builds the same message as {@code groupSetOnce("$company_id", companyId, properties, modifiers)}
-     * on this builder.
-     *
-     * @param properties a collection of properties to set on the company profile. Each key
-     *            in the properties argument will be updated on the profile.
-     * @param modifiers Modifiers associated with the update message. (for example "$time" or "$ignore_time").
-     *            this can be null- if non-null, the keys and values in the modifiers
-     *            object will be associated directly with the update.
-     * @return company profile setOnce message for consumption by MixpanelAPI
-     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
-     */
-    public JSONObject companySetOnce(JSONObject properties, JSONObject modifiers) {
-        return groupSetOnce("$company_id", requireBoundCompanyId("companySetOnce"), properties, modifiers);
-    }
-
-    /**
-     * Deletes the profile of the company bound with {@link #forCompany(String)}.
-     * Builds the same message as {@code groupDelete("$company_id", companyId)} on this builder.
-     *
-     * @return company profile delete message for consumption by MixpanelAPI
-     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
-     */
-    public JSONObject companyDelete() {
-        return companyDelete(null);
-    }
-
-    /**
-     * Deletes the profile of the company bound with {@link #forCompany(String)}.
-     * Builds the same message as {@code groupDelete("$company_id", companyId, modifiers)} on this builder.
-     *
-     * @param modifiers Modifiers associated with the update message. (for example "$time" or "$ignore_time").
-     *            this can be null- if non-null, the keys and values in the modifiers
-     *            object will be associated directly with the update.
-     * @return company profile delete message for consumption by MixpanelAPI
-     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
-     */
-    public JSONObject companyDelete(JSONObject modifiers) {
-        return groupDelete("$company_id", requireBoundCompanyId("companyDelete"), modifiers);
-    }
-
-    /**
-     * For each key and value in the properties argument, attempts to remove that value from a list
-     * associated with the key in the profile of the company bound with {@link #forCompany(String)}.
-     * Builds the same message as {@code groupRemove("$company_id", companyId, properties)} on this builder.
-     *
-     * @param properties properties for the remove operation
-     * @return company profile remove message for consumption by MixpanelAPI
-     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
-     */
-    public JSONObject companyRemove(JSONObject properties) {
-        return companyRemove(properties, null);
-    }
-
-    /**
-     * For each key and value in the properties argument, attempts to remove that value from a list
-     * associated with the key in the profile of the company bound with {@link #forCompany(String)}.
-     * Builds the same message as {@code groupRemove("$company_id", companyId, properties, modifiers)}
-     * on this builder.
-     *
-     * @param properties properties for the remove operation
-     * @param modifiers Modifiers associated with the update message. (for example "$time" or "$ignore_time").
-     *            this can be null- if non-null, the keys and values in the modifiers
-     *            object will be associated directly with the update.
-     * @return company profile remove message for consumption by MixpanelAPI
-     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
-     */
-    public JSONObject companyRemove(JSONObject properties, JSONObject modifiers) {
-        return groupRemove("$company_id", requireBoundCompanyId("companyRemove"), properties, modifiers);
-    }
-
-    /**
-     * Merges list-valued properties into the profile of the company bound with
-     * {@link #forCompany(String)}, ignoring duplicate list values.
-     * Builds the same message as {@code groupUnion("$company_id", companyId, properties)} on this builder.
-     *
-     * @param properties properties for the union operation
-     * @return company profile union message for consumption by MixpanelAPI
-     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
-     */
-    public JSONObject companyUnion(Map properties) {
-        return companyUnion(properties, null);
-    }
-
-    /**
-     * Merges list-valued properties into the profile of the company bound with
-     * {@link #forCompany(String)}, ignoring duplicate list values.
-     * Builds the same message as {@code groupUnion("$company_id", companyId, properties, modifiers)}
-     * on this builder.
-     *
-     * @param properties properties for the union operation
-     * @param modifiers Modifiers associated with the update message. (for example "$time" or "$ignore_time").
-     *            this can be null- if non-null, the keys and values in the modifiers
-     *            object will be associated directly with the update.
-     * @return company profile union message for consumption by MixpanelAPI
-     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
-     */
-    public JSONObject companyUnion(Map properties, JSONObject modifiers) {
-        return groupUnion("$company_id", requireBoundCompanyId("companyUnion"), properties, modifiers);
-    }
-
-    /**
-     * Removes the properties named in propertyNames from the profile of the company bound with
-     * {@link #forCompany(String)}.
-     * Builds the same message as {@code groupUnset("$company_id", companyId, propertyNames)} on this builder.
-     *
-     * @param propertyNames properties for the unset operation
-     * @return company profile unset message for consumption by MixpanelAPI
-     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
-     */
-    public JSONObject companyUnset(Collection propertyNames) {
-        return companyUnset(propertyNames, null);
-    }
-
-    /**
-     * Removes the properties named in propertyNames from the profile of the company bound with
-     * {@link #forCompany(String)}.
-     * Builds the same message as {@code groupUnset("$company_id", companyId, propertyNames, modifiers)}
-     * on this builder.
-     *
-     * @param propertyNames properties for the unset operation
-     * @param modifiers Modifiers associated with the update message. (for example "$time" or "$ignore_time").
-     *            this can be null- if non-null, the keys and values in the modifiers
-     *            object will be associated directly with the update.
-     * @return company profile unset message for consumption by MixpanelAPI
-     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
-     */
-    public JSONObject companyUnset(Collection propertyNames, JSONObject modifiers) {
-        return groupUnset("$company_id", requireBoundCompanyId("companyUnset"), propertyNames, modifiers);
-    }
-
-    private String requireBoundCompanyId(String method) {
-        if (mCompanyId == null) {
-            throw new IllegalStateException(method + " builds a message for the profile of the company bound with "
-                    + "forCompany, and this builder has none. Use forCompany(companyId)." + method
-                    + ". Nothing to send");
-        }
-        return mCompanyId;
-    }
-
     /**
      * Formats a generic group profile message.
      * Use of this method requires familiarity with the underlying Mixpanel HTTP API,
diff --git a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java
index 1e8af45..f313044 100644
--- a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java
+++ b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java
@@ -9,9 +9,7 @@
 
 import java.util.Arrays;
 import java.util.Collections;
-import java.util.List;
 import java.util.Map;
-import java.util.function.Function;
 
 import static org.junit.Assert.*;
 
@@ -478,7 +476,7 @@ public void testCompanyOnlyIdentityProfileUpdateIsRejected() {
         IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
                 () -> hgBuilder.set(MixpanelIdentity.builder().companyId("acme").build(), new JSONObject().put("tier", "ent")));
 
-        assertTrue(e.getMessage(), e.getMessage().contains("companySet"));
+        assertTrue(e.getMessage(), e.getMessage().contains("groupSet"));
     }
 
     @Test
@@ -622,78 +620,6 @@ public void testSupportedGroupOperationsBuild() {
         assertNotNull(acme.groupDelete("department", "finance"));
     }
 
-    /** A company method call, followed by the equivalent group method call with the company named twice. */
-    private static List> companyAndGroupCalls(JSONObject modifiers) {
-        JSONObject props = new JSONObject().put("plan", "enterprise");
-        Map union = Collections.singletonMap("tags", new JSONArray().put("a"));
-        List unset = Arrays.asList("plan");
-        if (modifiers == null) {
-            return Arrays.asList(
-                b -> b.companySet(props), b -> b.groupSet("$company_id", "acme", props),
-                b -> b.companySetOnce(props), b -> b.groupSetOnce("$company_id", "acme", props),
-                b -> b.companyDelete(), b -> b.groupDelete("$company_id", "acme"),
-                b -> b.companyRemove(props), b -> b.groupRemove("$company_id", "acme", props),
-                b -> b.companyUnion(union), b -> b.groupUnion("$company_id", "acme", union),
-                b -> b.companyUnset(unset), b -> b.groupUnset("$company_id", "acme", unset));
-        }
-        return Arrays.asList(
-            b -> b.companySet(props, modifiers), b -> b.groupSet("$company_id", "acme", props, modifiers),
-            b -> b.companySetOnce(props, modifiers), b -> b.groupSetOnce("$company_id", "acme", props, modifiers),
-            b -> b.companyDelete(modifiers), b -> b.groupDelete("$company_id", "acme", modifiers),
-            b -> b.companyRemove(props, modifiers), b -> b.groupRemove("$company_id", "acme", props, modifiers),
-            b -> b.companyUnion(union, modifiers), b -> b.groupUnion("$company_id", "acme", union, modifiers),
-            b -> b.companyUnset(unset, modifiers), b -> b.groupUnset("$company_id", "acme", unset, modifiers));
-    }
-
-    private static void assertCompanyMethodsMatchGroupMethods(MessageBuilder acme, JSONObject modifiers) {
-        List> calls = companyAndGroupCalls(modifiers);
-        for (int i = 0; i < calls.size(); i += 2) {
-            JSONObject company = calls.get(i).apply(acme);
-            JSONObject group = calls.get(i + 1).apply(acme);
-            if (modifiers == null) {
-                // $time is the build time, so it can differ between the two calls.
-                company.getJSONObject("message").remove("$time");
-                group.getJSONObject("message").remove("$time");
-            }
-            assertEquals(group.toString(), company.toString());
-
-            JSONObject message = company.getJSONObject("message");
-            assertEquals("$company_id", message.getString("$group_key"));
-            assertEquals("acme", message.getString("$group_id"));
-            assertEquals("acme", message.getString("$company_id"));
-        }
-    }
-
-    @Test
-    public void testCompanyMethodsMatchGroupMethodsWithBoundCompany() {
-        assertCompanyMethodsMatchGroupMethods(hgBuilder.forCompany("acme"), null);
-        assertCompanyMethodsMatchGroupMethods(legacyBuilder.forCompany("acme"), null);
-    }
-
-    @Test
-    public void testCompanyMethodsWithModifiersMatchGroupMethodsWithBoundCompany() {
-        JSONObject modifiers = new JSONObject().put("$time", 1234567890L).put("$ignore_time", true);
-        assertCompanyMethodsMatchGroupMethods(hgBuilder.forCompany("acme"), modifiers);
-        assertCompanyMethodsMatchGroupMethods(legacyBuilder.forCompany("acme"), modifiers);
-    }
-
-    @Test
-    public void testCompanyMethodsWithoutBoundCompanyAreRejected() {
-        String[] names = {"companySet", "companySetOnce", "companyDelete", "companyRemove", "companyUnion",
-            "companyUnset"};
-        for (JSONObject modifiers : Arrays.asList(null, new JSONObject().put("$company_id", "acme"))) {
-            List> calls = companyAndGroupCalls(modifiers);
-            for (MessageBuilder unbound : Arrays.asList(hgBuilder, legacyBuilder)) {
-                for (int i = 0; i < calls.size(); i += 2) {
-                    Function call = calls.get(i);
-                    IllegalStateException e = assertThrows(IllegalStateException.class, () -> call.apply(unbound));
-                    assertTrue(e.getMessage(), e.getMessage().contains("forCompany"));
-                    assertTrue(e.getMessage(), e.getMessage().startsWith(names[i / 2]));
-                }
-            }
-        }
-    }
-
     // #endregion
     // #region Flag context
 

From ba8300eb6d193104662dfcdb36522bcfadcda4bf Mon Sep 17 00:00:00 2001
From: Tyler Roach 
Date: Tue, 3 Nov 2026 10:12:40 -0500
Subject: [PATCH 07/17] feat(analytics): infer the company for $company_id
 group profile updates

Co-Authored-By: Claude Opus 5.5 
---
 .../mixpanel/mixpanelapi/MessageBuilder.java  | 34 +++++--
 .../HierarchicalGroupsScenariosTest.java      | 10 +-
 .../mixpanelapi/HierarchicalGroupsTest.java   | 94 +++++++++++++++++++
 3 files changed, 127 insertions(+), 11 deletions(-)

diff --git a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java
index 700f3b1..e2096ec 100644
--- a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java
+++ b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java
@@ -59,7 +59,8 @@ public MessageBuilder(String token) {
      * only a distinct id of the form {@code $company:{company}|$user:{user}}, send it to
      * {@code /groups} as that user's profile within the company, and throw
      * {@link IllegalArgumentException} for any other distinct id. Group profile messages require a
-     * known company, either through {@link #forCompany(String)} or a {@code $company_id} modifier.
+     * known company, either through {@link #forCompany(String)} or a {@code $company_id} modifier;
+     * a {@code $company_id} group profile is its own company and needs neither.
      *
      * @param token the Mixpanel project token
      * @param hierarchicalGroups true if the project uses hierarchical groups
@@ -77,11 +78,12 @@ private MessageBuilder(String token, boolean hierarchicalGroups, String companyI
     /**
      * Returns a MessageBuilder that adds {@code $company_id} to every group profile
      * message it builds. Hierarchical group projects require {@code $company_id} on
-     * every {@code /groups} write, including the company's own profile and user profiles:
+     * every {@code /groups} write, including user profiles. The company's own profile needs no
+     * binding, since its group id is the company:
      * 
      * {@code
+     *     messageBuilder.groupSet("$company_id", "acme", companyProperties);
      *     MessageBuilder acme = messageBuilder.forCompany("acme");
-     *     acme.groupSet("$company_id", "acme", companyProperties);
      *     acme.groupSet("$user_id", "user_123", userProperties);
      *     acme.groupSet("department", "finance", departmentProperties);
      * }
@@ -1144,8 +1146,10 @@ public JSONObject groupUnset(String groupKey, String groupId, Collection
      *           be merged as modifiers associated with the update message (for example, "$time" or "$ignore_time")
      * @return generic group profile message for consumption by MixpanelAPI
      *
-     * @throws IllegalArgumentException if properties is not intelligible as a JSONObject property
-     * @throws IllegalStateException for hierarchical groups projects when no company is known
+     * @throws IllegalArgumentException if properties is not intelligible as a JSONObject property, or, for
+     *           hierarchical groups projects, when a {@code $company_id} group profile is given a different company
+     * @throws IllegalStateException for hierarchical groups projects when no company is known. A
+     *           {@code $company_id} group profile is its own company.
      * @throws UnsupportedOperationException on a hierarchical groups builder, for an operation /groups lacks
      *           ($add, $append or $merge)
      *
@@ -1166,7 +1170,25 @@ public JSONObject groupMessage(String groupKey, String groupId, String actionTyp
 
     private JSONObject buildGroupMessage(String groupKey, String groupId, String actionType, Object properties,
                                          JSONObject modifiers, String companyId) {
-        if (mHierarchicalGroups && companyId == null && (modifiers == null || !modifiers.has("$company_id"))) {
+        boolean hasCompanyModifier = modifiers != null && modifiers.has("$company_id");
+        if (mHierarchicalGroups && "$company_id".equals(groupKey)) {
+            // The company's own profile: its group id is the company
+            Object conflicting = null;
+            if (companyId != null && !companyId.equals(groupId)) {
+                conflicting = companyId;
+            } else if (hasCompanyModifier && (groupId == null || !groupId.equals(modifiers.opt("$company_id")))) {
+                conflicting = modifiers.opt("$company_id");
+            }
+            if (conflicting != null) {
+                throw new IllegalArgumentException("The company profile's company is its own id, so group "
+                        + groupKey + "=" + groupId + " cannot be updated for company " + conflicting
+                        + ". Nothing to send.");
+            }
+            if (companyId == null && !hasCompanyModifier && groupId != null && !groupId.isEmpty()) {
+                companyId = groupId;
+            }
+        }
+        if (mHierarchicalGroups && companyId == null && !hasCompanyModifier) {
             throw new IllegalStateException("Group profile updates for hierarchical groups projects require a "
                     + "company. Use forCompany(companyId) to build them. Nothing to send for group "
                     + groupKey + "=" + groupId);
diff --git a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsScenariosTest.java b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsScenariosTest.java
index 75680a2..d15704a 100644
--- a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsScenariosTest.java
+++ b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsScenariosTest.java
@@ -575,13 +575,13 @@ public void testSetUserPropertyWithoutUserIsUnsupported() {
         assertNothingSent();
     }
 
-    // Set Company Property Without Company (Unsupported)
+    // Set Company Property Without Company (the company profile is its own company)
 
     @Test
-    public void testSetCompanyPropertyWithoutCompanyIsUnsupported() {
-        assertThrows(IllegalStateException.class,
-                () -> mBuilder.groupSet("$company_id", COMPANY_ID, json("tier", "enterprise")));
-        assertNothingSent();
+    public void testSetCompanyPropertyWithoutCompany() {
+        send(mBuilder.groupSet("$company_id", COMPANY_ID, json("tier", "enterprise")));
+
+        assertJsonEquals(json("tier", "enterprise"), onlyCompanyUpdate("$set"));
     }
 
     // Set Custom Group Property Without Company (Unsupported)
diff --git a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java
index f313044..077ced1 100644
--- a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java
+++ b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java
@@ -6,9 +6,11 @@
 import org.json.JSONObject;
 import org.junit.Before;
 import org.junit.Test;
+import org.junit.function.ThrowingRunnable;
 
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.List;
 import java.util.Map;
 
 import static org.junit.Assert.*;
@@ -542,6 +544,98 @@ public void testHierarchicalGroupWithoutCompanyIsRejected() {
         assertTrue(e.getMessage(), e.getMessage().contains("forCompany"));
     }
 
+    private static JSONObject[] companyProfileMessages(MessageBuilder builder, String companyId, JSONObject modifiers) {
+        JSONObject props = new JSONObject().put("plan", "enterprise");
+        return new JSONObject[] {
+            builder.groupSet("$company_id", companyId, props, modifiers),
+            builder.groupSetOnce("$company_id", companyId, props, modifiers),
+            builder.groupDelete("$company_id", companyId, modifiers),
+            builder.groupRemove("$company_id", companyId, props, modifiers),
+            builder.groupUnion("$company_id", companyId, Collections.singletonMap("tags", new JSONArray().put("a")),
+                    modifiers),
+            builder.groupUnset("$company_id", companyId, Arrays.asList("plan"), modifiers),
+            builder.groupMessage("$company_id", companyId, "$set", props, modifiers),
+        };
+    }
+
+    private static void assertCompanyProfileMessages(JSONObject[] envelopes) {
+        assertEquals(7, envelopes.length);
+        for (JSONObject envelope : envelopes) {
+            JSONObject message = envelope.getJSONObject("message");
+            assertEquals("$company_id", message.getString("$group_key"));
+            assertEquals("acme", message.getString("$group_id"));
+            assertEquals("acme", message.getString("$company_id"));
+        }
+    }
+
+    private static void assertEveryCompanyProfileOperationThrows(Class expected,
+                                                                 MessageBuilder builder, String companyId,
+                                                                 JSONObject modifiers, String messageText) {
+        JSONObject props = new JSONObject().put("plan", "enterprise");
+        List calls = Arrays.asList(
+            () -> builder.groupSet("$company_id", companyId, props, modifiers),
+            () -> builder.groupSetOnce("$company_id", companyId, props, modifiers),
+            () -> builder.groupDelete("$company_id", companyId, modifiers),
+            () -> builder.groupRemove("$company_id", companyId, props, modifiers),
+            () -> builder.groupUnion("$company_id", companyId,
+                    Collections.singletonMap("tags", new JSONArray().put("a")), modifiers),
+            () -> builder.groupUnset("$company_id", companyId, Arrays.asList("plan"), modifiers),
+            () -> builder.groupMessage("$company_id", companyId, "$set", props, modifiers));
+        for (ThrowingRunnable call : calls) {
+            Throwable e = assertThrows(expected, call);
+            assertTrue(e.getMessage(), e.getMessage().contains(messageText));
+        }
+    }
+
+    @Test
+    public void testCompanyProfileInfersItsCompanyFromGroupId() {
+        assertCompanyProfileMessages(companyProfileMessages(hgBuilder, "acme", null));
+    }
+
+    @Test
+    public void testCompanyProfileWithMatchingCompany() {
+        assertCompanyProfileMessages(companyProfileMessages(hgBuilder.forCompany("acme"), "acme", null));
+        assertCompanyProfileMessages(
+                companyProfileMessages(hgBuilder, "acme", new JSONObject().put("$company_id", "acme")));
+        assertCompanyProfileMessages(companyProfileMessages(hgBuilder.forCompany("acme"), "acme",
+                new JSONObject().put("$company_id", "acme")));
+    }
+
+    @Test
+    public void testCompanyProfileWithDifferentBoundCompanyIsRejected() {
+        assertEveryCompanyProfileOperationThrows(IllegalArgumentException.class, hgBuilder.forCompany("other"),
+                "acme", null, "company profile's company is its own id");
+    }
+
+    @Test
+    public void testCompanyProfileWithDifferentModifierCompanyIsRejected() {
+        assertEveryCompanyProfileOperationThrows(IllegalArgumentException.class, hgBuilder, "acme",
+                new JSONObject().put("$company_id", "other"), "company profile's company is its own id");
+        assertEveryCompanyProfileOperationThrows(IllegalArgumentException.class, hgBuilder.forCompany("acme"),
+                "acme", new JSONObject().put("$company_id", "other"), "company profile's company is its own id");
+    }
+
+    @Test
+    public void testCompanyProfileWithEmptyIdStillNeedsCompany() {
+        assertEveryCompanyProfileOperationThrows(IllegalStateException.class, hgBuilder, "", null, "forCompany");
+    }
+
+    @Test
+    public void testLegacyCompanyProfileUnchanged() {
+        JSONObject message = legacyBuilder.groupSet("$company_id", "acme", new JSONObject().put("plan", "x"))
+            .getJSONObject("message");
+        assertFalse(message.has("$company_id"));
+
+        JSONObject bound = legacyBuilder.forCompany("other")
+            .groupSet("$company_id", "acme", new JSONObject().put("plan", "x"))
+            .getJSONObject("message");
+        assertEquals("other", bound.getString("$company_id"));
+
+        JSONObject modified = legacyBuilder.groupSet("$company_id", "acme", new JSONObject().put("plan", "x"),
+                new JSONObject().put("$company_id", "other")).getJSONObject("message");
+        assertEquals("other", modified.getString("$company_id"));
+    }
+
     @Test
     public void testCompanyIdModifierIsAccepted() {
         JSONObject modifiers = new JSONObject().put("$company_id", "acme");

From 84fdbb135e00b440efcc432392da2b90bef2e206 Mon Sep 17 00:00:00 2001
From: Tyler Roach 
Date: Tue, 3 Nov 2026 10:40:21 -0500
Subject: [PATCH 08/17] feat(analytics): restore the company profile on
 for_company clients

Brings back the company profile methods in place of inferring the company from $company_id
group writes.

Co-Authored-By: Claude Opus 5.5 
---
 .../mixpanel/mixpanelapi/MessageBuilder.java  | 226 +++++++++++++++---
 .../HierarchicalGroupsScenariosTest.java      |  10 +-
 .../mixpanelapi/HierarchicalGroupsTest.java   | 168 ++++++-------
 3 files changed, 276 insertions(+), 128 deletions(-)

diff --git a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java
index e2096ec..45e8d47 100644
--- a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java
+++ b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java
@@ -59,8 +59,7 @@ public MessageBuilder(String token) {
      * only a distinct id of the form {@code $company:{company}|$user:{user}}, send it to
      * {@code /groups} as that user's profile within the company, and throw
      * {@link IllegalArgumentException} for any other distinct id. Group profile messages require a
-     * known company, either through {@link #forCompany(String)} or a {@code $company_id} modifier;
-     * a {@code $company_id} group profile is its own company and needs neither.
+     * known company, either through {@link #forCompany(String)} or a {@code $company_id} modifier.
      *
      * @param token the Mixpanel project token
      * @param hierarchicalGroups true if the project uses hierarchical groups
@@ -78,12 +77,13 @@ private MessageBuilder(String token, boolean hierarchicalGroups, String companyI
     /**
      * Returns a MessageBuilder that adds {@code $company_id} to every group profile
      * message it builds. Hierarchical group projects require {@code $company_id} on
-     * every {@code /groups} write, including user profiles. The company's own profile needs no
-     * binding, since its group id is the company:
+     * every {@code /groups} write, including the company's own profile and user profiles. The
+     * company methods, such as {@link #companySet(JSONObject)}, build messages for the bound
+     * company's own profile:
      * 
      * {@code
-     *     messageBuilder.groupSet("$company_id", "acme", companyProperties);
      *     MessageBuilder acme = messageBuilder.forCompany("acme");
+     *     acme.companySet(companyProperties);
      *     acme.groupSet("$user_id", "user_123", userProperties);
      *     acme.groupSet("department", "finance", departmentProperties);
      * }
@@ -740,7 +740,7 @@ private JSONObject profileMessage(MixpanelIdentity identity, String actionType,
                         + "Device ids identify anonymous events, not profiles.");
             }
             throw new IllegalArgumentException("User profile updates require a user id, and " + identity
-                    + " has none. Use groupSet(\"$company_id\", companyId, properties) to update the company's "
+                    + " has none. Use forCompany(companyId).companySet(properties) to update the company's "
                     + "profile.");
         }
         return buildGroupMessage(USER_ID_GROUP_KEY, identity.getUserId(), actionType, properties, modifiers,
@@ -1126,6 +1126,194 @@ public JSONObject groupUnset(String groupKey, String groupId, Collection
         return groupMessage(groupKey, groupId, "$unset", propNamesArray, modifiers);
     }
 
+    /**
+     * Sets properties on the profile of the company bound with {@link #forCompany(String)},
+     * creating the profile if needed. Existing values for the given properties are replaced.
+     * Builds the same message as {@code groupSet("$company_id", companyId, properties)} on this builder.
+     *
+     * @param properties a collection of properties to set on the company profile. Each key
+     *            in the properties argument will be updated on the profile.
+     * @return company profile set message for consumption by MixpanelAPI
+     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
+     */
+    public JSONObject companySet(JSONObject properties) {
+        return companySet(properties, null);
+    }
+
+    /**
+     * Sets properties on the profile of the company bound with {@link #forCompany(String)},
+     * creating the profile if needed. Existing values for the given properties are replaced.
+     * Builds the same message as {@code groupSet("$company_id", companyId, properties, modifiers)}
+     * on this builder.
+     *
+     * @param properties a collection of properties to set on the company profile. Each key
+     *            in the properties argument will be updated on the profile.
+     * @param modifiers Modifiers associated with the update message. (for example "$time" or "$ignore_time").
+     *            this can be null- if non-null, the keys and values in the modifiers
+     *            object will be associated directly with the update.
+     * @return company profile set message for consumption by MixpanelAPI
+     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
+     */
+    public JSONObject companySet(JSONObject properties, JSONObject modifiers) {
+        return groupSet("$company_id", requireBoundCompanyId("companySet"), properties, modifiers);
+    }
+
+    /**
+     * Sets properties if they do not already exist on the profile of the company bound with
+     * {@link #forCompany(String)}.
+     * Builds the same message as {@code groupSetOnce("$company_id", companyId, properties)} on this builder.
+     *
+     * @param properties a collection of properties to set on the company profile. Each key
+     *            in the properties argument will be updated on the profile.
+     * @return company profile setOnce message for consumption by MixpanelAPI
+     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
+     */
+    public JSONObject companySetOnce(JSONObject properties) {
+        return companySetOnce(properties, null);
+    }
+
+    /**
+     * Sets properties if they do not already exist on the profile of the company bound with
+     * {@link #forCompany(String)}.
+     * Builds the same message as {@code groupSetOnce("$company_id", companyId, properties, modifiers)}
+     * on this builder.
+     *
+     * @param properties a collection of properties to set on the company profile. Each key
+     *            in the properties argument will be updated on the profile.
+     * @param modifiers Modifiers associated with the update message. (for example "$time" or "$ignore_time").
+     *            this can be null- if non-null, the keys and values in the modifiers
+     *            object will be associated directly with the update.
+     * @return company profile setOnce message for consumption by MixpanelAPI
+     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
+     */
+    public JSONObject companySetOnce(JSONObject properties, JSONObject modifiers) {
+        return groupSetOnce("$company_id", requireBoundCompanyId("companySetOnce"), properties, modifiers);
+    }
+
+    /**
+     * Deletes the profile of the company bound with {@link #forCompany(String)}.
+     * Builds the same message as {@code groupDelete("$company_id", companyId)} on this builder.
+     *
+     * @return company profile delete message for consumption by MixpanelAPI
+     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
+     */
+    public JSONObject companyDelete() {
+        return companyDelete(null);
+    }
+
+    /**
+     * Deletes the profile of the company bound with {@link #forCompany(String)}.
+     * Builds the same message as {@code groupDelete("$company_id", companyId, modifiers)} on this builder.
+     *
+     * @param modifiers Modifiers associated with the update message. (for example "$time" or "$ignore_time").
+     *            this can be null- if non-null, the keys and values in the modifiers
+     *            object will be associated directly with the update.
+     * @return company profile delete message for consumption by MixpanelAPI
+     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
+     */
+    public JSONObject companyDelete(JSONObject modifiers) {
+        return groupDelete("$company_id", requireBoundCompanyId("companyDelete"), modifiers);
+    }
+
+    /**
+     * For each key and value in the properties argument, attempts to remove that value from a list
+     * associated with the key in the profile of the company bound with {@link #forCompany(String)}.
+     * Builds the same message as {@code groupRemove("$company_id", companyId, properties)} on this builder.
+     *
+     * @param properties properties for the remove operation
+     * @return company profile remove message for consumption by MixpanelAPI
+     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
+     */
+    public JSONObject companyRemove(JSONObject properties) {
+        return companyRemove(properties, null);
+    }
+
+    /**
+     * For each key and value in the properties argument, attempts to remove that value from a list
+     * associated with the key in the profile of the company bound with {@link #forCompany(String)}.
+     * Builds the same message as {@code groupRemove("$company_id", companyId, properties, modifiers)}
+     * on this builder.
+     *
+     * @param properties properties for the remove operation
+     * @param modifiers Modifiers associated with the update message. (for example "$time" or "$ignore_time").
+     *            this can be null- if non-null, the keys and values in the modifiers
+     *            object will be associated directly with the update.
+     * @return company profile remove message for consumption by MixpanelAPI
+     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
+     */
+    public JSONObject companyRemove(JSONObject properties, JSONObject modifiers) {
+        return groupRemove("$company_id", requireBoundCompanyId("companyRemove"), properties, modifiers);
+    }
+
+    /**
+     * Merges list-valued properties into the profile of the company bound with
+     * {@link #forCompany(String)}, ignoring duplicate list values.
+     * Builds the same message as {@code groupUnion("$company_id", companyId, properties)} on this builder.
+     *
+     * @param properties properties for the union operation
+     * @return company profile union message for consumption by MixpanelAPI
+     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
+     */
+    public JSONObject companyUnion(Map properties) {
+        return companyUnion(properties, null);
+    }
+
+    /**
+     * Merges list-valued properties into the profile of the company bound with
+     * {@link #forCompany(String)}, ignoring duplicate list values.
+     * Builds the same message as {@code groupUnion("$company_id", companyId, properties, modifiers)}
+     * on this builder.
+     *
+     * @param properties properties for the union operation
+     * @param modifiers Modifiers associated with the update message. (for example "$time" or "$ignore_time").
+     *            this can be null- if non-null, the keys and values in the modifiers
+     *            object will be associated directly with the update.
+     * @return company profile union message for consumption by MixpanelAPI
+     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
+     */
+    public JSONObject companyUnion(Map properties, JSONObject modifiers) {
+        return groupUnion("$company_id", requireBoundCompanyId("companyUnion"), properties, modifiers);
+    }
+
+    /**
+     * Removes the properties named in propertyNames from the profile of the company bound with
+     * {@link #forCompany(String)}.
+     * Builds the same message as {@code groupUnset("$company_id", companyId, propertyNames)} on this builder.
+     *
+     * @param propertyNames properties for the unset operation
+     * @return company profile unset message for consumption by MixpanelAPI
+     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
+     */
+    public JSONObject companyUnset(Collection propertyNames) {
+        return companyUnset(propertyNames, null);
+    }
+
+    /**
+     * Removes the properties named in propertyNames from the profile of the company bound with
+     * {@link #forCompany(String)}.
+     * Builds the same message as {@code groupUnset("$company_id", companyId, propertyNames, modifiers)}
+     * on this builder.
+     *
+     * @param propertyNames properties for the unset operation
+     * @param modifiers Modifiers associated with the update message. (for example "$time" or "$ignore_time").
+     *            this can be null- if non-null, the keys and values in the modifiers
+     *            object will be associated directly with the update.
+     * @return company profile unset message for consumption by MixpanelAPI
+     * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)}
+     */
+    public JSONObject companyUnset(Collection propertyNames, JSONObject modifiers) {
+        return groupUnset("$company_id", requireBoundCompanyId("companyUnset"), propertyNames, modifiers);
+    }
+
+    private String requireBoundCompanyId(String method) {
+        if (mCompanyId == null) {
+            throw new IllegalStateException(method + " builds a message for the profile of the company bound with "
+                    + "forCompany, and this builder has none. Use forCompany(companyId)." + method
+                    + ". Nothing to send");
+        }
+        return mCompanyId;
+    }
+
     /**
      * Formats a generic group profile message.
      * Use of this method requires familiarity with the underlying Mixpanel HTTP API,
@@ -1146,10 +1334,8 @@ public JSONObject groupUnset(String groupKey, String groupId, Collection
      *           be merged as modifiers associated with the update message (for example, "$time" or "$ignore_time")
      * @return generic group profile message for consumption by MixpanelAPI
      *
-     * @throws IllegalArgumentException if properties is not intelligible as a JSONObject property, or, for
-     *           hierarchical groups projects, when a {@code $company_id} group profile is given a different company
-     * @throws IllegalStateException for hierarchical groups projects when no company is known. A
-     *           {@code $company_id} group profile is its own company.
+     * @throws IllegalArgumentException if properties is not intelligible as a JSONObject property
+     * @throws IllegalStateException for hierarchical groups projects when no company is known
      * @throws UnsupportedOperationException on a hierarchical groups builder, for an operation /groups lacks
      *           ($add, $append or $merge)
      *
@@ -1170,25 +1356,7 @@ public JSONObject groupMessage(String groupKey, String groupId, String actionTyp
 
     private JSONObject buildGroupMessage(String groupKey, String groupId, String actionType, Object properties,
                                          JSONObject modifiers, String companyId) {
-        boolean hasCompanyModifier = modifiers != null && modifiers.has("$company_id");
-        if (mHierarchicalGroups && "$company_id".equals(groupKey)) {
-            // The company's own profile: its group id is the company
-            Object conflicting = null;
-            if (companyId != null && !companyId.equals(groupId)) {
-                conflicting = companyId;
-            } else if (hasCompanyModifier && (groupId == null || !groupId.equals(modifiers.opt("$company_id")))) {
-                conflicting = modifiers.opt("$company_id");
-            }
-            if (conflicting != null) {
-                throw new IllegalArgumentException("The company profile's company is its own id, so group "
-                        + groupKey + "=" + groupId + " cannot be updated for company " + conflicting
-                        + ". Nothing to send.");
-            }
-            if (companyId == null && !hasCompanyModifier && groupId != null && !groupId.isEmpty()) {
-                companyId = groupId;
-            }
-        }
-        if (mHierarchicalGroups && companyId == null && !hasCompanyModifier) {
+        if (mHierarchicalGroups && companyId == null && (modifiers == null || !modifiers.has("$company_id"))) {
             throw new IllegalStateException("Group profile updates for hierarchical groups projects require a "
                     + "company. Use forCompany(companyId) to build them. Nothing to send for group "
                     + groupKey + "=" + groupId);
diff --git a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsScenariosTest.java b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsScenariosTest.java
index d15704a..75680a2 100644
--- a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsScenariosTest.java
+++ b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsScenariosTest.java
@@ -575,13 +575,13 @@ public void testSetUserPropertyWithoutUserIsUnsupported() {
         assertNothingSent();
     }
 
-    // Set Company Property Without Company (the company profile is its own company)
+    // Set Company Property Without Company (Unsupported)
 
     @Test
-    public void testSetCompanyPropertyWithoutCompany() {
-        send(mBuilder.groupSet("$company_id", COMPANY_ID, json("tier", "enterprise")));
-
-        assertJsonEquals(json("tier", "enterprise"), onlyCompanyUpdate("$set"));
+    public void testSetCompanyPropertyWithoutCompanyIsUnsupported() {
+        assertThrows(IllegalStateException.class,
+                () -> mBuilder.groupSet("$company_id", COMPANY_ID, json("tier", "enterprise")));
+        assertNothingSent();
     }
 
     // Set Custom Group Property Without Company (Unsupported)
diff --git a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java
index 077ced1..1e8af45 100644
--- a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java
+++ b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java
@@ -6,12 +6,12 @@
 import org.json.JSONObject;
 import org.junit.Before;
 import org.junit.Test;
-import org.junit.function.ThrowingRunnable;
 
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.function.Function;
 
 import static org.junit.Assert.*;
 
@@ -478,7 +478,7 @@ public void testCompanyOnlyIdentityProfileUpdateIsRejected() {
         IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
                 () -> hgBuilder.set(MixpanelIdentity.builder().companyId("acme").build(), new JSONObject().put("tier", "ent")));
 
-        assertTrue(e.getMessage(), e.getMessage().contains("groupSet"));
+        assertTrue(e.getMessage(), e.getMessage().contains("companySet"));
     }
 
     @Test
@@ -544,98 +544,6 @@ public void testHierarchicalGroupWithoutCompanyIsRejected() {
         assertTrue(e.getMessage(), e.getMessage().contains("forCompany"));
     }
 
-    private static JSONObject[] companyProfileMessages(MessageBuilder builder, String companyId, JSONObject modifiers) {
-        JSONObject props = new JSONObject().put("plan", "enterprise");
-        return new JSONObject[] {
-            builder.groupSet("$company_id", companyId, props, modifiers),
-            builder.groupSetOnce("$company_id", companyId, props, modifiers),
-            builder.groupDelete("$company_id", companyId, modifiers),
-            builder.groupRemove("$company_id", companyId, props, modifiers),
-            builder.groupUnion("$company_id", companyId, Collections.singletonMap("tags", new JSONArray().put("a")),
-                    modifiers),
-            builder.groupUnset("$company_id", companyId, Arrays.asList("plan"), modifiers),
-            builder.groupMessage("$company_id", companyId, "$set", props, modifiers),
-        };
-    }
-
-    private static void assertCompanyProfileMessages(JSONObject[] envelopes) {
-        assertEquals(7, envelopes.length);
-        for (JSONObject envelope : envelopes) {
-            JSONObject message = envelope.getJSONObject("message");
-            assertEquals("$company_id", message.getString("$group_key"));
-            assertEquals("acme", message.getString("$group_id"));
-            assertEquals("acme", message.getString("$company_id"));
-        }
-    }
-
-    private static void assertEveryCompanyProfileOperationThrows(Class expected,
-                                                                 MessageBuilder builder, String companyId,
-                                                                 JSONObject modifiers, String messageText) {
-        JSONObject props = new JSONObject().put("plan", "enterprise");
-        List calls = Arrays.asList(
-            () -> builder.groupSet("$company_id", companyId, props, modifiers),
-            () -> builder.groupSetOnce("$company_id", companyId, props, modifiers),
-            () -> builder.groupDelete("$company_id", companyId, modifiers),
-            () -> builder.groupRemove("$company_id", companyId, props, modifiers),
-            () -> builder.groupUnion("$company_id", companyId,
-                    Collections.singletonMap("tags", new JSONArray().put("a")), modifiers),
-            () -> builder.groupUnset("$company_id", companyId, Arrays.asList("plan"), modifiers),
-            () -> builder.groupMessage("$company_id", companyId, "$set", props, modifiers));
-        for (ThrowingRunnable call : calls) {
-            Throwable e = assertThrows(expected, call);
-            assertTrue(e.getMessage(), e.getMessage().contains(messageText));
-        }
-    }
-
-    @Test
-    public void testCompanyProfileInfersItsCompanyFromGroupId() {
-        assertCompanyProfileMessages(companyProfileMessages(hgBuilder, "acme", null));
-    }
-
-    @Test
-    public void testCompanyProfileWithMatchingCompany() {
-        assertCompanyProfileMessages(companyProfileMessages(hgBuilder.forCompany("acme"), "acme", null));
-        assertCompanyProfileMessages(
-                companyProfileMessages(hgBuilder, "acme", new JSONObject().put("$company_id", "acme")));
-        assertCompanyProfileMessages(companyProfileMessages(hgBuilder.forCompany("acme"), "acme",
-                new JSONObject().put("$company_id", "acme")));
-    }
-
-    @Test
-    public void testCompanyProfileWithDifferentBoundCompanyIsRejected() {
-        assertEveryCompanyProfileOperationThrows(IllegalArgumentException.class, hgBuilder.forCompany("other"),
-                "acme", null, "company profile's company is its own id");
-    }
-
-    @Test
-    public void testCompanyProfileWithDifferentModifierCompanyIsRejected() {
-        assertEveryCompanyProfileOperationThrows(IllegalArgumentException.class, hgBuilder, "acme",
-                new JSONObject().put("$company_id", "other"), "company profile's company is its own id");
-        assertEveryCompanyProfileOperationThrows(IllegalArgumentException.class, hgBuilder.forCompany("acme"),
-                "acme", new JSONObject().put("$company_id", "other"), "company profile's company is its own id");
-    }
-
-    @Test
-    public void testCompanyProfileWithEmptyIdStillNeedsCompany() {
-        assertEveryCompanyProfileOperationThrows(IllegalStateException.class, hgBuilder, "", null, "forCompany");
-    }
-
-    @Test
-    public void testLegacyCompanyProfileUnchanged() {
-        JSONObject message = legacyBuilder.groupSet("$company_id", "acme", new JSONObject().put("plan", "x"))
-            .getJSONObject("message");
-        assertFalse(message.has("$company_id"));
-
-        JSONObject bound = legacyBuilder.forCompany("other")
-            .groupSet("$company_id", "acme", new JSONObject().put("plan", "x"))
-            .getJSONObject("message");
-        assertEquals("other", bound.getString("$company_id"));
-
-        JSONObject modified = legacyBuilder.groupSet("$company_id", "acme", new JSONObject().put("plan", "x"),
-                new JSONObject().put("$company_id", "other")).getJSONObject("message");
-        assertEquals("other", modified.getString("$company_id"));
-    }
-
     @Test
     public void testCompanyIdModifierIsAccepted() {
         JSONObject modifiers = new JSONObject().put("$company_id", "acme");
@@ -714,6 +622,78 @@ public void testSupportedGroupOperationsBuild() {
         assertNotNull(acme.groupDelete("department", "finance"));
     }
 
+    /** A company method call, followed by the equivalent group method call with the company named twice. */
+    private static List> companyAndGroupCalls(JSONObject modifiers) {
+        JSONObject props = new JSONObject().put("plan", "enterprise");
+        Map union = Collections.singletonMap("tags", new JSONArray().put("a"));
+        List unset = Arrays.asList("plan");
+        if (modifiers == null) {
+            return Arrays.asList(
+                b -> b.companySet(props), b -> b.groupSet("$company_id", "acme", props),
+                b -> b.companySetOnce(props), b -> b.groupSetOnce("$company_id", "acme", props),
+                b -> b.companyDelete(), b -> b.groupDelete("$company_id", "acme"),
+                b -> b.companyRemove(props), b -> b.groupRemove("$company_id", "acme", props),
+                b -> b.companyUnion(union), b -> b.groupUnion("$company_id", "acme", union),
+                b -> b.companyUnset(unset), b -> b.groupUnset("$company_id", "acme", unset));
+        }
+        return Arrays.asList(
+            b -> b.companySet(props, modifiers), b -> b.groupSet("$company_id", "acme", props, modifiers),
+            b -> b.companySetOnce(props, modifiers), b -> b.groupSetOnce("$company_id", "acme", props, modifiers),
+            b -> b.companyDelete(modifiers), b -> b.groupDelete("$company_id", "acme", modifiers),
+            b -> b.companyRemove(props, modifiers), b -> b.groupRemove("$company_id", "acme", props, modifiers),
+            b -> b.companyUnion(union, modifiers), b -> b.groupUnion("$company_id", "acme", union, modifiers),
+            b -> b.companyUnset(unset, modifiers), b -> b.groupUnset("$company_id", "acme", unset, modifiers));
+    }
+
+    private static void assertCompanyMethodsMatchGroupMethods(MessageBuilder acme, JSONObject modifiers) {
+        List> calls = companyAndGroupCalls(modifiers);
+        for (int i = 0; i < calls.size(); i += 2) {
+            JSONObject company = calls.get(i).apply(acme);
+            JSONObject group = calls.get(i + 1).apply(acme);
+            if (modifiers == null) {
+                // $time is the build time, so it can differ between the two calls.
+                company.getJSONObject("message").remove("$time");
+                group.getJSONObject("message").remove("$time");
+            }
+            assertEquals(group.toString(), company.toString());
+
+            JSONObject message = company.getJSONObject("message");
+            assertEquals("$company_id", message.getString("$group_key"));
+            assertEquals("acme", message.getString("$group_id"));
+            assertEquals("acme", message.getString("$company_id"));
+        }
+    }
+
+    @Test
+    public void testCompanyMethodsMatchGroupMethodsWithBoundCompany() {
+        assertCompanyMethodsMatchGroupMethods(hgBuilder.forCompany("acme"), null);
+        assertCompanyMethodsMatchGroupMethods(legacyBuilder.forCompany("acme"), null);
+    }
+
+    @Test
+    public void testCompanyMethodsWithModifiersMatchGroupMethodsWithBoundCompany() {
+        JSONObject modifiers = new JSONObject().put("$time", 1234567890L).put("$ignore_time", true);
+        assertCompanyMethodsMatchGroupMethods(hgBuilder.forCompany("acme"), modifiers);
+        assertCompanyMethodsMatchGroupMethods(legacyBuilder.forCompany("acme"), modifiers);
+    }
+
+    @Test
+    public void testCompanyMethodsWithoutBoundCompanyAreRejected() {
+        String[] names = {"companySet", "companySetOnce", "companyDelete", "companyRemove", "companyUnion",
+            "companyUnset"};
+        for (JSONObject modifiers : Arrays.asList(null, new JSONObject().put("$company_id", "acme"))) {
+            List> calls = companyAndGroupCalls(modifiers);
+            for (MessageBuilder unbound : Arrays.asList(hgBuilder, legacyBuilder)) {
+                for (int i = 0; i < calls.size(); i += 2) {
+                    Function call = calls.get(i);
+                    IllegalStateException e = assertThrows(IllegalStateException.class, () -> call.apply(unbound));
+                    assertTrue(e.getMessage(), e.getMessage().contains("forCompany"));
+                    assertTrue(e.getMessage(), e.getMessage().startsWith(names[i / 2]));
+                }
+            }
+        }
+    }
+
     // #endregion
     // #region Flag context
 

From 651f0d94628dc83bb87decdc59379f0a850912ff Mon Sep 17 00:00:00 2001
From: Tyler Roach 
Date: Tue, 3 Nov 2026 16:33:22 -0500
Subject: [PATCH 09/17] feat(analytics): refuse a user from another company on
 a forCompany builder

A people message from a MessageBuilder bound with forCompany used the
identity's company and ignored the bound one, so a mismatched identity
silently built an update of the other company's user. It now throws
IllegalArgumentException.

Co-Authored-By: Claude Opus 5.5 
---
 .../mixpanel/mixpanelapi/MessageBuilder.java  | 18 +++++++++++++++
 .../mixpanelapi/HierarchicalGroupsTest.java   | 22 ++++++++++++++-----
 2 files changed, 34 insertions(+), 6 deletions(-)

diff --git a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java
index 45e8d47..81f4360 100644
--- a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java
+++ b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java
@@ -89,6 +89,10 @@ private MessageBuilder(String token, boolean hierarchicalGroups, String companyI
      * }
      * 
* + * User profile updates made with a {@link MixpanelIdentity} use the identity's own company and do + * not need a bound company; a bound builder throws {@link IllegalArgumentException} for a user + * from another company. + * * @param companyId the id of the top-level company * @return a new MessageBuilder bound to the given company */ @@ -743,6 +747,7 @@ private JSONObject profileMessage(MixpanelIdentity identity, String actionType, + " has none. Use forCompany(companyId).companySet(properties) to update the company's " + "profile."); } + requireBoundCompany(identity.getCompanyId(), identity.toString()); return buildGroupMessage(USER_ID_GROUP_KEY, identity.getUserId(), actionType, properties, modifiers, identity.getCompanyId()); } @@ -900,10 +905,23 @@ private JSONObject hierarchicalUserProfileMessage(String distinctId, String acti + "MixpanelIdentity.builder().userId(userId).companyId(companyId).build(), or a distinct id " + "of the form $company:{company}|$user:{user}."); } + requireBoundCompany(identity.getCompanyId(), "\"" + distinctId + "\""); return buildGroupMessage(USER_ID_GROUP_KEY, identity.getUserId(), actionType, properties, groupModifiers, identity.getCompanyId()); } + /** + * A builder bound with {@link #forCompany(String)} refuses a user from another company rather + * than silently building an update of that company's user profile. + */ + private void requireBoundCompany(String companyId, String described) { + if (companyId != null && mCompanyId != null && !companyId.equals(mCompanyId)) { + throw new IllegalArgumentException("This builder is bound to company \"" + mCompanyId + "\", but " + + described + " belongs to company \"" + companyId + "\". Update the user through a builder " + + "for that company, or one without forCompany."); + } + } + /** * Sets properties on the group profile identified by the given groupKey * and groupId, creating the profile if needed. Existing values for the diff --git a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java index 1e8af45..937b870 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java @@ -313,11 +313,11 @@ public void testCombinedDistinctIdSplitsOnFirstUserSeparator() { } @Test - public void testCombinedDistinctIdCompanyWinsOverForCompany() { - JSONObject envelope = hgBuilder.forCompany("other").set("$company:acme|$user:user_123", - new JSONObject().put("a", 1)); + public void testForCompanyRefusesACombinedDistinctIdFromAnotherCompany() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> hgBuilder.forCompany("other").set("$company:acme|$user:user_123", new JSONObject().put("a", 1))); - assertUserGroupMessage(envelope, "acme", "user_123"); + assertTrue(e.getMessage().contains("belongs to company \"acme\"")); } @Test @@ -449,13 +449,23 @@ public void testHierarchicalIdentityRequiresHierarchicalBuilder() { } @Test - public void testIdentityCompanyWinsOverForCompany() { - JSONObject envelope = hgBuilder.forCompany("other") + public void testForCompanyAcceptsAUserFromTheBoundCompany() { + JSONObject envelope = hgBuilder.forCompany("acme") .set(MixpanelIdentity.builder().userId("user_123").companyId("acme").build(), new JSONObject().put("role", "admin")); assertEquals("acme", envelope.getJSONObject("message").getString("$company_id")); } + @Test + public void testForCompanyRefusesAUserFromAnotherCompany() { + MixpanelIdentity user = MixpanelIdentity.builder().userId("user_123").companyId("acme").build(); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> hgBuilder.forCompany("other").set(user, new JSONObject().put("role", "admin"))); + + assertTrue(e.getMessage().contains("bound to company \"other\"")); + } + @Test public void testLegacyIdentityUsesEngage() { JSONObject envelope = legacyBuilder.set(MixpanelIdentity.builder().distinctId("user_123").build(), new JSONObject().put("role", "admin")); From a635c143628f8cb7f0fea918efd56d6ac5c04c5b Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Tue, 3 Nov 2026 16:46:38 -0500 Subject: [PATCH 10/17] feat(analytics): send a user without a company as given on hierarchical groups An identity with a user id but no company or device id threw on every call. It is valid to send, like a plain distinct id, so events now send it as given. User profile updates still need the company and fail with the existing company-required error. Co-Authored-By: Claude Opus 5.5 --- .../com/mixpanel/mixpanelapi/MessageBuilder.java | 10 ---------- .../com/mixpanel/mixpanelapi/MixpanelIdentity.java | 7 ++++--- .../HierarchicalGroupsScenariosTest.java | 13 ++++++++----- .../mixpanelapi/HierarchicalGroupsTest.java | 9 +++++---- 4 files changed, 17 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java index 81f4360..4c1a24a 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java +++ b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java @@ -205,16 +205,6 @@ private void requireIdentityMatchesProject(MixpanelIdentity identity) { throw new IllegalArgumentException(identity + " requires a hierarchical groups MessageBuilder: " + "new MessageBuilder(token, true)"); } - // The company is part of a hierarchical user's identity, not a one-time association. A - // $user_id sent without one resolves to a separate user that nothing reconciles later. - // A device id rescues that case: Mixpanel ignores the user id, keeps the event under the - // device, and links it to the company-scoped user once a later event carries all three. - if (mHierarchicalGroups && identity.getUserId() != null && identity.getCompanyId() == null - && identity.getDeviceId() == null) { - throw new IllegalArgumentException(identity + " needs a company on a hierarchical groups project. " - + "A user id sent without one, and without a device id, names a separate user that cannot " - + "be merged afterwards."); - } } private static JSONObject withIdentity(MixpanelIdentity identity, JSONObject properties) { diff --git a/src/main/java/com/mixpanel/mixpanelapi/MixpanelIdentity.java b/src/main/java/com/mixpanel/mixpanelapi/MixpanelIdentity.java index 1ef6939..da7d365 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/MixpanelIdentity.java +++ b/src/main/java/com/mixpanel/mixpanelapi/MixpanelIdentity.java @@ -22,9 +22,10 @@ * * Which combinations are valid depends on the project, so {@link MessageBuilder} rather than * this type decides. On a hierarchical groups project the company is part of the user's - * identity: a {@code $user_id} sent without a {@code $company_id} resolves to a different - * user that nothing reconciles later, so the builder rejects it. A user who belongs to two - * companies is two users, by design. + * identity: a {@code $user_id} sent without a {@code $company_id} resolves to a different user + * that nothing reconciles later, so include the company whenever the user has one. Events still + * send a user id without one as given; user profile updates need the company. A user who + * belongs to two companies is two users, by design. * * Instances are immutable. */ diff --git a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsScenariosTest.java b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsScenariosTest.java index 75680a2..2f7b200 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsScenariosTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsScenariosTest.java @@ -553,16 +553,19 @@ public void testDeleteCustomGroup() { assertJsonEquals(new JSONObject(), onlyCustomGroupUpdate("$delete")); } - // Identify Without Company (Unsupported) + // Identify Without Company @Test - public void testIdentifyWithoutCompanyIsUnsupported() { + public void testIdentifyWithoutCompany() { MixpanelIdentity userOnly = MixpanelIdentity.builder().userId(USER_ID).build(); - assertThrows(IllegalArgumentException.class, () -> mBuilder.event(userOnly, "Signup", null)); - assertThrows(IllegalArgumentException.class, () -> mBuilder.importEvent(userOnly, "Signup", null)); - assertThrows(IllegalArgumentException.class, () -> mBuilder.set(userOnly, json("plan", "pro"))); + // The event is sent as given; a user profile needs the company. + assertThrows(IllegalStateException.class, () -> mBuilder.set(userOnly, json("plan", "pro"))); assertNothingSent(); + + send(mBuilder.event(userOnly, "Signup", null)); + JSONObject props = onlyTrack("Signup", "$user_id"); + assertEquals(USER_ID, props.getString("$user_id")); } // Set User Property Without User (Unsupported) diff --git a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java index 937b870..c9dc35e 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java @@ -134,11 +134,12 @@ public void testUserAndDeviceWithoutCompanyIsValidForSimplifiedIdMerge() { } @Test - public void testUserWithoutCompanyOrDeviceIsRejectedForHierarchicalGroups() { - IllegalArgumentException e = assertThrows(IllegalArgumentException.class, - () -> hgBuilder.event(MixpanelIdentity.builder().userId("user_123").build(), "Signup", null)); + public void testUserWithoutCompanyIsSentAsGivenForHierarchicalGroups() { + JSONObject props = eventProperties(hgBuilder.event( + MixpanelIdentity.builder().userId("user_123").build(), "Signup", null)); - assertTrue(e.getMessage(), e.getMessage().contains("cannot be merged")); + assertEquals("user_123", props.getString("$user_id")); + assertFalse(props.has("$company_id")); } @Test From 9664c612686231f637bf73e1038478dcfb58f739 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Wed, 4 Nov 2026 13:15:54 -0500 Subject: [PATCH 11/17] fix(analytics): keep a user-only profile update within the bound company On a forCompany builder, a user profile update for a user-only identity ignored the bound company: a $company_id modifier chose which company's user profile was written, and without one it threw the company-required error. The identity's company, then the bound company, now decides, and either wins over a $company_id modifier. Without both, the modifier still supplies the company. An empty or JSONObject.NULL $company_id modifier no longer counts as a company. Co-Authored-By: Claude Opus 5.5 --- .../mixpanel/mixpanelapi/MessageBuilder.java | 20 +++++++-- .../mixpanelapi/HierarchicalGroupsTest.java | 41 +++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java index 4c1a24a..001a303 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java +++ b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java @@ -91,7 +91,7 @@ private MessageBuilder(String token, boolean hierarchicalGroups, String companyI * * User profile updates made with a {@link MixpanelIdentity} use the identity's own company and do * not need a bound company; a bound builder throws {@link IllegalArgumentException} for a user - * from another company. + * from another company, and updates a user-only identity's profile within the bound company. * * @param companyId the id of the top-level company * @return a new MessageBuilder bound to the given company @@ -738,8 +738,10 @@ private JSONObject profileMessage(MixpanelIdentity identity, String actionType, + "profile."); } requireBoundCompany(identity.getCompanyId(), identity.toString()); + // A user-only identity takes the bound company, which then wins over a $company_id modifier. + String companyId = identity.getCompanyId() != null ? identity.getCompanyId() : mCompanyId; return buildGroupMessage(USER_ID_GROUP_KEY, identity.getUserId(), actionType, properties, modifiers, - identity.getCompanyId()); + companyId); } /** @@ -1364,7 +1366,7 @@ public JSONObject groupMessage(String groupKey, String groupId, String actionTyp private JSONObject buildGroupMessage(String groupKey, String groupId, String actionType, Object properties, JSONObject modifiers, String companyId) { - if (mHierarchicalGroups && companyId == null && (modifiers == null || !modifiers.has("$company_id"))) { + if (mHierarchicalGroups && companyId == null && !hasCompanyIdModifier(modifiers)) { throw new IllegalStateException("Group profile updates for hierarchical groups projects require a " + "company. Use forCompany(companyId) to build them. Nothing to send for group " + groupKey + "=" + groupId); @@ -1408,4 +1410,16 @@ private JSONObject buildGroupMessage(String groupKey, String groupId, String act } } + /** + * An empty or JSONObject.NULL {@code $company_id} modifier names no company, just as + * {@link #forCompany(String)} rejects a null or empty id. + */ + private static boolean hasCompanyIdModifier(JSONObject modifiers) { + if (modifiers == null) { + return false; + } + Object companyId = modifiers.opt("$company_id"); + return companyId != null && !JSONObject.NULL.equals(companyId) && !"".equals(companyId); + } + } diff --git a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java index c9dc35e..38f2691 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java @@ -467,6 +467,47 @@ public void testForCompanyRefusesAUserFromAnotherCompany() { assertTrue(e.getMessage().contains("bound to company \"other\"")); } + @Test + public void testForCompanyUpdatesAUserOnlyIdentityWithinTheBoundCompany() { + MixpanelIdentity userOnly = MixpanelIdentity.builder().userId("user_123").build(); + MessageBuilder acme = hgBuilder.forCompany("acme"); + JSONObject props = new JSONObject().put("role", "admin"); + + assertUserGroupMessage(acme.set(userOnly, props), "acme", "user_123"); + assertUserGroupMessage(acme.set(userOnly, props, new JSONObject().put("$company_id", "globex")), + "acme", "user_123"); + } + + @Test + public void testUserOnlyIdentityUsesCompanyIdModifierWithoutBoundCompany() { + MixpanelIdentity userOnly = MixpanelIdentity.builder().userId("user_123").build(); + JSONObject props = new JSONObject().put("role", "admin"); + + assertUserGroupMessage(hgBuilder.set(userOnly, props, new JSONObject().put("$company_id", "acme")), + "acme", "user_123"); + assertThrows(IllegalStateException.class, () -> hgBuilder.set(userOnly, props)); + } + + @Test + public void testIdentityCompanyWinsOverCompanyIdModifier() { + MixpanelIdentity user = MixpanelIdentity.builder().userId("user_123").companyId("acme").build(); + + assertUserGroupMessage(hgBuilder.set(user, new JSONObject().put("role", "admin"), + new JSONObject().put("$company_id", "globex")), "acme", "user_123"); + } + + @Test + public void testEmptyCompanyIdModifierIsNotACompany() { + MixpanelIdentity userOnly = MixpanelIdentity.builder().userId("user_123").build(); + JSONObject props = new JSONObject().put("role", "admin"); + + for (Object empty : Arrays.asList("", JSONObject.NULL)) { + JSONObject modifiers = new JSONObject().put("$company_id", empty); + assertThrows(IllegalStateException.class, () -> hgBuilder.set(userOnly, props, modifiers)); + assertThrows(IllegalStateException.class, () -> hgBuilder.groupSet("department", "finance", props, modifiers)); + } + } + @Test public void testLegacyIdentityUsesEngage() { JSONObject envelope = legacyBuilder.set(MixpanelIdentity.builder().distinctId("user_123").build(), new JSONObject().put("role", "admin")); From ee93df0ec338d186423103c4e6d19db5899f63cb Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Wed, 4 Nov 2026 14:13:42 -0500 Subject: [PATCH 12/17] fix(analytics): drop identity keys the identity leaves unset on identity events A distinct_id, $user_id, $company_id or $device_id in the passed properties no longer survives when the identity leaves that field unset, so an event never carries a stale id alongside the identity's own. Co-Authored-By: Claude Opus 5.5 --- .../mixpanel/mixpanelapi/MessageBuilder.java | 4 +++- .../mixpanel/mixpanelapi/MixpanelIdentity.java | 12 +++++++----- .../mixpanelapi/HierarchicalGroupsTest.java | 18 ++++++++++++++++++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java index 001a303..8fb0778 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java +++ b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java @@ -164,7 +164,9 @@ public JSONObject event(String distinctId, String eventName, JSONObject properti * Creates a message tracking an event for the given identity. In hierarchical * group mode the message carries {@code $user_id} and {@code $company_id} instead * of {@code distinct_id}; Mixpanel constructs the distinct id from them. - * Identity keys replace any values for the same keys in properties. + * The identity decides {@code distinct_id}, {@code $user_id}, {@code $company_id} + * and {@code $device_id}: it replaces any values for those keys in properties, + * and drops the keys it leaves unset. * * @param identity who the event belongs to * @param eventName a human readable name for the event diff --git a/src/main/java/com/mixpanel/mixpanelapi/MixpanelIdentity.java b/src/main/java/com/mixpanel/mixpanelapi/MixpanelIdentity.java index da7d365..7aac2a9 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/MixpanelIdentity.java +++ b/src/main/java/com/mixpanel/mixpanelapi/MixpanelIdentity.java @@ -164,13 +164,15 @@ public boolean isHierarchical() { /** * Writes this identity's keys into the given event properties, replacing any - * caller-supplied values for the same keys. + * caller-supplied values for the same keys. A key this identity leaves unset is + * removed, since JSONObject.put with a null value removes the key, so the + * properties never carry a stale id. */ /* package */ void applyTo(JSONObject properties) throws JSONException { - if (mDistinctId != null) properties.put("distinct_id", mDistinctId); - if (mUserId != null) properties.put("$user_id", mUserId); - if (mCompanyId != null) properties.put("$company_id", mCompanyId); - if (mDeviceId != null) properties.put("$device_id", mDeviceId); + properties.put("distinct_id", mDistinctId); + properties.put("$user_id", mUserId); + properties.put("$company_id", mCompanyId); + properties.put("$device_id", mDeviceId); } @Override diff --git a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java index 38f2691..b062007 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java @@ -85,6 +85,24 @@ public void testIdentityOverridesCallerSuppliedKeysAndKeepsOtherProperties() { assertEquals("wrong", caller.getString("$company_id")); } + @Test + public void testIdentityDropsCallerSuppliedKeysItLeavesUnset() { + JSONObject caller = new JSONObject(); + caller.put("distinct_id", "stale"); + caller.put("$device_id", "stale_device"); + caller.put("$user_id", "stale_user"); + caller.put("plan", "enterprise"); + + JSONObject props = eventProperties(hgBuilder.event(MixpanelIdentity.builder().companyId("acme").build(), "Renewed", caller)); + + assertEquals("acme", props.getString("$company_id")); + assertFalse(props.has("distinct_id")); + assertFalse(props.has("$device_id")); + assertFalse(props.has("$user_id")); + assertEquals("enterprise", props.getString("plan")); + assertEquals("stale", caller.getString("distinct_id")); + } + @Test public void testHierarchicalImportEvent() { JSONObject envelope = hgBuilder.importEvent(MixpanelIdentity.builder().userId("user_123").companyId("acme").build(), "Signup", null); From f8291e4c732c9d9d2669d6f0097d40baf22099f0 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Wed, 4 Nov 2026 15:17:44 -0500 Subject: [PATCH 13/17] fix(flags): send exposures with an empty distinct_id as before without hierarchical groups The exposure check had started treating an empty distinct_id as missing on every project, so a non-hierarchical-groups exposure with distinct_id "" was dropped. Only a hierarchical groups provider now treats an empty value as missing. Co-Authored-By: Claude Opus 5.5 --- .../provider/BaseFlagsProvider.java | 10 ++++++---- .../provider/LocalFlagsProviderTest.java | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProvider.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProvider.java index e6b9937..8fa988a 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProvider.java @@ -212,12 +212,12 @@ public boolean isEnabled(String flagKey, Map context) { * from which Mixpanel constructs the distinct id. */ protected boolean canAttributeExposure(Map context) { - if (config.isHierarchicalGroups() && isPresent(context.get("$company_id"))) { - return true; + if (!config.isHierarchicalGroups()) { + return context.get("distinct_id") != null; } // A distinct id attributes on any project, including one Mixpanel built from a // company and user, which ingestion parses back into its parts. - return isPresent(context.get("distinct_id")); + return isPresent(context.get("$company_id")) || isPresent(context.get("distinct_id")); } private static boolean isPresent(Object value) { @@ -246,7 +246,9 @@ protected void trackExposure(Map context, String flagKey, String } } Object distinctIdObj = context.get("distinct_id"); - String distinctId = isPresent(distinctIdObj) ? distinctIdObj.toString() : null; + // Without hierarchical groups any distinct id is sent as given, as before + boolean hasDistinctId = config.isHierarchicalGroups() ? isPresent(distinctIdObj) : distinctIdObj != null; + String distinctId = hasDistinctId ? distinctIdObj.toString() : null; properties.put("Experiment name", flagKey); properties.put("Variant name", variantKey); properties.put("$experiment_type", "feature_flag"); diff --git a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java index 64c4b51..c189f09 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java @@ -1026,6 +1026,23 @@ public void close() {} } } + @Test + public void testTracksExposureForEmptyDistinctIdWithoutHierarchicalGroups() throws Exception { + List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); + List rollouts = Arrays.asList(new Rollout(1.0f)); + String response = buildFlagsResponse("device-flag", "device_id", variants, rollouts, null); + + provider = createProviderWithResponse(response); + provider.startPollingForDefinitions(); + + Map context = new HashMap<>(); + context.put("device_id", "device-abc"); + context.put("distinct_id", ""); + + assertEquals("value-a", provider.getVariantValue("device-flag", fallbackVariantValue, context)); + assertEquals(1, eventSender.getEvents().size()); + } + // #endregion // #region Polling Lifecycle Thread Safety (SDK-81) From 7a070cba2cc3bba4433a1863722b629dc9a64294 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Wed, 4 Nov 2026 16:20:45 -0500 Subject: [PATCH 14/17] fix(flags): keep the context value template regex linear Exclude "{" from the placeholder capture so a template full of "{" can't make matching slow (CodeQL polynomial regular expression). Co-Authored-By: Claude Opus 5.5 --- .../mixpanelapi/featureflags/provider/LocalFlagsProvider.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java index 35015e2..16696ad 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java @@ -233,7 +233,7 @@ private Map parseDefinitions(String jsonResponse) { } /** A {@code {key}} placeholder in a context value template. */ - private static final Pattern CONTEXT_PLACEHOLDER = Pattern.compile("\\{([^}]+)\\}"); + private static final Pattern CONTEXT_PLACEHOLDER = Pattern.compile("\\{([^{}]+)\\}"); /** * Fills a flag's {@code context_value_template} with values from the evaluation context. From da4504e0fa85be24fbf57f18b593d4bec28e63a0 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Mon, 9 Nov 2026 10:41:56 -0500 Subject: [PATCH 15/17] fix: log master's exposure warning call when hierarchical groups is off Co-Authored-By: Claude Opus 5.5 --- .../featureflags/provider/LocalFlagsProvider.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java index 16696ad..d8a2d69 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java @@ -758,9 +758,15 @@ private void trackLocalExposure(Map context, String flagKey, Str // exposure event still needs distinct_id ($company_id for hierarchical // groups) to attribute the user. Surface the drop instead of silently // returning so callers can see what to include in the context. - logger.log(Level.WARNING, - "Cannot track exposure event for flag ''{0}'' without a {1} in the context", - new Object[] {flagKey, config.isHierarchicalGroups() ? "$company_id" : "distinct_id"}); + if (config.isHierarchicalGroups()) { + logger.log(Level.WARNING, + "Cannot track exposure event for flag ''{0}'' without a {1} in the context", + new Object[] {flagKey, "$company_id"}); + } else { + logger.log(Level.WARNING, + "Cannot track exposure event for flag ''{0}'' without a distinct_id in the context", + flagKey); + } return; } From 571302b85bec33ec765ee7c696b636a057a029bd Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Mon, 9 Nov 2026 12:11:24 -0500 Subject: [PATCH 16/17] fix: keep unset identity keys, refuse unsupported modifier operations and let identity win in FlagContext - MixpanelIdentity.applyTo writes only the keys the identity sets; keys it leaves unset keep the caller's values. - On hierarchical groups, people and group profile messages refuse any $-prefixed modifier key outside $set, $set_once, $unset, $union, $remove and $delete (non-operation keys such as $time and $company_id are allowed) with UnsupportedOperationException. Without hierarchical groups, modifiers are copied as before. - FlagContext applies identity keys last, so identity() and the explicit id setters win over group() and customProperty() whatever the call order; among them the later call wins. Co-Authored-By: Claude Opus 5.5 --- .../mixpanel/mixpanelapi/MessageBuilder.java | 36 +++- .../mixpanelapi/MixpanelIdentity.java | 21 ++- .../mixpanelapi/featureflags/FlagContext.java | 17 +- .../mixpanelapi/HierarchicalGroupsTest.java | 172 +++++++++++++++++- 4 files changed, 224 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java index 8fb0778..13d8428 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java +++ b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java @@ -31,6 +31,10 @@ public class MessageBuilder { // /groups has no $add, $append or $merge private static final Set GROUP_OPERATIONS = Collections.unmodifiableSet(new LinkedHashSet<>( Arrays.asList("$set", "$set_once", "$unset", "$union", "$remove", "$delete"))); + // Keys of a /engage or /groups message that are not operations + private static final Set NON_OPERATION_KEYS = Collections.unmodifiableSet(new LinkedHashSet<>( + Arrays.asList("$token", "$distinct_id", "$group_key", "$group_id", "$company_id", "$time", "$ip", + "$ignore_time", "$ignore_alias", "$mp_metadata", "$latitude", "$longitude"))); private final String mToken; private final boolean mHierarchicalGroups; @@ -825,7 +829,7 @@ public JSONObject trackCharge(String distinctId, double amount, JSONObject prope * @throws IllegalArgumentException if properties is not intelligible as a JSONObject property, or on a * hierarchical groups builder if the distinct id does not name both a company and a user * @throws UnsupportedOperationException on a hierarchical groups builder, for $add, $append or any other - * operation /groups does not support + * operation /groups does not support, whether as the actionType or as a modifier key * * @see MessageBuilder#set(String distinctId, JSONObject properties) * @see MessageBuilder#delete(String distinctId) @@ -841,6 +845,8 @@ public JSONObject peopleMessage(String distinctId, String actionType, Object pro + "groups equivalent."); } if (mHierarchicalGroups) { + // Checked before the distinct id, as the actionType is. + requireSupportedModifiers(modifiers); return hierarchicalUserProfileMessage(distinctId, actionType, properties, modifiers); } JSONObject dataObj = new JSONObject(); @@ -1349,7 +1355,8 @@ private String requireBoundCompanyId(String method) { * @throws IllegalArgumentException if properties is not intelligible as a JSONObject property * @throws IllegalStateException for hierarchical groups projects when no company is known * @throws UnsupportedOperationException on a hierarchical groups builder, for an operation /groups lacks - * ($add, $append or $merge) + * (anything but $set, $set_once, $unset, $union, $remove or $delete), whether as the actionType + * or as a modifier key * * @see MessageBuilder#groupSet(String groupKey, String groupId, JSONObject properties) * @see MessageBuilder#groupSetOnce(String groupKey, String groupId, JSONObject properties) @@ -1368,6 +1375,9 @@ public JSONObject groupMessage(String groupKey, String groupId, String actionTyp private JSONObject buildGroupMessage(String groupKey, String groupId, String actionType, Object properties, JSONObject modifiers, String companyId) { + if (mHierarchicalGroups) { + requireSupportedModifiers(modifiers); + } if (mHierarchicalGroups && companyId == null && !hasCompanyIdModifier(modifiers)) { throw new IllegalStateException("Group profile updates for hierarchical groups projects require a " + "company. Use forCompany(companyId) to build them. Nothing to send for group " @@ -1412,6 +1422,28 @@ private JSONObject buildGroupMessage(String groupKey, String groupId, String act } } + /** + * Modifiers are merged into the message, so on a hierarchical groups builder a {@code $}-prefixed + * modifier key is an operation unless it is one of the non-operation keys such as {@code $time}, + * and an operation /groups lacks throws just as it does as the actionType. + */ + private static void requireSupportedModifiers(JSONObject modifiers) { + if (modifiers == null) { + return; + } + final String[] keys = JSONObject.getNames(modifiers); + if (keys == null) { + return; + } + for (String key : keys) { + if (key.startsWith("$") && !GROUP_OPERATIONS.contains(key) && !NON_OPERATION_KEYS.contains(key)) { + throw new UnsupportedOperationException(key + " is not supported for group profiles on " + + "hierarchical groups projects, whose user profiles are group profiles keyed by $user_id. " + + "Supported operations are " + GROUP_OPERATIONS + "."); + } + } + } + /** * An empty or JSONObject.NULL {@code $company_id} modifier names no company, just as * {@link #forCompany(String)} rejects a null or empty id. diff --git a/src/main/java/com/mixpanel/mixpanelapi/MixpanelIdentity.java b/src/main/java/com/mixpanel/mixpanelapi/MixpanelIdentity.java index 7aac2a9..a9d79c3 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/MixpanelIdentity.java +++ b/src/main/java/com/mixpanel/mixpanelapi/MixpanelIdentity.java @@ -163,16 +163,21 @@ public boolean isHierarchical() { } /** - * Writes this identity's keys into the given event properties, replacing any - * caller-supplied values for the same keys. A key this identity leaves unset is - * removed, since JSONObject.put with a null value removes the key, so the - * properties never carry a stale id. + * Writes the keys this identity sets into the given event properties, replacing any + * caller-supplied values for those keys. A key this identity leaves unset is left as + * the caller gave it. */ /* package */ void applyTo(JSONObject properties) throws JSONException { - properties.put("distinct_id", mDistinctId); - properties.put("$user_id", mUserId); - properties.put("$company_id", mCompanyId); - properties.put("$device_id", mDeviceId); + putIfSet(properties, "distinct_id", mDistinctId); + putIfSet(properties, "$user_id", mUserId); + putIfSet(properties, "$company_id", mCompanyId); + putIfSet(properties, "$device_id", mDeviceId); + } + + private static void putIfSet(JSONObject properties, String key, String value) throws JSONException { + if (value != null) { + properties.put(key, value); + } } @Override diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/FlagContext.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/FlagContext.java index d63391e..2f9d0dd 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/FlagContext.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/FlagContext.java @@ -31,6 +31,9 @@ public static Builder builder() { public static final class Builder { private final Map mContext = new HashMap<>(); + // Identity keys are applied last in build(), so they win over group() and + // customProperty() whatever the call order; among them the later call wins. + private final Map mIdentity = new HashMap<>(); private final Map mCustomProperties = new HashMap<>(); private Builder() { @@ -40,6 +43,11 @@ private Builder() { * Sets the identity keys from a {@link MixpanelIdentity}: {@code distinct_id} for a * legacy identity, or {@code $user_id}, {@code $company_id} and {@code device_id} * for a hierarchical groups identity. + * + *

Identity keys, whether set here or with {@link #distinctId}, {@link #userId}, + * {@link #companyId} or {@link #deviceId}, win over the same keys set with + * {@link #group} or {@link #customProperty}, whatever the call order. Among the + * identity setters, the later call wins. */ public Builder identity(MixpanelIdentity identity) { if (identity.getDistinctId() != null) distinctId(identity.getDistinctId()); @@ -50,23 +58,23 @@ public Builder identity(MixpanelIdentity identity) { } public Builder distinctId(String distinctId) { - mContext.put("distinct_id", distinctId); + mIdentity.put("distinct_id", distinctId); return this; } /** Sets the device id, used as the assignment key by flags that assign on {@code device_id}. */ public Builder deviceId(String deviceId) { - mContext.put("device_id", deviceId); + mIdentity.put("device_id", deviceId); return this; } public Builder userId(String userId) { - mContext.put("$user_id", userId); + mIdentity.put("$user_id", userId); return this; } public Builder companyId(String companyId) { - mContext.put("$company_id", companyId); + mIdentity.put("$company_id", companyId); return this; } @@ -92,6 +100,7 @@ public Map build() { if (!mCustomProperties.isEmpty()) { context.put("custom_properties", new HashMap<>(mCustomProperties)); } + context.putAll(mIdentity); return context; } } diff --git a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java index b062007..a2db8ce 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java @@ -86,21 +86,28 @@ public void testIdentityOverridesCallerSuppliedKeysAndKeepsOtherProperties() { } @Test - public void testIdentityDropsCallerSuppliedKeysItLeavesUnset() { + public void testIdentityKeepsCallerSuppliedKeysItLeavesUnset() { JSONObject caller = new JSONObject(); - caller.put("distinct_id", "stale"); - caller.put("$device_id", "stale_device"); - caller.put("$user_id", "stale_user"); + caller.put("distinct_id", "caller"); + caller.put("$device_id", "caller_device"); + caller.put("$user_id", "caller_user"); + caller.put("$company_id", "wrong"); caller.put("plan", "enterprise"); JSONObject props = eventProperties(hgBuilder.event(MixpanelIdentity.builder().companyId("acme").build(), "Renewed", caller)); assertEquals("acme", props.getString("$company_id")); - assertFalse(props.has("distinct_id")); - assertFalse(props.has("$device_id")); - assertFalse(props.has("$user_id")); + assertEquals("caller", props.getString("distinct_id")); + assertEquals("caller_device", props.getString("$device_id")); + assertEquals("caller_user", props.getString("$user_id")); assertEquals("enterprise", props.getString("plan")); - assertEquals("stale", caller.getString("distinct_id")); + assertEquals("wrong", caller.getString("$company_id")); + + JSONObject imported = eventProperties(hgBuilder.importEvent( + MixpanelIdentity.builder().userId("user_123").companyId("acme").build(), "Renewed", + new JSONObject().put("distinct_id", "caller"))); + assertEquals("caller", imported.getString("distinct_id")); + assertEquals("user_123", imported.getString("$user_id")); } @Test @@ -377,6 +384,50 @@ public void testUnsupportedOperationIsReportedBeforeDistinctId() { () -> hgBuilder.peopleMessage("not_combined", "$append", props, null)); } + @Test + public void testUnsupportedModifierOperationThrowsOnHierarchicalUserProfile() { + JSONObject props = new JSONObject().put("plan", "pro"); + for (String operation : Arrays.asList("$add", "$append", "$merge", "$foo")) { + JSONObject modifiers = new JSONObject().put(operation, new JSONObject().put("logins", 1)); + UnsupportedOperationException e = assertThrows(UnsupportedOperationException.class, + () -> hgBuilder.peopleMessage("$company:acme|$user:user_123", "$set", props, modifiers)); + assertTrue(e.getMessage(), e.getMessage().startsWith(operation + " is not supported")); + // Before the distinct id is checked, as for the actionType + assertThrows(UnsupportedOperationException.class, + () -> hgBuilder.peopleMessage("not_combined", "$set", props, modifiers)); + assertThrows(UnsupportedOperationException.class, + () -> hgBuilder.set(MixpanelIdentity.builder().userId("user_123").companyId("acme").build(), + props, modifiers)); + } + } + + @Test + public void testNonOperationAndSupportedModifiersBuildOnHierarchicalUserProfile() { + JSONObject modifiers = new JSONObject() + .put("$token", TOKEN).put("$time", 1L).put("$ip", "1.2.3.4").put("$ignore_time", true) + .put("$ignore_alias", true).put("$mp_metadata", new JSONObject()).put("$company_id", "acme") + .put("$group_key", "$user_id").put("$group_id", "user_123") + .put("$unset", new JSONArray().put("old")).put("custom", 1); + JSONObject envelope = hgBuilder.peopleMessage("$company:acme|$user:user_123", "$set", + new JSONObject().put("plan", "pro"), modifiers); + + assertUserGroupMessage(envelope, "acme", "user_123"); + JSONObject message = envelope.getJSONObject("message"); + assertEquals("pro", message.getJSONObject("$set").getString("plan")); + assertEquals("old", message.getJSONArray("$unset").getString(0)); + } + + @Test + public void testLegacyPeopleMessageCopiesAnyModifier() { + JSONObject envelope = legacyBuilder.peopleMessage("user_123", "$set", new JSONObject().put("plan", "pro"), + new JSONObject().put("$add", new JSONObject().put("logins", 1)).put("$foo", 1)); + + JSONObject message = envelope.getJSONObject("message"); + assertEquals("people", envelope.getString("message_type")); + assertEquals(1, message.getJSONObject("$add").getInt("logins")); + assertEquals(1, message.getInt("$foo")); + } + @Test public void testLegacyGenericPeopleMessageKeepsDistinctId() { JSONObject envelope = legacyBuilder.peopleMessage("$company:acme|$user:user_123", "$set", @@ -672,6 +723,48 @@ public void testUnsupportedGroupOperationThrows() { } } + @Test + public void testUnsupportedGroupModifierOperationThrows() { + MessageBuilder acme = hgBuilder.forCompany("acme"); + JSONObject properties = new JSONObject().put("seats", 1); + for (String operation : Arrays.asList("$add", "$append", "$merge", "$foo")) { + JSONObject modifiers = new JSONObject().put(operation, properties); + UnsupportedOperationException e = assertThrows(UnsupportedOperationException.class, + () -> acme.groupMessage("company", "acme", "$set", properties, modifiers)); + assertTrue(e.getMessage(), e.getMessage().startsWith(operation + " is not supported")); + assertThrows(UnsupportedOperationException.class, + () -> acme.groupSet("company", "acme", properties, modifiers)); + // Before the company check, as for the actionType + assertThrows(UnsupportedOperationException.class, + () -> hgBuilder.groupMessage("company", "acme", "$set", properties, modifiers)); + } + } + + @Test + public void testNonOperationGroupModifiersBuild() { + JSONObject modifiers = new JSONObject() + .put("$token", TOKEN).put("$time", 1L).put("$ip", "1.2.3.4").put("$ignore_time", true) + .put("$ignore_alias", true).put("$mp_metadata", new JSONObject()).put("$distinct_id", "x") + .put("$company_id", "acme").put("$remove", new JSONObject().put("tags", "a")); + JSONObject envelope = hgBuilder.groupMessage("company", "acme", "$set", new JSONObject().put("seats", 1), + modifiers); + + JSONObject message = envelope.getJSONObject("message"); + assertEquals("acme", message.getString("$company_id")); + assertEquals(1, message.getJSONObject("$set").getInt("seats")); + assertEquals("a", message.getJSONObject("$remove").getString("tags")); + } + + @Test + public void testLegacyGroupMessageCopiesAnyModifier() { + JSONObject envelope = legacyBuilder.groupMessage("company", "acme", "$set", new JSONObject().put("seats", 1), + new JSONObject().put("$add", new JSONObject().put("seats", 1)).put("$foo", 1)); + + JSONObject message = envelope.getJSONObject("message"); + assertEquals(1, message.getJSONObject("$add").getInt("seats")); + assertEquals(1, message.getInt("$foo")); + } + @Test public void testUnsupportedGroupOperationThrowsBeforeCompanyCheck() { assertThrows(UnsupportedOperationException.class, @@ -783,6 +876,69 @@ public void testFlagContextFromHierarchicalIdentity() { assertEquals("enterprise", ((Map) context.get("custom_properties")).get("plan")); } + @Test + public void testFlagContextIdentityWinsOverGroupsWhateverTheOrder() { + MixpanelIdentity identity = MixpanelIdentity.builder().distinctId("d1").userId("user_123") + .companyId("acme").deviceId("device_abc").build(); + + Map groupsFirst = FlagContext.builder() + .group("distinct_id", "g").group("$user_id", "g").group("$company_id", "g").group("device_id", "g") + .group("custom_properties", "g") + .customProperty("plan", "enterprise") + .identity(identity) + .build(); + Map identityFirst = FlagContext.builder() + .identity(identity) + .customProperty("plan", "enterprise") + .group("distinct_id", "g").group("$user_id", "g").group("$company_id", "g").group("device_id", "g") + .build(); + + for (Map context : Arrays.asList(groupsFirst, identityFirst)) { + assertEquals("d1", context.get("distinct_id")); + assertEquals("user_123", context.get("$user_id")); + assertEquals("acme", context.get("$company_id")); + assertEquals("device_abc", context.get("device_id")); + assertEquals("enterprise", ((Map) context.get("custom_properties")).get("plan")); + } + } + + @Test + public void testFlagContextExplicitSettersWinOverGroupsWhateverTheOrder() { + Map settersFirst = FlagContext.builder() + .distinctId("d1").userId("user_123").companyId("acme").deviceId("device_abc") + .group("distinct_id", "g").group("$user_id", "g").group("$company_id", "g").group("device_id", "g") + .build(); + Map groupsFirst = FlagContext.builder() + .group("distinct_id", "g").group("$user_id", "g").group("$company_id", "g").group("device_id", "g") + .distinctId("d1").userId("user_123").companyId("acme").deviceId("device_abc") + .build(); + + for (Map context : Arrays.asList(settersFirst, groupsFirst)) { + assertEquals("d1", context.get("distinct_id")); + assertEquals("user_123", context.get("$user_id")); + assertEquals("acme", context.get("$company_id")); + assertEquals("device_abc", context.get("device_id")); + } + } + + @Test + public void testFlagContextLaterIdentitySourceWins() { + Map setterLast = FlagContext.builder() + .identity(MixpanelIdentity.builder().userId("from_identity").companyId("acme").build()) + .userId("from_setter") + .build(); + assertEquals("from_setter", setterLast.get("$user_id")); + assertEquals("acme", setterLast.get("$company_id")); + + Map identityLast = FlagContext.builder() + .userId("from_setter") + .companyId("other") + .identity(MixpanelIdentity.builder().userId("from_identity").companyId("acme").build()) + .build(); + assertEquals("from_identity", identityLast.get("$user_id")); + assertEquals("acme", identityLast.get("$company_id")); + } + @Test public void testFlagContextFromLegacyIdentity() { Map context = FlagContext.builder() From 71dd95173a6e9cea0417f5ed19a877ab40ef2a48 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Mon, 9 Nov 2026 12:46:55 -0500 Subject: [PATCH 17/17] fix: document that event and importEvent keep identity keys the identity leaves unset Co-Authored-By: Claude Opus 5.5 --- .../java/com/mixpanel/mixpanelapi/MessageBuilder.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java index 13d8428..9b507c6 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java +++ b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java @@ -168,9 +168,9 @@ public JSONObject event(String distinctId, String eventName, JSONObject properti * Creates a message tracking an event for the given identity. In hierarchical * group mode the message carries {@code $user_id} and {@code $company_id} instead * of {@code distinct_id}; Mixpanel constructs the distinct id from them. - * The identity decides {@code distinct_id}, {@code $user_id}, {@code $company_id} - * and {@code $device_id}: it replaces any values for those keys in properties, - * and drops the keys it leaves unset. + * Each of {@code distinct_id}, {@code $user_id}, {@code $company_id} and + * {@code $device_id} that the identity sets replaces any value for that key in + * properties; a key the identity leaves unset is kept as given in properties. * * @param identity who the event belongs to * @param eventName a human readable name for the event @@ -185,6 +185,9 @@ public JSONObject event(MixpanelIdentity identity, String eventName, JSONObject /** * Creates a message importing a historical event for the given identity. + * The identity's keys replace those in properties as in + * {@link #event(MixpanelIdentity, String, JSONObject)}; a key the identity leaves + * unset is kept as given in properties. * * @param identity who the event belongs to * @param eventName a human readable name for the event