diff --git a/api/src/main/java/com/glassdoor/planout4j/SimpleNamespaceFactory.java b/api/src/main/java/com/glassdoor/planout4j/SimpleNamespaceFactory.java index ba18a17..f03ea05 100644 --- a/api/src/main/java/com/glassdoor/planout4j/SimpleNamespaceFactory.java +++ b/api/src/main/java/com/glassdoor/planout4j/SimpleNamespaceFactory.java @@ -3,16 +3,19 @@ import java.util.Collections; import java.util.Map; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import com.google.common.base.Optional; - import com.glassdoor.planout4j.config.Planout4jRepository; import com.glassdoor.planout4j.config.Planout4jRepositoryImpl; import com.glassdoor.planout4j.config.ValidationException; +import com.glassdoor.planout4j.logging.LogRecord; +import com.glassdoor.planout4j.logging.Planout4jLogger; +import com.glassdoor.planout4j.logging.Planout4jLoggerFactory; import com.glassdoor.planout4j.util.VersionLogger; +import com.google.common.base.MoreObjects; +import com.google.common.base.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -import static com.google.common.base.Preconditions.*; +import static com.google.common.base.Preconditions.checkNotNull; /** * Reads namespace data once and caches forever. @@ -26,16 +29,18 @@ public class SimpleNamespaceFactory implements NamespaceFactory { private static final Logger LOG = LoggerFactory.getLogger(SimpleNamespaceFactory.class); protected final Planout4jRepository planout4jRepository; + protected final Planout4jLogger p4jLogger; protected volatile Map namespaceName2namespaceConfigMap = Collections.emptyMap(); - public SimpleNamespaceFactory(final Planout4jRepository planout4jRepository) { + public SimpleNamespaceFactory(final Planout4jRepository planout4jRepository, final Planout4jLogger p4jLogger) { this.planout4jRepository = planout4jRepository; + this.p4jLogger = MoreObjects.firstNonNull(p4jLogger, Planout4jLogger.NO_OP); namespaceName2namespaceConfigMap = readConfig(); } public SimpleNamespaceFactory() { - this(new Planout4jRepositoryImpl()); + this(new Planout4jRepositoryImpl(), Planout4jLoggerFactory.getLogger()); } /** @@ -49,8 +54,18 @@ public SimpleNamespaceFactory() { @Override public Optional getNamespace(final String name, final Map paramName2valueMap, final Map overrides) { final Optional config = getNamespaceConfig(name); - return config.isPresent() ? Optional.of(new Namespace(config.get(), paramName2valueMap, overrides)) - : Optional.absent(); + final Namespace ns; + if (config.isPresent()) { + ns = new Namespace(config.get(), paramName2valueMap, overrides); + try { + p4jLogger.exposed(new LogRecord(ns, paramName2valueMap, overrides)); + } catch (final RuntimeException e) { + LOG.warn("Failure in exposure logging", e); + } + } else { + ns = null; + } + return Optional.fromNullable(ns); } /** @@ -79,7 +94,7 @@ public void refresh() { LOG.info("refreshing ..."); try { namespaceName2namespaceConfigMap = readConfig(); - } catch (Exception e) { + } catch (final Exception e) { LOG.error("Namespace refresh failed: Invalid configuration", e); } } diff --git a/api/src/main/java/com/glassdoor/planout4j/logging/AbstractJSONLogger.java b/api/src/main/java/com/glassdoor/planout4j/logging/AbstractJSONLogger.java new file mode 100644 index 0000000..e048677 --- /dev/null +++ b/api/src/main/java/com/glassdoor/planout4j/logging/AbstractJSONLogger.java @@ -0,0 +1,13 @@ +package com.glassdoor.planout4j.logging; + +public abstract class AbstractJSONLogger extends AbstractSerializingLogger { + + protected AbstractJSONLogger() { + this(false); + } + + protected AbstractJSONLogger(final boolean pretty) { + super(new JSONSerializer(pretty)); + } + +} diff --git a/api/src/main/java/com/glassdoor/planout4j/logging/AbstractSerializingLogger.java b/api/src/main/java/com/glassdoor/planout4j/logging/AbstractSerializingLogger.java new file mode 100644 index 0000000..91fa0ee --- /dev/null +++ b/api/src/main/java/com/glassdoor/planout4j/logging/AbstractSerializingLogger.java @@ -0,0 +1,47 @@ +package com.glassdoor.planout4j.logging; + +import java.util.Objects; + +import com.google.common.base.Converter; +import com.typesafe.config.Config; + + +/** + * Base class for all serializing loggers. + * @param what to serialize to + * @author ernest_mishkin + */ +public abstract class AbstractSerializingLogger implements Planout4jLogger { + + private static final String LOG_DEFAULT_EXPOSURE = "log_default_exposure"; + + protected final Converter serializer; + public volatile boolean logDefaultExposure; + + protected AbstractSerializingLogger(final Converter serializer) { + this.serializer = Objects.requireNonNull(serializer); + } + + /** + * Set certain properties from the configuration (logging section of the master planout4j.conf file) + * @param config logging section config + */ + @Override + public void configure(final Config config) { + logDefaultExposure = config.hasPath(LOG_DEFAULT_EXPOSURE) && config.getBoolean(LOG_DEFAULT_EXPOSURE); + } + + /** + * How to persist the serialized record + * @param record serialized record + */ + protected abstract void persist(S record); + + @Override + public void exposed(final LogRecord record) { + if (logDefaultExposure || record.namespace.getExperiment() != null) { + persist(serializer.convert(record)); + } + } + +} diff --git a/api/src/main/java/com/glassdoor/planout4j/logging/JSONLoggingRecord.java b/api/src/main/java/com/glassdoor/planout4j/logging/JSONLoggingRecord.java new file mode 100644 index 0000000..e2f93ad --- /dev/null +++ b/api/src/main/java/com/glassdoor/planout4j/logging/JSONLoggingRecord.java @@ -0,0 +1,55 @@ +package com.glassdoor.planout4j.logging; + +import java.util.Map; + + +@SuppressWarnings("unused") +class JSONLoggingRecord { + + String event; + String timestamp; + String namespace; + String experiment; + String salt; + String checksum; + Map inputs; + Map overrides; + Map params; + + public String getEvent() { + return event; + } + + public String getTimestamp() { + return timestamp; + } + + public String getNamespace() { + return namespace; + } + + public String getExperiment() { + return experiment; + } + + public String getSalt() { + return salt; + } + + public String getChecksum() { + return checksum; + } + + public Map getInputs() { + return inputs; + } + + public Map getOverrides() { + return overrides; + } + + public Map getParams() { + return params; + } + +} diff --git a/api/src/main/java/com/glassdoor/planout4j/logging/JSONSerializer.java b/api/src/main/java/com/glassdoor/planout4j/logging/JSONSerializer.java new file mode 100644 index 0000000..a27bb14 --- /dev/null +++ b/api/src/main/java/com/glassdoor/planout4j/logging/JSONSerializer.java @@ -0,0 +1,88 @@ +package com.glassdoor.planout4j.logging; + + +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.TimeZone; + +import com.glassdoor.planout4j.Experiment; +import com.google.common.base.Converter; +import com.google.common.base.MoreObjects; +import com.google.common.hash.HashFunction; +import com.google.common.hash.Hashing; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + + +/** + * Serializes an exposure logging record into JSON string. + * Format of the record follows planout example with minor tweaks. + * Sample record:
+ {
+     "event": "exposure",
+     "timestamp": "2016-05-31T19:06:58.510Z",
+     "namespace": "test_ns",
+     "experiment": "def_exp",
+     "checksum": "77341e05",
+     "inputs": {
+     "userid": 12345
+     },
+     "overrides": {
+     },
+     "params": {
+     "specific_goal": true,
+     "group_size": 1,
+     "ratings_per_user_goal": 64,
+     "ratings_goal": 64
+     }
+ }
+ * 
+ * @author ernest_mishkin + */ +public class JSONSerializer extends Converter { + + private static final TimeZone UTC = TimeZone.getTimeZone("UTC"); + private static final DateFormat ISO = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); + static { + ISO.setTimeZone(UTC); + } + private static final HashFunction CHECKSUM = Hashing.crc32(); + + private final Gson gson; + + public JSONSerializer() { + this(false); + } + + public JSONSerializer(final boolean pretty) { + final GsonBuilder builder = new GsonBuilder(); + if (pretty) { + builder.setPrettyPrinting(); + } + gson = builder.create(); + } + + @Override + protected String doForward(final LogRecord record) { + final JSONLoggingRecord jlr = new JSONLoggingRecord(); + jlr.event = "exposure"; + jlr.timestamp = ISO.format(new Date()); + jlr.namespace = record.namespace.getName(); + jlr.salt = record.namespace.nsConf.salt; + final Experiment exp = MoreObjects.firstNonNull(record.namespace.getExperiment(), record.namespace.nsConf.getDefaultExperiment()); + jlr.experiment = exp.name; + jlr.checksum = CHECKSUM.hashUnencodedChars(exp.def.getCopyOfScript().toString()).toString(); + jlr.inputs = record.input; + jlr.overrides = record.overrides; + jlr.params = record.namespace.getParams(); + return gson.toJson(jlr); + } + + @Override + protected LogRecord doBackward(final String s) { + throw new IllegalStateException("Deserialization is not implemented"); + } + + +} diff --git a/api/src/main/java/com/glassdoor/planout4j/logging/LogRecord.java b/api/src/main/java/com/glassdoor/planout4j/logging/LogRecord.java new file mode 100644 index 0000000..fe97d47 --- /dev/null +++ b/api/src/main/java/com/glassdoor/planout4j/logging/LogRecord.java @@ -0,0 +1,60 @@ +package com.glassdoor.planout4j.logging; + +import java.util.Collections; +import java.util.Map; +import java.util.Objects; + +import com.glassdoor.planout4j.Namespace; +import com.google.common.base.MoreObjects; +import com.google.common.collect.ImmutableMap; + + +/** + * Represents data logged at exposure. + * @author ernest_mishkin + */ +public class LogRecord { + + public final Namespace namespace; + public final Map input; + public final Map overrides; + + public LogRecord(final Namespace namespace, final Map input, final Map overrides) { + this.namespace = namespace; + this.input = ImmutableMap.copyOf(input); + //noinspection unchecked,CollectionsFieldAccessReplaceableByMethodCall + this.overrides = overrides == null ? Collections.EMPTY_MAP : ImmutableMap.copyOf(overrides); + } + + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final LogRecord record = (LogRecord) o; + return Objects.equals(namespace, record.namespace) && + Objects.equals(input, record.input) && + Objects.equals(overrides, record.overrides); + } + + @Override + public int hashCode() { + return Objects.hash(namespace, input, overrides); + } + + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("namespace", namespace.getName()) + .add("experiment", namespace.getExperiment().name) + .add("input", input) + .add("overrides", overrides) + .toString(); + } + +} diff --git a/api/src/main/java/com/glassdoor/planout4j/logging/Planout4jLogger.java b/api/src/main/java/com/glassdoor/planout4j/logging/Planout4jLogger.java new file mode 100644 index 0000000..26fed3a --- /dev/null +++ b/api/src/main/java/com/glassdoor/planout4j/logging/Planout4jLogger.java @@ -0,0 +1,39 @@ +package com.glassdoor.planout4j.logging; + +import com.typesafe.config.Config; + + +/** + * Implementations of this interface are responsible for performing exposure logging. + * If the logging involves more than trivial amount of time/resources, it should be done in a separate thread + * as planout4j code itself will perform logging serially. + * @author ernest_mishkin + */ +public interface Planout4jLogger { + + /** + * Invoked when input (e.g. a user) has been exposed to an experiment within a specific namespace. + * Any runtime exceptions thrown within implementations will be caught and logged (but will not break the flow). + * @param record exposure details + */ + void exposed(LogRecord record); + + /** + * Set certain properties from the configuration (logging section of the master planout4j.conf file) + * @param config logging section config + */ + void configure(Config config); + + + /** + * The "no-op" logger, does nothing. Default one for backwards-compatibility reasons. + */ + Planout4jLogger NO_OP = new Planout4jLogger() { + @Override + public void exposed(final LogRecord record) {} + @Override + public void configure(final Config config) {} + }; + + +} diff --git a/api/src/main/java/com/glassdoor/planout4j/logging/Planout4jLoggerFactory.java b/api/src/main/java/com/glassdoor/planout4j/logging/Planout4jLoggerFactory.java new file mode 100644 index 0000000..d3304aa --- /dev/null +++ b/api/src/main/java/com/glassdoor/planout4j/logging/Planout4jLoggerFactory.java @@ -0,0 +1,36 @@ +package com.glassdoor.planout4j.logging; + +import com.glassdoor.planout4j.config.ConfFileLoader; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigException.Missing; +import org.apache.commons.lang3.ClassUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + + +/** + * Resolves logging implementation from the master config file, then instantiates and configures it. + * Falls back on the no-op implementation. + * @author ernest_mishkin + */ +public final class Planout4jLoggerFactory { + + public static Planout4jLogger getLogger() { + final Logger log = LoggerFactory.getLogger(Planout4jLoggerFactory.class); + try { + final Config config = ConfFileLoader.loadConfig().getConfig("planout4j").getConfig("logging"); + final Planout4jLogger p4jLog = ClassUtils.getClass(config.getString("class")).asSubclass(Planout4jLogger.class).newInstance(); + p4jLog.configure(config); + log.info("Loaded and configured {} for exposure logging", p4jLog); + return p4jLog; + } catch (final Missing e) { + log.info(e.getMessage()); + return Planout4jLogger.NO_OP; + } catch (final ClassNotFoundException|IllegalAccessException|InstantiationException e) { + throw new RuntimeException("Failed to load planout4j logger implementation", e); + } + } + + private Planout4jLoggerFactory() {} + +} diff --git a/api/src/main/java/com/glassdoor/planout4j/logging/SLF4JLogger.java b/api/src/main/java/com/glassdoor/planout4j/logging/SLF4JLogger.java new file mode 100644 index 0000000..1c948fd --- /dev/null +++ b/api/src/main/java/com/glassdoor/planout4j/logging/SLF4JLogger.java @@ -0,0 +1,23 @@ +package com.glassdoor.planout4j.logging; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + + +public class SLF4JLogger extends AbstractJSONLogger { + + public static final String LOGGER_NAME = "planout"; + private static final Logger LOG = LoggerFactory.getLogger(LOGGER_NAME); + + public SLF4JLogger() {} + + public SLF4JLogger(final boolean pretty) { + super(pretty); + } + + @Override + protected void persist(final String record) { + LOG.info(record); + } + +} diff --git a/api/src/test/java/com/glassdoor/planout4j/logging/JSONSerializerTest.java b/api/src/test/java/com/glassdoor/planout4j/logging/JSONSerializerTest.java new file mode 100644 index 0000000..d1dba8e --- /dev/null +++ b/api/src/test/java/com/glassdoor/planout4j/logging/JSONSerializerTest.java @@ -0,0 +1,31 @@ +package com.glassdoor.planout4j.logging; + +import java.util.Map; + +import com.glassdoor.planout4j.Namespace; +import com.glassdoor.planout4j.NamespaceConfig; +import com.google.common.collect.ImmutableMap; +import org.json.simple.JSONValue; +import org.junit.Test; + + +@SuppressWarnings("ALL") +public class JSONSerializerTest { + + private static final Object TEST_DEF = JSONValue.parse("{\"op\":\"seq\",\"seq\":[{\"op\":\"set\",\"var\":\"group_size\",\"value\":{\"choices\":{\"op\":\"array\",\"values\":[1,10]},\"unit\":{\"op\":\"get\",\"var\":\"userid\"},\"op\":\"uniformChoice\"}},{\"op\":\"set\",\"var\":\"specific_goal\",\"value\":{\"p\":0.8,\"unit\":{\"op\":\"get\",\"var\":\"userid\"},\"op\":\"bernoulliTrial\"}},{\"op\":\"cond\",\"cond\":[{\"if\":{\"op\":\"get\",\"var\":\"specific_goal\"},\"then\":{\"op\":\"seq\",\"seq\":[{\"op\":\"set\",\"var\":\"ratings_per_user_goal\",\"value\":{\"choices\":{\"op\":\"array\",\"values\":[8,16,32,64]},\"unit\":{\"op\":\"get\",\"var\":\"userid\"},\"op\":\"uniformChoice\"}},{\"op\":\"set\",\"var\":\"ratings_goal\",\"value\":{\"op\":\"product\",\"values\":[{\"op\":\"get\",\"var\":\"group_size\"},{\"op\":\"get\",\"var\":\"ratings_per_user_goal\"}]}}]}}]}]}"); + + @Test + public void testSerialization() { + final JSONSerializer ser = new JSONSerializer(); + final NamespaceConfig nsConf = new NamespaceConfig("test_ns", 100, "userid", null); + nsConf.defineExperiment("def", (Map)TEST_DEF); + nsConf.setDefaultExperiment("def"); + nsConf.addExperiment("def_exp", "def", 100); + nsConf.noMoreChanges(); + final Map input = ImmutableMap.of("userid", 12345); + final Namespace ns = new Namespace(nsConf, input, null); + System.out.println(ser.doForward(new LogRecord(ns, input, null))); + } + + +} diff --git a/config/src/main/resources/planout4j.conf b/config/src/main/resources/planout4j.conf index db0dbf4..9e6f1ee 100644 --- a/config/src/main/resources/planout4j.conf +++ b/config/src/main/resources/planout4j.conf @@ -5,6 +5,7 @@ # for syntax, see https://github.com/typesafehub/config/blob/master/HOCON.md planout4j { + backend { # repository uses "runtimeRepo" key while shipper tool uses "source" and "target" keys @@ -56,4 +57,16 @@ planout4j { } } -} \ No newline at end of file + + + logging { + # optionally uncomment below to log exposure events serialized to JSON via SLF4J + # or point to a custom implementation of com.glassdoor.planout4j.logging.Planout4jLogger + # class = com.glassdoor.planout4j.logging.SLF4JLogger + + # whether to fire exposure events when a unit was assigned to the default experiment + log_default_exposure = false + + # optionally set implementation-specfic properties + } +}