Go Server SDK
Introduction
The Go Server SDK is a server-side SDK that evaluates configs and their targeting rules. Upon initialization, it retrieves configs and targeting rules from ConfigDirector services. From that point on, it evaluates configs locally for any user context, and receives updates via server sent events (SSE) when configs are updated on the dashboard or via the admin API.
The minimum Go version supported is 1.26.
The client is safe to share between goroutines. Create one when your application starts, share it for the lifetime of the process, and close it on shutdown. Evaluations read config state the client already holds in memory, so they make no network calls on the request path.
Installation
The SDK can be installed with go get. Its package reference is on pkg.go.dev: https://pkg.go.dev/github.com/ConfigDirector/go-sdks/configdirector
go get github.com/ConfigDirector/go-sdks/configdirector@latest
Configure and initialize the client
- Create a client providing your server SDK key. You can retrieve a server SDK key for each environment under
SDK Keysin the dashboard's navigation panel. The client starts connecting as soon as it is created. - Wait for the client to be ready before serving requests.
import (
"context"
"log"
"github.com/ConfigDirector/go-sdks/configdirector"
)
// IMPORTANT: Do not commit the server SDK key to your source code, it is a secret value.
// Creating the client starts its connection in the background.
client, err := configdirector.NewClient(configdirector.Options{SDKKey: "YOUR-SERVER-SDK-KEY"})
if err != nil {
log.Fatal(err)
}
defer client.Close()
// Blocks until the first config state arrives or the connection timeout elapses.
// If the wait times out, the client keeps connecting in the background and
// reads return their in-code default value until config state arrives.
if err := client.WaitForReady(context.Background()); err != nil {
log.Printf("ConfigDirector is not ready yet: %v", err)
}
The client is safe to share between goroutines. Create one at startup, share it for the lifetime of the process, and call Close on shutdown.
WaitForReady blocks until the first config state arrives, the connection timeout elapses, or the context you pass ends, whichever comes first. It returns nil once the client is ready, context.DeadlineExceeded when the connection timeout elapsed first, the context's own error when it ended first, and ErrClientClosed when the client was closed. An error only says that the wait ended: the client keeps connecting in the background and becomes ready when config state arrives. Until then, every read returns its in-code default value. Ready reports whether config state has arrived.
Additional configuration options
Every option is a field of the Options struct passed to NewClient. Only SDKKey is required; a zero value in any other field means the default described below. An option the client cannot use, such as a blank SDK key, an unknown connection mode, or a relative URL, is returned as an error from NewClient rather than as a client that quietly never updates.
Metadata
The Metadata option allows you to provide your application's name and version. These values can be used in targeting rules conditionals. For example, if a certain feature should only be enabled starting with a certain version of your application.
client, err := configdirector.NewClient(configdirector.Options{
SDKKey: "YOUR-SERVER-SDK-KEY",
Metadata: configdirector.Metadata{AppName: "YOUR-APP-NAME", AppVersion: "1.0.2"},
})
Logger
The SDK logs through the standard library's log/slog package. By default it writes to slog.Default(), so it follows whatever handler and level your application configures. Every record carries the attribute component=configdirector, which makes the SDK's records easy to tell apart from your application's own.
Pass a *slog.Logger in the Logger option to send the SDK's records somewhere else, or to give them their own level:
import (
"log/slog"
"os"
)
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
client, err := configdirector.NewClient(configdirector.Options{
SDKKey: "YOUR-SERVER-SDK-KEY",
Logger: logger,
})
At the debug level the SDK logs every config update it receives.
Connection
The Connection option takes a ConnectionOptions struct with the following optional fields:
Mode- The connection mode, which can be either
ConnectionModeStreamingorConnectionModePolling. It is recommended to use the default of streaming unless you have a specific need to use polling instead. - Defaults to
ConnectionModeStreaming
- The connection mode, which can be either
PollingInterval- Only used in polling mode. How long the client waits between polls of ConfigDirector services for updates, as a
time.Duration. A value below the minimum of 1 minute is raised to 1 minute and a warning is logged. See Polling intervals. - Defaults to 5 minutes
- Only used in polling mode. How long the client waits between polls of ConfigDirector services for updates, as a
Timeout- How long
WaitForReadywaits for the first config state, as atime.Duration. If the timeout is reached,WaitForReadyreturnscontext.DeadlineExceededbut the client is still not ready and returns in-code default values. The client will continue to attempt to connect and retrieve config values in the background. - Defaults to 3 seconds
- How long
URL- The base URL used to connect to ConfigDirector services
- This should only be provided if your environment requires you to configure a proxy server in order to connect to ConfigDirector services
HTTPClient- The
*http.Clientthat sends every request. Provide one to control transport settings such as a corporate proxy or custom TLS. - Defaults to
http.DefaultClient
- The
import "time"
client, err := configdirector.NewClient(configdirector.Options{
SDKKey: "YOUR-SERVER-SDK-KEY",
Connection: configdirector.ConnectionOptions{
Mode: configdirector.ConnectionModePolling,
PollingInterval: 10 * time.Minute,
Timeout: 5 * time.Second,
},
})
Telemetry
Telemetry tuning. It is unlikely these settings need to be adjusted. However, in cases where your application has a large number of evaluations per second, you can adjust these settings to tune the memory footprint and frequency of telemetry requests.
Keep in mind that ConfigDirector relies on these telemetry events to provide insights and features related to the configs being used.
The Telemetry option takes a TelemetryOptions struct with the following optional fields:
EventQueueLimit- The size limit of telemetry event queues. If the size limit is reached before the events are flushed to the network, older events will be dropped.
- ConfigDirector keeps a count of dropped events. If the number of dropped events is higher than 50% of the total events, ConfigDirector will issue an alert in the dashboard.
- A number between 100 and 100,000. Defaults to 5,000.
FlushInterval- How often events are flushed and sent over the network, as a
time.Duration. - Decrease this number if your application consistently captures a large number of events in short periods of time in order to reduce memory footprint from a large event queue.
- Defaults to 30 seconds.
- How often events are flushed and sent over the network, as a
import "time"
client, err := configdirector.NewClient(configdirector.Options{
SDKKey: "YOUR-SERVER-SDK-KEY",
Telemetry: configdirector.TelemetryOptions{EventQueueLimit: 10_000, FlushInterval: 15 * time.Second},
})
Hooks
Hooks are events you can subscribe to in order to be notified of some key actions from the client. In order to subscribe to hooks you can provide handlers in the Hooks option, which attaches them before the client can emit any event, or call the matching method on the client, which returns a function that cancels the registration:
unsubscribe := client.OnConfigsUpdated(func(update configdirector.ConfigsUpdated) {
log.Printf("configs updated: %v", update.Keys)
})
unsubscribe() // Cancels the registration
client, err := configdirector.NewClient(configdirector.Options{
SDKKey: "YOUR-SERVER-SDK-KEY",
Hooks: configdirector.Hooks{
OnConfigEvaluated: func(evaluation configdirector.Evaluation) {
log.Printf("%s evaluated to %v", evaluation.Key, evaluation.Value)
},
},
})
The following hooks are available:
OnReady: func()- Called when the client is initialized. When this event is emitted it means the client has received a payload from the ConfigDirector servers and is ready to evaluate configs. It is emitted once; a handler registered with
OnReadyafter that point is called at once, on the calling goroutine.
- Called when the client is initialized. When this event is emitted it means the client has received a payload from the ConfigDirector servers and is ready to evaluate configs. It is emitted once; a handler registered with
OnConfigsUpdated: func(ConfigsUpdated)- Called when a payload is received from the ConfigDirector servers with config data. It is emitted during initialization when an entire config payload is received. After initialization, it is emitted when updates are pushed from the server (or discovered via polling if that connection mode is used).
Keys []string- The config keys that were included in the payload from the server, and the keys of configs whose targeting rules use a segment the payload included, sorted.RemovedKeys []string- The keys of configs the payload removed, sorted. A config is removed when a full payload no longer includes it. Empty when nothing was removed.
OnConfigEvaluated: func(Evaluation)- Called whenever a config is evaluated. This includes calls to the value methods and evaluations delivered to watches.
Evaluation- A struct containing the details of the evaluation:Key string- The config key that was evaluatedValue any- The value the config evaluated to, in the type the read asked for. It can be the in-code default value provided to the value method or to the watch. For example, if the config was evaluated before the client was initialized.ValueID string- The value ID, which is a stable hash of the value that can be used for analytics or other third parties rather than sending theValue. This can be useful for large values, like a JSON config, or to avoid disclosing the values themselves to third parties. It is empty when the evaluation fell back to the in-code default value.UsedDefault bool- Whether or not the evaluation fell back to the in-code default value.Reason EvaluationReason- The reason for the evaluation resolution. In the case the config successfully evaluated based on server-provided targeting rules, it will beEvaluationReasonFoundMatch. If the evaluation had to fall back to the in-code default value, theReasonwill encode why the fallback was required:EvaluationReasonClientNotReady- The evaluation happened before the client finished initialization, or after it was closedEvaluationReasonConfigStateMissing- The requested config key was not present in the payload received from the server, usually because of an incorrect config keyEvaluationReasonInvalidNumber- The config was requested as a number but the value received from the server could not be read as that number typeEvaluationReasonInvalidBoolean- The config was requested as a boolean but the value received from the server was neithertruenorfalseEvaluationReasonInvalidJSON- The config was requested as JSON but the value received from the server could not be decoded into the targetEvaluationReasonTypeMismatch- The config was requested as a string but it is a boolean or number configEvaluationReasonValueMissing- The config key was present in the payload received from the server, but it carried no value
UserContext UserContext- The user context that was provided to the evaluation
Each reason constant's value is its name in kebab case, such as type-mismatch for EvaluationReasonTypeMismatch, which is how logs and the test client's documentation spell them.
OnReady and OnConfigsUpdated handlers run on the connection's goroutine, so one that blocks delays later updates. OnConfigEvaluated handlers run on the goroutine that read the value, or on the connection's goroutine when a watch fires. A panic in a handler is recovered and logged at error level; the other handlers and the connection continue.
Retrieve config values
Retrieve config values via the typed value methods, and subscribe to updates via the typed watches. The first argument is the config key, the second is the in-code default value, which is returned if the config is not available or cannot be read as the given type, and the third is the user context targeting rules are evaluated for. The method decides the type the config is read as:
user := configdirector.UserContext{ID: "user-id"}
// Retrieve config values
retries := client.IntValue("max-retries", 3, user)
theme := client.StringValue("theme", "light", user)
newCheckout := client.BoolValue("new-checkout", false, user)
// A JSON config is decoded into a value of your own, which holds the in-code default value
limits := map[string]any{"per_minute": 60}
if err := client.JSONValue("rate-limits", &limits, user); err != nil {
log.Fatal(err)
}
// Watch for config updates
unwatch := client.WatchBool("new-checkout", false, user, func(value bool) {
log.Printf("new-checkout is now %t", value)
})
// Call unwatch when the watch is no longer needed
unwatch()
The type a config is read as is decided by the method you call, not by how the config was declared in the dashboard. There is one method per type the SDK can read, so a type it cannot fill is a compile error rather than a surprise at runtime. Each one takes the value to return when the config is missing, the client is not ready, or the value will not read as the requested type, so the in-code default value should always be the safe choice:
BoolValue(key string, defaultValue bool, userContext UserContext) boolStringValue(key string, defaultValue string, userContext UserContext) stringIntValue(key string, defaultValue int64, userContext UserContext) int64FloatValue(key string, defaultValue float64, userContext UserContext) float64JSONValue(key string, target any, userContext UserContext) error
A few of those are worth calling out:
- Reading a string, enum, URL or JSON config as a string returns the value as the server spelled it, with no parsing, so a JSON config gives its JSON. A boolean or number config read as a string gives the in-code default value instead.
- Only
trueandfalseread as a boolean, in either casing. A config holding1yields the in-code default value. - A whole number the server wrote as
26.0or2.6e1still reads as26. A value anint64cannot hold yields the in-code default value rather than an overflow.
Every value method has a ValueDetails twin, such as BoolValueDetails, that also returns an EvaluationDetails with the Reason, ValueID and UsedDefault of the read, so your code can tell a served value from a fallback:
newCheckout, details := client.BoolValueDetails("new-checkout", false, user)
if details.UsedDefault {
log.Printf("new-checkout fell back to its in-code default value: %s", details.Reason)
}
ValueDetails twins and through OnConfigEvaluated.JSON configs
JSONValue decodes the config's JSON document into target, a non-nil pointer to a struct, map, slice or any other type encoding/json can decode into. The value you put in the target beforehand is the in-code default value: the target is left as it was when the config is missing, the client is not ready, or the document cannot be decoded into it. The only error is for a target that is not a non-nil pointer.
type RateLimits struct {
PerMinute int `json:"per_minute"`
}
limits := RateLimits{PerMinute: 60}
if err := client.JSONValue("rate-limits", &limits, user); err != nil {
log.Fatal(err)
}
encoding/json ignores any member the target does not declare, and leaves a field the document does not carry as it was, so a config whose shape has moved on cannot be told apart from your in-code default value. Decode into a map[string]any to read the document as it stands.Evaluate config values with a user context
Every value method takes a UserContext as its last argument, which is what targeting rules are evaluated against. The same key can therefore resolve differently per user. Pass an empty configdirector.UserContext{} when there is no user to evaluate for.
The user context is the last argument of every value method. Unlike client SDKs, the server SDKs are able to evaluate targeting rules for the given user context locally without additional network calls.
import "github.com/ConfigDirector/go-sdks/configdirector"
user := configdirector.UserContext{
ID: "12345",
Name: "Example User",
Traits: map[string]any{
"region": "North America", // Any arbitrary traits which can be referenced in targeting rules
},
}
client.BoolValue("my-boolean-config-key", false, user)
It is also the third argument of every watch:
unwatch := client.WatchBool("new-checkout", false, user, func(value bool) {
log.Printf("new-checkout is now %t", value)
})
UserContext is a struct with the following fields:
ID- The user's identifier. It decides their bucket in a percentage rollout, so changing it can move a user into a different bucket. Leave it empty for a user without a stable identity.
Name- The user's display name.
Traits- A
map[string]anyof arbitrary traits which can be referenced in targeting rules, keyed by the name the rule references. Each trait is evaluated as the valueencoding/jsonencodes it to: anintis a number, a[]stringis an array of text, a struct is an object keyed by its JSON field names, and atime.Timeis its RFC 3339 text. A traitencoding/jsoncannot encode, such as a channel, matches no condition, and the other traits in the context still match.
- A
Anonymous- Keeps the context out of the dashboard: it is evaluated but never persisted, and telemetry reports neither the context nor its ID.
user := configdirector.UserContext{
ID: "user-id",
Name: "Example User",
Traits: map[string]any{
"region": "North America",
"age": 26,
"tags": []string{"beta", "internal"},
},
}
Watch for updates
The watches mirror the value methods, one per type. Each accepts the config key, the in-code default value used when an update will not read as that type, the user context, and a handler that runs with the newly evaluated value:
WatchBool(key string, defaultValue bool, userContext UserContext, handler func(bool))WatchString(key string, defaultValue string, userContext UserContext, handler func(string))WatchInt(key string, defaultValue int64, userContext UserContext, handler func(int64))WatchFloat(key string, defaultValue float64, userContext UserContext, handler func(float64))WatchJSON(key string, newTarget func() any, userContext UserContext, handler func(target any))
A watch fires whenever an update carries its key or a segment the config's targeting rules use, whether or not the value changed. Registering a watch before the first config state arrives means it is called for that first state as well; one registered afterwards only sees later updates. When a full payload no longer includes the watched config, the handler is called with the in-code default value, so it always agrees with what the value method would return. Handlers run on the connection's goroutine, so one that blocks delays later updates.
unwatch := client.WatchBool(
"new-checkout",
false,
configdirector.UserContext{ID: "user-id", Name: "Example User", Traits: map[string]any{"region": "North America"}},
func(value bool) {
log.Printf("new-checkout is now %t", value)
},
)
WatchJSON takes a function that returns a fresh pointer holding the in-code default value, rather than the value itself, because every update decodes into a new target:
unwatch := client.WatchJSON(
"rate-limits",
func() any { return &RateLimits{PerMinute: 60} },
user,
func(target any) {
limits := target.(*RateLimits)
log.Printf("rate-limits is now %d per minute", limits.PerMinute)
},
)
Each watch returns a function that cancels it. Calling it twice is harmless.
Other useful client features
The client provides additional methods.
Ready
Returns a boolean indicating if the client has received config state and is ready to evaluate configs. It is initially false and becomes true once the first config state arrives. Until it does, every read returns its in-code default value.
Closed
Returns a boolean indicating if the client has been closed. A closed client cannot be reopened.
AllConfigs
Returns the evaluated state of every config the client holds as a []ConfigState sorted by key, each with the config's ID, Key, Type, Value as the server spelled it, and ValueID. It takes the user context to evaluate for and, optionally, the config keys to restrict the result to. It is empty until the client is ready.
This is intended for handing state to a client SDK to hydrate with. It records no telemetry and reaches no OnConfigEvaluated handler, since the SDK that receives the state reports its own evaluations.
UnwatchAll
Cancels every watch on every config. Handlers registered with OnReady, OnConfigsUpdated and OnConfigEvaluated stay.
Close
Closes the connection to ConfigDirector services, reports whatever telemetry is pending, and cancels every watch and event handler. After Close the client is not ready and every read returns its in-code default value. Calling Close more than once is harmless and returns the first call's result.
Only close when your application shuts down and it will no longer make use of the client.
Test your code
Use the SDK's configdirectortest package to test the code that reads your configs and flags. It creates a test client: the SDK's real client wired to an in-memory connection that your test controls. Only the connection to ConfigDirector and the telemetry are replaced. Everything else is the same code that runs in production, so the code under test behaves exactly as it does against ConfigDirector, and no network connection is opened, no telemetry is sent, and no goroutine is started. The package ships in the module, so there is nothing else to install.
import (
"testing"
"github.com/ConfigDirector/go-sdks/configdirector/configdirectortest"
"github.com/stretchr/testify/require"
)
func TestNewCheckout(t *testing.T) {
testClient := configdirectortest.NewTestClient(map[string]any{"new-checkout": true, "max-items": 20})
t.Cleanup(func() { require.NoError(t, testClient.Close()) })
service := NewCheckoutService(testClient.Client())
require.True(t, service.IsNewCheckoutEnabled("user-123"))
require.NoError(t, testClient.SetValue("new-checkout", false))
require.False(t, service.IsNewCheckoutEnabled("user-123"))
}
testClient.Client() is a *configdirector.Client, so it goes anywhere your code accepts one. It is ready when NewTestClient returns, with the values delivered as its first update, so the code under test can read at once; HoldInitialization() keeps it not ready instead, for testing what your code does before config state arrives. Pass the client to the code under test as a parameter, as NewCheckoutService does above, rather than reaching for a package-level one: that is what lets the test hand it a client it controls. The examples use require from the testify module; the standard testing package works just as well.
Values
NewTestClient, SetValue and ReplaceValues take Go values, and the config type follows from each value: a bool; any integer kind, time.Duration included (an integer config); a float32 or float64 (a float config); a string; or a map, slice, array or struct (a JSON config holding what encoding/json writes for it). A string is always a string config, so JSON text meant as a JSON config is given as a json.RawMessage. Every value is served as an unconditional config, so every user context receives the same value; a test that needs different values per context is written as one test per value. A nil value, a blank key, a non-finite number, a value that does not encode as JSON, or a value of any other kind, such as a pointer or a channel, is an error from SetValue and ReplaceValues and a panic from NewTestClient; in every case nothing changes.
Reads behave as they do in production: SetValue("k", true) read with StringValue returns the in-code default value with the type-mismatch reason, SetValue("k", 2.5) read with IntValue returns the default with the invalid-number reason, and SetValue("k", "") serves the in-code default value with the value-missing reason. The ValueDetails methods and OnConfigEvaluated handlers report the reason.
Controls
| Control | Effect |
|---|---|
SetValue(key, value) | Stores the value and, once the client has received its first update, delivers an update carrying only key. Watches of key, OnConfigsUpdated handlers and reads see it before the call returns. |
RemoveValue(key) | Removes the value and delivers a full update without it. Reads return the in-code default value with the config-state-missing reason, watches of key receive the default, and OnConfigsUpdated lists key in RemovedKeys. |
ReplaceValues(values) | Replaces every stored value and delivers a full update. Use it to reset a test client shared across tests. |
CompleteInitialization() | Delivers the stored values to a client built with HoldInitialization(), on the calling goroutine, so the client is ready and a blocked WaitForReady has returned when the call returns. On a ready client it does nothing. |
FailInitialization() | Fails the held initialization the way a refused SDK key does: the failure is logged at error level and the client stays not ready, so reads return the in-code default value with the client-not-ready reason and a WaitForReady in flight ends with its timeout or its context. CompleteInitialization() still delivers the stored values afterwards. On a ready client it does nothing. |
import "time"
testClient := configdirectortest.NewTestClient(
map[string]any{"new-checkout": true},
configdirectortest.HoldInitialization(),
configdirectortest.WithTimeout(500*time.Millisecond),
)
t.Cleanup(func() { require.NoError(t, testClient.Close()) })
service := NewCheckoutService(testClient.Client())
testClient.FailInitialization()
require.False(t, testClient.Client().Ready())
require.False(t, service.IsNewCheckoutEnabled("user-123"))
Options
NewTestClient(values, options...) takes HoldInitialization(), described above; WithTimeout, the client's connection timeout, which bounds how long a held WaitForReady waits and defaults to the SDK's production timeout; WithLogger, which defaults to slog.Default() as for a production client; and WithHooks, handlers attached before the first update as Options.Hooks attaches them, so an OnConfigsUpdated hook sees the values arrive and an OnReady hook sees the client become ready.
What to expect
- Watches and handlers run on the goroutine that calls
NewTestClient,SetValue,RemoveValue,ReplaceValuesorCompleteInitialization, not on a connection goroutine, so an assertion can follow the call directly. A change made from inside a handler is delivered after the update being handled, as it would be by the single connection goroutine in production. - The first update fires
OnConfigsUpdatedbeforeOnReady, as in production. WithHoldInitialization(), handlers and watches registered beforeCompleteInitializationsee it. - A held
WaitForReadythat times out leaves the client not ready, andCompleteInitializationafterwards still makes it ready, as production does when config state arrives after the wait. - Watches fire on every update carrying their key, whether or not the value changed, as in production.
RemoveValueandReplaceValuesdeliver a full update, so they also fire the watches of every remaining key. - A panic in a watch or handler is recovered and logged, as it is in production. A failed
requireassertion inside a handler ends the test as it does anywhere else, because the handler runs on the test's goroutine. - After
Close, reads return the in-code default value with theclient-not-readyreason, every handler and watch is cancelled, the controls store without delivering, and no goroutine is left running, because none was started.