/experiment

A Go package for experimenting with and evaluating new code paths.

Primary LanguageGoMIT LicenseMIT

Experiment

Examples | Contributing | Code of Conduct | License

GitHub release Actions Status MIT License GoDoc Report Card codecov

Experiment is a Go package to test and evaluate new code paths without interfering with the users end result.

This is inspired by the GitHub Scientist gem.

Use cases

Imagine a web application where you're generating images. You decide to investigate a new imaging package which seems to fit your needs more than the current package you're using. Tests help you transition from one package to the other, but you want to see how this behaves under load.

package main

import (
	"github.com/jelmersnoeck/experiment/v3"
)

func main() {
	exp := experiment.New[string](
		experiment.WithPercentage(50),
		experiment.WithConcurrency(),
	)

	// fetch arbitrary data
	userData := getUserData()

	exp.Control(func(context.Context) (string, error) {
		return dataToPng.Render(userData)
	})

	exp.Candidate("", func(context.Context) (string, error) {
		return imageX.Render(userData)
	})

	result, err := exp.Run(context.Background())
}

This allows you to serve the original content, dataToPng.Render() to the user whilst also testing the new package, imageX, in the background. This means that your end-user doesn't see any impact, but you get valuable information about your new implementation.

Usage

Import

This package uses go modules. To import it, use github.com/jelmersnoeck/experiment/v3 as import path.

Control

Control(func(context.Context) (any, error)) should be used to implement your current code. The result of this will be used to compare to other candidates. This will run as it would run normally.

A control is always expected. If no control is provided, the experiment will panic.

func main() {
	exp := experiment.New[string](
		experiment.WithPercentage(50),
	)

	exp.Control(func(context.Context) (string, error) {
		return fmt.Sprintf("Hello world!"), nil
	})

	result, err := exp.Run(context.Background())
	if err != nil {
		panic(err)
	} else {
		fmt.Println(result)
	}
}

The example above will always print Hello world!.

Candidate

Candidate(string, func(context.Context) (any, error)) is a potential refactored candidate. This will run sandboxed, meaning that when this panics, the panic is captured and the experiment continues.

A candidate will not always run, this depends on the WithPercentage(int) configuration option and further overrides.

func main() {
	exp := experiment.New[string](
		experiment.WithPercentage(50),
	)

	exp.Control(func(context.Context) (string, error) {
		return fmt.Sprintf("Hello world!"), nil
	})

	exp.Candidate("candidate1", func(context.Context) (string, error) {
		return "Hello candidate", nil
	})

	result, err := exp.Run(context.Background())
	if err != nil {
		panic(err)
	} else {
		fmt.Println(result)
	}
}

The example above will still only print Hello world!. The candidate1 function will however run in the background 50% of the time.

Run

Run(context.Context) will run the experiment and return the value and error of the control function. The control function is always executed. The result value of the Run(context.Context) function is an interface. The user should cast this to the expected type.

Force

Force(bool) allows you to force run an experiment and overrules all other options. This can be used in combination with feature flags or to always run the experiment for admins for example.

Ignore

Ignore(bool) will disable the experiment, meaning that it will only run the control function, nothing else.

Compare

Compare(any, any) bool is used to compare the control value against a candidate value.

If the candidate returned an error, this will not be executed.

Clean

Clean(any) any is used to clean the output values. This is implemented so that the publisher could use this cleaned data to store for later usage.

If the candidate returned an error, this will not be executed and the CleanValue field will be populated by the original Value.

Limitations and caveats

Stateless

Due to the fact that it is not guaranteed that a test will run every time or in what order a test will run, it is suggested that experiments only do stateless changes.

When enabling the WithConcurrency() option, keep in mind that your tests will run concurrently in a random fashion. Make sure accessing your data concurrently is allowed.

Performance

By default, the candidates run sequentially. This means that there could be a significant performance degradation due to slow new functionality.

Memory leaks

When running with the WithConcurrency() option, the tests will run concurrently and the control result will be returned as soon as possible. This does however mean that the other candidates are still running in the background. Be aware that this could lead to potential memory leaks and should thus be monitored closely.

Observation

An Observation contains several attributes. The first one is the Value. This is the value which is returned by the control function that is specified. There is also an Error attribute available, which contains the error returned.

Errors

Regular errors

When the control errors, this will be returned in the Run(context.Context) method. When a candidate errors, this will be attached to the Error field in its observation.

An error marks the experiment as a failure.

Panics

When the control panics, this panic will be respected and actually be triggered. When a candidate function panics, the experiment will swallow this and assign this to the Panic field of the observation, which you can use in the Publisher. An ErrCandidatePanic will also be returned.

Config

WithConcurrency()

If the WithConcurrency() configuration option is passed to the constructor, the experiment will run its candidates in parallel. The result of the control will be returned as soon as it's finished. Other work will continue in the background.

This is disabled by default.

WithPercentage(int)

WithPercentage(int) allows you to set the amount of time you want to run the experiment as a percentage. Force and Ignore do not have an impact on this.

This is set to 0 by default to encourage setting a sensible percentage.

Publishers

Publishers are used to send observation data to different locations to be able to get insights into said observations. There is a simple Publisher, the LogPublisher, which writes all observations to the logger you provide in it.

WithPublisher(Publisher)

WithPublisher(Publisher) marks the experiment as Publishable. This means that all the results will be pushed to the Publisher once the experiment has run.

This is nil by default.

LogPublisher

By default, there is the LogPublisher. This Publisher will log out the Observation values through a provided logger or the standard library logger.

func main() {
	exp := experiment.New[string](
		experiment.WithPercentage(50),
	).WithPublisher(experiment.NewLogPublisher[string]("publisher", nil))

	exp.Control(func(context.Context) (string, error) {
		return fmt.Sprintf("Hello world!"), nil
	})

	exp.Candidate("candidate1", func(context.Context) (string, error) {
		return "Hello candidate", nil
	})

	exp.Force(true)

	result, err := exp.Run(context.Context)
	if err != nil {
		panic(err)
	} else {
		fmt.Println(result)
	}
}

When the experiment gets triggered, this will log out

[Experiment Observation: publisher] name=control duration=10.979µs success=false value=Hello world! error=<nil>
[Experiment Observation: publisher] name=candidate1 duration=650ns success=false value=Hello candidate error=<nil>