Haskell port of
github/scientist
.
The following extensions are recommended:
{-# LANGUAGE OverloadedStrings #-}
Most usage will only require the top-level library:
import Scientist
-
Define a new
Experiment m c a b
with,ex0 :: Functor m => Experiment m c a b ex0 = newExperiment "some name" theOriginalCode
-
The type variables capture the following details:
m
: Some Monad to operate in, e.g.IO
.c
: Any context value you attach withsetExperimentContext
. It will then be available in theResult c a b
you publish.a
: The result type of your original (aka "control") code, this is what is always returned and so is the return type ofexperimentRun
.b
: The result type of your experimental (aka "candidate") code. It probably won't differ (and must not to use a comparison like(==)
), but it can (provided you implement a comparison betweena
andb
).
-
Configure the Experiment as desired
ex1 :: (Functor m, Eq a) => Experiment m c a a ex1 = setExperimentPublish publish0 $ setExperimentCompare experimentCompareEq $ setExperimentTry theExperimentalCode $ newExperiment "some name" theOriginalCode -- Increment Statsd, Log, store in Redis, whatever publish0 :: Result c a b -> m () publish0 = undefined
-
Run the experiment
run0 :: (MonadUnliftIO m, Eq a) => m a run0 = experimentRun $ setExperimentPublish publish0 $ setExperimentCompare experimentCompareEq $ setExperimentTry theExperimentalCode $ newExperiment "some name" theOriginalCode
-
Explore things like
setExperimentIgnore
,setExperimentEnabled
, etc.
The rest of this README matches section-by-section to the ported project and shows only the differences in syntax for those features. Please follow the header links for additional details, motivation, etc.
myWidgetAllows :: MonadUnliftIO m => Model -> User -> m Bool
myWidgetAllows model user = do
experimentRun
$ setExperimentTry
(userCanRead user model) -- new way
$ newExperiment "widget-permissions"
(modelCheckUserValid model user) -- old way
run1 :: MonadUnliftIO m => m a
run1 =
experimentRun
$ setExperimentEnabled (pure True)
$ setExperimentOnException onScientistException
$ setExperimentPublish (liftIO . putStrLn . formatResult)
$ setExperimentTry theExperimentalCode
$ newExperiment "some name" theOriginalCode
onScientistException :: MonadIO m => SomeException -> m ()
onScientistException ex = do
liftIO $ putStrLn "..."
-- To re-raise
throwIO ex
formatResult :: Result c a b -> String
formatResult = undefined
run2 :: MonadUnliftIO m => m [User]
run2 =
experimentRun
$ setExperimentCompare (experimentCompareOn $ map userLogin)
$ setExperimentTry userServiceFetch
$ newExperiment "users" fetchAllUsers
When using experimentCompareOn
, By
, or Eq
, if a candidate branch raises an
exception, that will never compare equally.
See setExperimentContext
.
Just do it ahead of time.
run3 :: MonadUnliftIO m => m a
run3 = do
x <- expensiveSetup
experimentRun
$ setExperimentTry (theExperimentalCodeWith x)
$ newExperiment "expensive" (theOriginalCodeWith x)
Not supported at this time. Format the value(s) as necessary when publishing.
See setExperimentIgnore
.
See setExperimentRunIf
.
run4 :: MonadUnliftIO m => m a
run4 =
experimentRun
$ setExperimentEnabled (experimentEnabledPercent 30)
$ setExperimentTry theExperimentalCode
$ newExperiment "some name" theOriginalCode
run5 :: MonadUnliftIO m => m User
run5 =
experimentRun
$ setExperimentPublish publish1
$ setExperimentTry theExperimentalCode
$ newExperiment "some name" theOriginalCode
publish1 :: MonadIO m => Result MyContext User User -> m ()
publish1 result = do
-- Details are present unless it's a ResultSkipped, which we'll ignore
for_ (resultDetails result) $ \details -> do
let eName = resultDetailsExperimentName details
-- Store the timing for the control value,
statsdTiming ("science." <> eName <> ".control")
$ resultControlDuration
$ resultDetailsControl details
-- for the candidate (only the first, see "Breaking the rules" below,
statsdTiming ("science." <> eName <> ".candidate")
$ resultCandidateDuration
$ resultDetailsCandidate details
-- and counts for match/ignore/mismatch:
case result of
ResultSkipped{} -> pure ()
ResultMatched{} -> do
statsdIncrement $ "science." <> eName <> ".matched"
ResultIgnored{} -> do
statsdIncrement $ "science." <> eName <> ".ignored"
ResultMismatched{} -> do
statsdIncrement $ "science." <> eName <> ".mismatched"
-- Finally, store mismatches in redis so they can be retrieved and
-- examined later on, for debugging and research.
storeMismatchData details
storeMismatchData :: Monad m => ResultDetails MyContext User User -> m ()
storeMismatchData details = do
let
eName = resultDetailsExperimentName details
eContext = resultDetailsExperimentContext details
payload = MyPayload
{ name = eName
, context = eContext
, control = controlObservationPayload $ resultDetailsControl details
, candidate = candidateObservationPayload $ resultDetailsCandidate details
, execution_order = resultDetailsExecutionOrder details
}
key = "science." <> eName <> ".mismatch"
redisLpush key $ toJSON payload
redisLtrim key 0 1000
controlObservationPayload :: ResultControl User -> Value
controlObservationPayload rc =
object ["value" .= cleanValue (resultControlValue rc)]
candidateObservationPayload :: ResultCandidate User -> Value
candidateObservationPayload rc = case resultCandidateValue rc of
Left ex -> object ["exception" .= displayException ex]
Right user -> object ["value" .= cleanValue user]
-- See "Keeping it clean" above
cleanValue :: User -> Text
cleanValue = userLogin
See Result
, ResultDetails
, ResultControl
and ResultCandidate
for all the
available data you can publish.
TODO: raise_on_mismatches
TODO: raise_with
Candidate code is wrapped in tryAny
, resulting in Either SomeException
values in the result candidates list. We use the safer
UnliftIO.Exception
module.
See setExperimentOnException
.
nope0 :: Experiment m c a b -> Experiment m c a b
nope0 = setExperimentIgnore (\_ _ -> True)
Or, more efficiently:
nope1 :: Experiment m c a b -> Experiment m c a b
nope1 = setExperimentCompare (\_ _ -> True)
If you call setExperimentTry
more than once, it will append (not overwrite)
candidate branches. If any candidate is deemed ignored or a mismatch, the
overall result will be.
setExperimentTryNamed
can be used to give branches explicit names (otherwise,
they are "control", "candidate", "candidate-{n}"). These names are visible in
ResultControl
, ResultCandidate
, and resultDetailsExecutionOrder
.
Not supported.
Supporting the lack of a Control branch in the types would ultimately lead to a
runtime error if you attempt to run such an Experiment
without having and
naming a Candidate to use instead, or severely complicate the types to account
for that safely. In our opinion, this feature is not worth either of those.