exd Go SDK (github.com/manasgarg/exd/sdks/go/exdclient)
The Go SDK is a thin Go facade around a CGO build of the
exd-client Rust crate. The Rust core owns the lint pipeline and
eval engine; Go handles ergonomic wrappers, error projections, and
the no-FFI ExdRemote HTTP path. This is the same architecture the
Java SDK uses with JNI and the Python SDK uses with PyO3.
Phase status. Current surfaces: in-memory
LintFiles,Namespace.FromFiles/FromDir/FromURI, typed eval (Eval/EvalBool/EvalString/EvalInt/EvalFloat/EvalJSON/EvalAll) plus*WithOptionsvariants carrying the rollout-workflowinclude_testingopt-in, refresh / replace, namespace inspection accessors, the no-FFIExdRemote, a goroutine-basedPoller, anExdClientGroupmulti-namespace bundle, a v2 closure-delta SSE consumer with hash-verified inline + snapshot apply, the no-FFIManifestClientwrite path (Pull/Push/Versions/Diff), and a telemetry subpackage (exdclient/telemetry) withEvaluationRecordbuilder and console / stdout / in-memory / NDJSON-file sinks. The test contract is intestplan/18-go-sdk.md.
Installation
go get github.com/manasgarg/exd/sdks/go/exdclient
import "github.com/manasgarg/exd/sdks/go/exdclient"
The Go SDK uses CGO to link against the
libexd_client_go.{so,dylib,dll} cdylib built from
sdks/go/ffi-rust/. Consumers need a working C toolchain (cc or
gcc) and cargo on PATH at build time. Cross-compilation
requires the matching Rust target toolchain.
To build the native library before invoking go build:
go generate ./... # runs: cargo build -p exd-client-go-ffi --release
The CGO directives in ffi.go expect the cdylib at
<repo>/target/release/. When consuming the SDK from outside the
monorepo, set CGO_LDFLAGS / CGO_CFLAGS to point at the
installed library and exd_client_go.h header.
Go 1.22+ supported. Tested under CGO with glibc on Linux x86_64; macOS x86_64/arm64 supported with the same generate-then-build flow.
Architecture
The repo layout is:
sdks/go/
├── ffi-rust/ # Rust C-ABI binding crate
│ ├── Cargo.toml # cdylib + staticlib, depends on crates/exd-client
│ ├── include/exd_client_go.h # C header consumed by cgo
│ └── src/lib.rs # #[no_mangle] extern "C" surface
├── exdclient/ # the published Go module
│ ├── go.mod # module github.com/manasgarg/exd/sdks/go/exdclient
│ ├── ffi.go # CGO directives, handle wrapper, string marshaling
│ ├── lint.go # LintFiles
│ ├── namespace.go # FromFiles, FromDir, FromURI, accessors, refresh
│ ├── eval.go # Eval, EvalBool, EvalString, EvalInt, EvalFloat, EvalJSON
│ ├── context.go # EvalContext builder
│ ├── result.go # EvalResult, RuleMatched
│ ├── errors.go # LintError, LoadError, EvalError
│ ├── remote.go # ExdRemote (no-FFI HTTP eval client)
│ └── util.go # SHA256Hex, BuildVariant
└── ci/
└── build.sh # cargo build + go test
FFI exchange format
The C ABI is a small set of extern "C" functions in
sdks/go/ffi-rust/src/lib.rs, mirroring the Java JNI surface:
- Manifest file maps, contexts, lint reports, and
EvalResults travel as JSON strings. - Scalar eval results are typed
bool/int64_t/double. Namespacehandles are opaque*mut c_void, owned by Rust, released throughexd_namespace_free.- Every string returned through a
char**out-parameter MUST be released viaexd_string_free. The Go layer hides this with agoStringAndFreehelper.
Status-code convention
Every FFI function returns an int32_t:
| Status | Meaning | out_err payload |
|---|---|---|
0 | Success | empty |
1 | Lint failure | LintReport as JSON |
2 | Load failure | human message |
3 | Eval failure | kind:message |
4 | Invalid argument | human message |
The Go layer projects these back into *LintError, *LoadError,
*EvalError, or plain error values.
Memory model
Every *Namespace returned by FromFiles/FromDir/FromURI owns a
Rust-side Box<Namespace>. Callers should invoke Close() to
release it. A runtime.SetFinalizer is a safety net but does not
replace explicit cleanup — Go's GC may delay the finalizer
indefinitely.
*Namespace values are safe for concurrent use; the Rust core
swaps state behind an Arc mutex on refresh, and in-flight
evaluations hold their own snapshot.
Public API
Lint
files := map[string]string{
"namespace.toml": "...",
"flags/checkout.toml": "...",
}
report, err := exdclient.LintFiles("demo", files)
if err != nil { /* I/O or input error */ }
if !report.Passed {
for _, d := range report.Errors {
fmt.Printf("%s %s:%d %s\n", d.Code, d.File, d.Line, d.Message)
}
}
LintReport mirrors crates/exd-client::lint::LintReport:
namespace slug, optional manifest version, error/warning/info
slices, and a Passed boolean.
Namespace
ns, err := exdclient.FromFiles("demo", files)
if err != nil {
var lerr *exdclient.LintError
if errors.As(err, &lerr) {
for _, d := range lerr.Report.Errors {
fmt.Println(d.Code, d.Message)
}
}
return err
}
defer ns.Close()
fmt.Println(ns.Slug(), ns.Environments(), ns.Flags(), ns.FlagVersion())
FromDir(path string) and FromURI(uri string) are the
filesystem and URI loaders; they delegate to the Rust core's
Namespace::from_dir and Namespace::from_uri. Supported URI
schemes match the Rust SDK (file://, https://, http://,
git+file/https/ssh://, exd://).
Eval
The plain Eval returns a structured result (or nil for
unknown flag/env, matching the Rust SDK's Option<EvalResult>):
ctx := exdclient.NewContext().Set("country", "US").Set("user_id", "u-42")
res, err := ns.Eval("checkout", "production", ctx)
if err != nil { return err }
if res == nil { /* unknown flag or env */ }
switch res.Rule.Kind {
case exdclient.RuleMatchedRule:
fmt.Println("matched rule", res.Rule.Index)
case exdclient.RuleMatchedDefault:
fmt.Println("fell through to default")
case exdclient.RuleMatchedAttrTypeMismatch:
fmt.Println("attr type mismatch on", res.Rule.Attribute)
}
fmt.Println(res.VariantKey, res.Value)
Typed eval surfaces type mismatches as *EvalError:
ok, err := ns.EvalBool("dark-mode", "production", ctx)
var ee *exdclient.EvalError
if errors.As(err, &ee) {
switch ee.Kind {
case exdclient.EvalErrUnknownFlag, exdclient.EvalErrUnknownEnvironment:
// fall back to a hard-coded default
case exdclient.EvalErrFlagTypeMismatch, exdclient.EvalErrAttrTypeMismatch:
// manifest bug; alert
}
}
EvalAll(env, ctx) returns map[string]EvalResult for every flag
in the namespace.
Refresh / replace
if err := ns.Refresh(); err != nil { ... }
report, err := ns.ReplaceFromFiles("demo", newFiles)
Refresh re-reads from the original source (dir or URI); ReplaceFromFiles
swaps the typed state from a fresh in-memory snapshot. Both
preserve the slug-mismatch guard and bump FlagVersion() on
success.
Remote eval — ExdRemote
ExdRemote is the no-FFI path: it posts evaluation requests to
an exd-server instance and returns the server-side resolved
EvalResult. The wire format is identical to the Python and Java
SDKs' ExdRemote — see docs/spec/server-api.md § Evaluate.
client := exdclient.NewExdRemote(
"https://flags.example.com",
"acme", "demo",
"exd_namespace-client_xxx",
nil,
)
res, err := client.Eval(ctx, "checkout", "production",
exdclient.NewContext().Set("country", "US"))
Use &exdclient.RemoteOptions{IncludeTesting: true} to opt the
connection into rollout-workflow rules (the top-level
include_testing body field).
Errors
| Type | Triggered by |
|---|---|
*LintError | Manifest fails lint during FromFiles/FromDir/FromURI/Refresh/ReplaceFromFiles. |
*LoadError | I/O, fetch, slug-mismatch, builder errors. |
*EvalError | Typed eval against a wrong-typed flag, unknown flag, unknown environment, or attr type mismatch. |
plain error | Argument decoding, JSON marshal/unmarshal, transport-layer failures. |
Build pipeline
# From the repo root:
cargo build -p exd-client-go-ffi --release
go -C sdks/go/exdclient test ./...
Or via the bundled helper:
./sdks/go/ci/build.sh
Single-validator invariant
Every entry point that touches manifest text — LintFiles,
FromFiles, FromDir, FromURI, ReplaceFromFiles — delegates
through the C-ABI binding to exd_client::lint_files (in-memory)
or Namespace::from_* (filesystem / URI). There is no Go-side
reimplementation of lint, eval, or any other manifest validation.
This invariant is asserted in
testplan/18-go-sdk.md § Rust
equivalence.
Polling — Poller
StartPolling(ns, opts) runs ns.Refresh() on a goroutine at a
fixed cadence and surfaces failures via callbacks. The underlying
Namespace keeps serving evaluations from its current snapshot
whenever a refresh fails; only successful refreshes advance the
typed state.
p, err := exdclient.StartPolling(ns, exdclient.PollOptions{
Interval: 30 * time.Second,
OnRefresh: func(version uint64) { log.Printf("manifest bumped to %d", version) },
OnError: func(err error) { log.Printf("refresh failed: %v", err) },
})
if err != nil { return err }
defer p.Stop()
Poller does NOT take ownership of ns — callers remain
responsible for ns.Close(). Stop() is idempotent.
Multi-namespace — ExdClientGroup
A static collection of pre-loaded *Namespace values addressable
by slug. Mirrors the Rust SDK's ExdClientGroup for the no-shared-
refresh shape; pair with StartPolling per member when freshness
matters.
g, err := exdclient.NewExdClientGroup(map[string]*exdclient.Namespace{
"checkout-team": nsA,
"rollout-team": nsB,
})
defer g.Close()
res, err := g.Eval("checkout-team", "flag-a", "production", ctx)
g.Close() releases every member's underlying Rust handle; callers
SHOULD NOT also call ns.Close() on a member.
SSE / closure-delta — OpenEventStream
OpenEventStream(ns, server, opts) connects to
/api/v1/events, applies snapshot and inline events to ns via
ReplaceFromFiles, and re-binds the typed state in lockstep with
the server. The wire format is protocol v2 per
docs/spec/server-api.md § Event Stream: closure hashes are
verified pre-apply and post-apply, per-file SHA-256 is checked
against content_b64, and hash mismatches trigger a recoverable
error reported via OnError (the next snapshot or resync event
recovers state).
stream, err := exdclient.OpenEventStream(ns, "https://flags.example.com",
exdclient.EventStreamOptions{
Token: "exd_read_xxx",
Members: []exdclient.MemberSubscription{
{Slug: "demo", Flags: []string{"*"}},
},
OnSnapshot: func(info exdclient.SnapshotInfo) { /* ... */ },
OnUpdate: func(info exdclient.UpdateInfo) { /* ... */ },
OnError: func(err error) { /* recoverable */ },
})
if err != nil { return err }
defer stream.Stop()
For multi-namespace fan-in over a single connection, use
OpenGroupEventStream(group, server, opts) with an
*ExdClientGroup. One SSE connection feeds every group member;
events are dispatched by the wire-level namespace field, and
each member has an apply mutex that isolates per-member failures:
gs, err := exdclient.OpenGroupEventStream(group, "https://flags.example.com",
exdclient.GroupEventStreamOptions{
Token: "exd_read_xxx",
Members: []exdclient.MemberSubscription{
{Slug: "billing", Flags: []string{"checkout-redesign"}},
{Slug: "growth", Flags: []string{"*"}},
},
OnUpdate: func(slug string, info exdclient.UpdateInfo) { /* per-member */ },
})
defer gs.Stop()
Manifest write path — ManifestClient
Pure-Go (no FFI) client for the exd-server write endpoints.
Mirrors the Rust SDK's ManifestClient field-for-field.
m := exdclient.NewManifestClient("https://flags.example.com", "exd_write_xxx")
// Pull current
res, err := m.Pull(ctx, "acme", "demo", "")
if err != nil { return err }
// Push new version with optimistic concurrency
v := uint64(8)
push, err := m.Push(ctx, "acme", "demo", updatedFiles, &v)
// List version history (paginated automatically)
versions, err := m.Versions(ctx, "acme", "demo")
// Byte-level diff between two pulled versions
diff, err := m.Diff(ctx, "acme", "demo", 7, 8)
Push failures surface as *VersionConflictError (HTTP 409) or
*LintFailedError (HTTP 422, carries the full LintReport).
Telemetry — exdclient/telemetry
The subpackage github.com/manasgarg/exd/sdks/go/exdclient/telemetry
mirrors crates/exd-client/src/telemetry/ field-for-field. Build
records via telemetry.BuildRecord(...); fan them out via a
SinkSet.
*Namespace satisfies telemetry.Bridge, so the entry point is:
import tel "github.com/manasgarg/exd/sdks/go/exdclient/telemetry"
sinks := tel.NewSinkSet(
tel.NewConsoleSink(),
tel.NewInMemorySink(),
)
res, _ := ns.Eval("checkout", "production", ctx)
record := tel.BuildRecord(ns, "production", "checkout", attrs,
tel.EvalSnapshot{
VariantKey: res.VariantKey,
Value: res.Value,
FlagVersion: res.FlagVersion,
RuleKind: "rule",
RuleIndex: res.Rule.Index,
}, false /* dryRun */)
if record != nil {
sinks.Push(*record)
}
Suppression rules match the Rust core: dry-run, namespace-level
telemetry_enabled = false, namespace + per-flag
private_attributes (filter context_attributes and suppress the
bucketing identifier when the bucket attribute is itself private),
and SHA-256 hashing of bucketing identifiers unless
raw_entity_ids = true. The SHA-256 step goes through the FFI
helper so the hash matches the Rust SDK byte-for-byte.
Built-in sinks:
| Sink | What it writes | When to use |
|---|---|---|
NewConsoleSink() | One human-readable line per record on stderr. | Local development. |
NewStdoutSink() | NDJSON to stdout. | Quick scripting / pipes. |
NewInMemorySink() | In-memory slice; goroutine-safe. | Unit tests. |
NewFileSink(path) | NDJSON to a file (append-only). | Long-running services. |
NewFileSinkWithOptions(path, opts) | NDJSON file with size + interval rotation. | Long-running services with disk-budget caps. |
NewFileSinkWithOptions rotates the current file to
<path>.<timestamp><ext> once either MaxSizeBytes or MaxInterval
is crossed, then opens a fresh file at the original path. The
timestamp format defaults to 20060102T150405Z (RFC 3339 stripped
of separators, UTC) and is overridable via TimestampFormat.
OTLP companion (github.com/manasgarg/exd/sdks/go/companion-otlp)
OTel-aware deployments can publish EvaluationRecords as
LogRecords via OTLP HTTP/protobuf by adding the OTLP companion
module:
import (
"github.com/manasgarg/exd/sdks/go/companion-otlp"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp"
sdklog "go.opentelemetry.io/otel/sdk/log"
)
exporter, _ := otlploghttp.New(ctx)
provider := sdklog.NewLoggerProvider(sdklog.WithProcessor(
sdklog.NewBatchProcessor(exporter),
))
otlpSink := otlp.New(provider, otlp.Options{})
sinks := tel.NewSinkSet(otlpSink, tel.NewInMemorySink())
The companion module lives in its own Go module so consumers of the
main SDK don't pull in OTel transitively. The mapping mirrors
crates/exd-client/src/sinks/otlp.rs field-for-field: every
LogRecord carries feature_flag.{key,variant,set.id,evaluation.reason,provider_name}
plus exd.{environment,manifest_version,sdk_version,unit_id_type}
attributes. Set Options{IncludeFullRecordBody: true} to also stamp
the marshaled record on the LogRecord body for downstream consumers
that want full fidelity.
The companion module requires Go 1.25+ (forced by the upstream
go.opentelemetry.io/otel/log minimum). The main SDK stays on Go 1.22+
since it doesn't import OTel.
Parquet companion (github.com/manasgarg/exd/sdks/go/companion-parquet)
Writes records as Parquet rows to an io.Writer or a file. Every
scalar field becomes its own Parquet column; nested fields
(variant_value, context_attributes, secondary_unit_ids) ride
as JSON-encoded string columns so every Parquet reader (DuckDB,
Arrow, PyArrow, Spark) can project further:
import pqsink "github.com/manasgarg/exd/sdks/go/companion-parquet"
sink, _ := pqsink.NewFile("/var/exd/telemetry.parquet", pqsink.Options{})
defer sink.Close(ctx)
Callers MUST call Close to write the Parquet footer; without it
the file is unreadable.
Object-store companion (github.com/manasgarg/exd/sdks/go/companion-object-store)
Batches records into NDJSON objects and uploads them to a
gocloud.dev/blob bucket.
Schemes s3://, gs://, azblob://, file://, and mem://
are available via blank-imports of the matching driver:
import (
objectstore "github.com/manasgarg/exd/sdks/go/companion-object-store"
_ "gocloud.dev/blob/s3blob"
)
sink, _ := objectstore.Open(ctx, "s3://exd-telemetry?region=us-east-1",
objectstore.Options{
Prefix: "production",
MaxBufferBytes: 4 << 20, // 4 MiB
MaxInterval: 5 * time.Minute,
})
defer sink.Close(ctx)
Object keys follow <prefix>/YYYY/MM/DD/HHMMSS-<rand>.ndjson —
partition-friendly for warehouse ingestion. Override
Options.ObjectNamer to customize the layout.
Analysis / introspection
The analysis surface mirrors the Rust SDK's read-side helpers used
by exd eval --trace / exd explain. Use it for SDK-aware tooling
(CI guards, codegen, agent guardrails).
// Probe segment membership.
m, err := ns.IsSegmentMember("north-america",
exdclient.NewContext().Set("country", "US"))
// m == exdclient.SegmentMember
// Inspect the inferred context schema.
schema, err := ns.ContextSchema("production")
for _, attr := range schema.Attributes {
fmt.Printf("%s: %s (required=%v)\n", attr.Name, attr.Type, attr.Required)
}
// Validate a candidate context for completeness.
res, err := ns.ValidateContext("production", ctx)
if !res.OK {
for _, m := range res.Mismatches {
if m.IsError() {
log.Printf("%s on %q: expected %s, got %s", m.Kind, m.Attribute, m.Expected, m.Got)
}
}
}
Available methods:
| Method | Mirrors |
|---|---|
IsSegmentMember(seg, ctx) | Namespace::is_segment_member |
ContextSchema(env) | Namespace::context_schema (env == "" widens) |
FlagContextSchema(env, flag) | Namespace::flag_context_schema |
ValidateContext(env, ctx) | Namespace::validate_context |
ValidateFlagContext(env, flag, ctx) | Namespace::validate_flag_context |
ValidationResult.OK is true iff there are zero error-severity
mismatches; warning-severity mismatches (e.g. MismatchUnknownAttr)
are surfaced via Mismatches but do not flip OK. Use
SchemaMismatch.IsError() to filter.
Roadmap
The one remaining deferred surface is the analysis projection:
analyze_flag(the fullFlagAnalysisprojection) andgenerate_fixtures— both require richer JSON projections than the Rust core currently exports. Today, consumers needing this surface can shell out toexd explain/exd fixtures. This unblocks when the Rust core grows aserde::Serializeprojection ofFlagAnalysis/FixtureSet.
Span-event attachment on the OTLP sink (used when a
context.Context carries an active OTel span) is also deferred —
it requires extending the telemetry.Sink interface to accept a
Context. Worth a design decision before landing.