experiment

package module
v3.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 8, 2023 License: MIT Imports: 5 Imported by: 0

README

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>

Documentation

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type BeforeFunc

type BeforeFunc func(context.Context) error

BeforeFunc represents the function that gets run before the experiment starts. This function will only run if the experiment should run. The functionality should be defined by the user.

type CandidateFunc

type CandidateFunc[C any] func(context.Context) (C, error)

CandidateFunc represents a function that is implemented by a candidate. The value returned is the value that will be used to compare data.

type CandidatePanicError

type CandidatePanicError struct {
	Name  string
	Panic interface{}
}

ErrCandidatePanic represents the error that a candidate panicked.

func (CandidatePanicError) Error

func (e CandidatePanicError) Error() string

Error returns a simple error message. It does not include the panic information.

type CleanFunc

type CleanFunc[C any] func(C) C

CleanFunc represents the function that cleans up the output data. This function will only be called for candidates that did not error.

type CompareFunc

type CompareFunc[C any] func(C, C) bool

CompareFunc represents the function that takes two candidates and knows how to compare them. The functionality is implemented by the user. This function will only be called for candidates that did not error.

type Config

type Config struct {
	Percentage  int
	Concurrency bool
	Timeout     *time.Duration
}

Config represents the configuration options for an experiment.

type ConfigFunc

type ConfigFunc func(*Config)

ConfigFunc represents a function that knows how to set a configuration option.

func WithConcurrency

func WithConcurrency() ConfigFunc

WithConcurrency forces the experiment to run concurrently

func WithDefaultConfig

func WithDefaultConfig() ConfigFunc

WithDefaultConfig returns a new configuration with defaults.

func WithPercentage

func WithPercentage(p int) ConfigFunc

WithPercentage returns a new func(*Config) that sets the percentage.

func WithTimeout

func WithTimeout(t time.Duration) ConfigFunc

type Experiment

type Experiment[C any] struct {
	// contains filtered or unexported fields
}

Experiment represents a new refactoring experiment. This is where you'll define your control and candidates on and this will run the experiment according to the configuration.

func New

func New[C any](cfgs ...ConfigFunc) *Experiment[C]

New creates a new Experiment with the given configuration options.

func (*Experiment[C]) Before

func (e *Experiment[C]) Before(fnc BeforeFunc)

Before filter to do expensive setup only when the experiment is going to run. This will be skipped if the experiment doesn't need to run. A good use case would be to do a deep copy of a struct.

func (*Experiment[C]) Candidate

func (e *Experiment[C]) Candidate(name string, fnc CandidateFunc[C]) error

Candidate represents a refactoring solution. The order of candidates is randomly determined. If the concurrent configuration is given, candidates will run concurrently. If a candidate panics, your application will not panic, but the candidate will be marked as failed. If the name is control, this will panic.

func (*Experiment[C]) Clean

func (e *Experiment[C]) Clean(fnc CleanFunc[C])

Clean will cleanup the state of a candidate (control included). This is done so the state could be cleaned up before storing for later comparison.

func (*Experiment[C]) Compare

func (e *Experiment[C]) Compare(fnc CompareFunc[C])

Compare represents the comparison functionality between a control and a candidate.

func (*Experiment[C]) Control

func (e *Experiment[C]) Control(fnc CandidateFunc[C])

Control represents the control function, this resembles the old or current implementation. This function will always run, regardless of the configuration percentage or overwrites. If this function panics, the application will panic. The output of this function will be the base to what all the candidates will be compared to.

func (*Experiment[C]) Force

func (e *Experiment[C]) Force(f bool)

Force lets you overwrite the percentage. If set to true, the candidates will definitely run.

func (*Experiment[C]) Ignore

func (e *Experiment[C]) Ignore(i bool)

Ignore lets you decide if the experiment should be ignored this run or not. If set to true, the candidates will not run.

func (*Experiment[C]) Publish

func (e *Experiment[C]) Publish(ctx context.Context) error

Publish will publish all observations of the experiment to the configured publisher. This will publish all observations, regardless if one errors or not. It returns a PublishError which contains all underlying errors.

func (*Experiment[C]) Run

func (e *Experiment[C]) Run(ctx context.Context) (C, error)

Run runs all the candidates and control in a random order. The value of the control function will be returned. If the concurrency configuration is given, this will return as soon as the control has finished running.

func (*Experiment[C]) WithPublisher

func (e *Experiment[C]) WithPublisher(pub Publisher[C]) *Experiment[C]

WithPublisher configures the publisher for the experiment. The publisher must have the same type associated as the experiment.

type LogPublisher

type LogPublisher[C any] struct {
	Name   string
	Logger Logger
}

LogPublisher is a publisher that writes out the observation values as a log line. If no Logger is provided, the standard library logger will be used.

Example
package main

import (
	"context"
	"fmt"

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

func main() {
	exp := experiment.New[string]().
		WithPublisher(experiment.NewLogPublisher[string]("publisher", &fmtLogger{}))

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

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

}

type fmtLogger struct{}

func (l *fmtLogger) Printf(s string, a ...interface{}) {
	fmt.Printf(s, a...)
}
Output:

Hello world!

func NewLogPublisher

func NewLogPublisher[C any](name string, logger Logger) *LogPublisher[C]

NewLogPublisher returns a new LogPublisher.

func (*LogPublisher[C]) Publish

func (l *LogPublisher[C]) Publish(_ context.Context, o Observation[C]) error

Publish will publish all the Observation variables as a log line. It is in the following format: [Experiment Observation] name=%s duration=%s success=%t value=%v error=%v

type Logger

type Logger interface {
	Printf(string, ...interface{})
}

Logger represents the interface that experiment expects for a logger.

type Observation

type Observation[C any] struct {
	Duration     time.Duration
	Error        error
	Success      bool
	Name         string
	Value        C
	CleanValue   C
	ControlValue C
}

Observation represents the outcome of a candidate that has run.

type PublishError

type PublishError struct {
	// contains filtered or unexported fields
}

PublishError is an error used when the publisher returns an error. It combines all errors into a single error.

func (*PublishError) Error

func (e *PublishError) Error() string

func (*PublishError) Unwrap

func (e *PublishError) Unwrap() []error

type Publisher

type Publisher[C any] interface {
	Publish(context.Context, Observation[C]) error
}

Publisher represents an interface that allows you to publish results.

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL