Skip to main content

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 *WithOptions variants carrying the rollout-workflow include_testing opt-in, refresh / replace, namespace inspection accessors, the no-FFI ExdRemote, a goroutine-based Poller, an ExdClientGroup multi-namespace bundle, a v2 closure-delta SSE consumer with hash-verified inline + snapshot apply, the no-FFI ManifestClient write path (Pull / Push / Versions / Diff), and a telemetry subpackage (exdclient/telemetry) with EvaluationRecord builder and console / stdout / in-memory / NDJSON-file sinks. The test contract is in testplan/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.
  • Namespace handles are opaque *mut c_void, owned by Rust, released through exd_namespace_free.
  • Every string returned through a char** out-parameter MUST be released via exd_string_free. The Go layer hides this with a goStringAndFree helper.

Status-code convention

Every FFI function returns an int32_t:

StatusMeaningout_err payload
0Successempty
1Lint failureLintReport as JSON
2Load failurehuman message
3Eval failurekind:message
4Invalid argumenthuman 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

TypeTriggered by
*LintErrorManifest fails lint during FromFiles/FromDir/FromURI/Refresh/ReplaceFromFiles.
*LoadErrorI/O, fetch, slug-mismatch, builder errors.
*EvalErrorTyped eval against a wrong-typed flag, unknown flag, unknown environment, or attr type mismatch.
plain errorArgument 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:

SinkWhat it writesWhen 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:

MethodMirrors
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 full FlagAnalysis projection) and generate_fixtures — both require richer JSON projections than the Rust core currently exports. Today, consumers needing this surface can shell out to exd explain / exd fixtures. This unblocks when the Rust core grows a serde::Serialize projection of FlagAnalysis / 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.