diff --git a/README.md b/README.md index 7f9fd84..16c2f34 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,18 @@ construction time — this is the recommended pattern. The embedded flows in automatically. The legacy `flow.SubWorkflow` type is deprecated and will be removed in the next major version. +## Passing values through `context.Context` + +Cross-cutting capabilities — a logger, an Azure identity, a Kubernetes +client — belong in `ctx`, not in every step's constructor. `flow.ContextKey[T]` +is the type-safe key helper for putting them there; `flow.Logger` +(`ContextKey[*slog.Logger]`) is the canonical key for structured logging +and `flow.LogStepFields` / `flow.LogAttemptField` are ready-to-use +interceptors that tag every log line with `step=` and `attempt=N`. + +See `example/04_context_values_test.go` and the godoc on `flow.ContextKey` +/ `flow.Logger` / `flow.LogStepFields` for runnable examples. + ## Learn more - **[`example/`](./example)** — runnable, narrated examples for every feature, in increasing diff --git a/context.go b/context.go new file mode 100644 index 0000000..a26c6f7 --- /dev/null +++ b/context.go @@ -0,0 +1,50 @@ +package flow + +import "context" + +// ContextKey is the typed-key helper for flowing values through a +// context.Context across go-workflow and its users. Declare a package-level +// variable of ContextKey[T] per value type and use With / From / FromOr at +// the call site: +// +// type Identity struct{ TenantID, SubID string } +// var IdentityKey = flow.ContextKey[Identity]{} +// +// // Caller injects: +// ctx = IdentityKey.With(ctx, Identity{TenantID: "t", SubID: "s"}) +// +// // Steper reads: +// func (s *MyStep) Do(ctx context.Context) error { +// id, ok := IdentityKey.From(ctx) +// if !ok { +// return errors.New("identity required") +// } +// // ... use id ... +// return nil +// } +// +// Uniqueness is by T alone (ContextKey[T] is a zero-size struct used as +// the underlying context key), so two ContextKey[Identity]{} variables +// declared in different packages will collide. Each value type should +// have exactly one canonical key variable, exported by the package that +// owns the type. +type ContextKey[T any] struct{} + +// With returns a new context carrying v under k. +func (k ContextKey[T]) With(ctx context.Context, v T) context.Context { + return context.WithValue(ctx, k, v) +} + +// From returns the value stored under k and whether it was present. +func (k ContextKey[T]) From(ctx context.Context) (T, bool) { + v, ok := ctx.Value(k).(T) + return v, ok +} + +// FromOr returns the value stored under k, or def if no value is present. +func (k ContextKey[T]) FromOr(ctx context.Context, def T) T { + if v, ok := k.From(ctx); ok { + return v + } + return def +} diff --git a/context_test.go b/context_test.go new file mode 100644 index 0000000..b58c7da --- /dev/null +++ b/context_test.go @@ -0,0 +1,66 @@ +package flow + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestContextKey(t *testing.T) { + type creds struct{ Token string } + var key = ContextKey[creds]{} + + t.Run("From returns ok=false when unset", func(t *testing.T) { + _, ok := key.From(context.Background()) + assert.False(t, ok) + }) + + t.Run("FromOr returns default when unset", func(t *testing.T) { + def := creds{Token: "default"} + got := key.FromOr(context.Background(), def) + assert.Equal(t, def, got) + }) + + t.Run("With + From round-trips the value", func(t *testing.T) { + want := creds{Token: "abc"} + ctx := key.With(context.Background(), want) + got, ok := key.From(ctx) + assert.True(t, ok) + assert.Equal(t, want, got) + }) + + t.Run("FromOr returns stored value over default", func(t *testing.T) { + want := creds{Token: "abc"} + ctx := key.With(context.Background(), want) + got := key.FromOr(ctx, creds{Token: "fallback"}) + assert.Equal(t, want, got) + }) + + t.Run("keys with different T do not collide", func(t *testing.T) { + var keyA = ContextKey[string]{} + var keyB = ContextKey[int]{} + ctx := keyA.With(context.Background(), "hello") + ctx = keyB.With(ctx, 42) + + gotA, okA := keyA.From(ctx) + gotB, okB := keyB.From(ctx) + assert.True(t, okA) + assert.Equal(t, "hello", gotA) + assert.True(t, okB) + assert.Equal(t, 42, gotB) + }) + + t.Run("two distinct ContextKey[T] vars share the same key", func(t *testing.T) { + // ContextKey[T]{} is a zero-size struct keyed only by T, so two + // separate package-level vars of the same T will collide. This + // documents the contract: each value type should have ONE canonical + // key var (typically exported by the package that owns the type). + var k1 = ContextKey[string]{} + var k2 = ContextKey[string]{} + ctx := k1.With(context.Background(), "x") + got, ok := k2.From(ctx) + assert.True(t, ok) + assert.Equal(t, "x", got) + }) +} diff --git a/example/04_context_values_test.go b/example/04_context_values_test.go new file mode 100644 index 0000000..8b01684 --- /dev/null +++ b/example/04_context_values_test.go @@ -0,0 +1,77 @@ +package flow_test + +import ( + "context" + "fmt" + "log/slog" + "os" + + flow "github.com/Azure/go-workflow" +) + +// # Passing values through ctx +// +// **What you'll learn** +// - Use `flow.ContextKey[T]` to put a typed value in `ctx` once and read +// it in any Steper — the everyday pattern for sharing things like a +// request ID, tenant, logger or client across a Workflow. +// - Reach for the prebuilt `flow.Logger` key (`ContextKey[*slog.Logger]`) +// when the value is a structured logger; combine with +// `flow.LogStepFields` / `flow.LogAttemptField` to tag every log line +// with the current step name and attempt number for free. + +// UserKey is the canonical key for a request-scoped user. Declare one +// `ContextKey[T]` per value type — uniqueness is by T, so two such vars +// of the same T deliberately share the same key. +var UserKey = flow.ContextKey[string]{} + +// ExampleContextKey shows the bare-bones pattern: caller injects, step reads. +func ExampleContextKey() { + greet := flow.Func("Greet", func(ctx context.Context) error { + fmt.Println("hello", UserKey.FromOr(ctx, "anonymous")) + return nil + }) + + w := new(flow.Workflow) + w.Add(flow.Step(greet)) + + ctx := UserKey.With(context.Background(), "ada") + _ = w.Do(ctx) + // Output: + // hello ada +} + +// ExampleLogger shows the prebuilt flow.Logger key together with the two +// log interceptors. The step's Do() only logs business attributes; +// step=Greet and attempt=0 come along for free. +func ExampleLogger() { + // Plain text logger to stdout, with time/level stripped so // Output: + // stays deterministic. Real callers just pass slog.Default() or their + // own *slog.Logger. + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey || a.Key == slog.LevelKey { + return slog.Attr{} + } + return a + }, + })) + + greet := flow.Func("Greet", func(ctx context.Context) error { + flow.Logger.FromOr(ctx, slog.Default()).Info("hi", "name", "ada") + return nil + }) + + w := &flow.Workflow{ + Option: flow.WorkflowOption{ + StepInterceptors: []flow.StepInterceptor{flow.LogStepFields()}, + AttemptInterceptors: []flow.AttemptInterceptor{flow.LogAttemptField()}, + }, + } + w.Add(flow.Step(greet)) + + ctx := flow.Logger.With(context.Background(), logger) + _ = w.Do(ctx) + // Output: + // msg=hi step=Greet attempt=0 name=ada +} diff --git a/example/04_callbacks_test.go b/example/05_callbacks_test.go similarity index 97% rename from example/04_callbacks_test.go rename to example/05_callbacks_test.go index da7a9b1..aede450 100644 --- a/example/04_callbacks_test.go +++ b/example/05_callbacks_test.go @@ -18,7 +18,7 @@ import ( // // **Where they fit** // -// StepInterceptor (workflow-level, see 10_observability_test.go) +// StepInterceptor (workflow-level, see 11_observability_test.go) // └── retry loop (one iteration per attempt) // └── AttemptInterceptor (workflow-level) // └── BeforeStep callbacks ← runs once PER ATTEMPT diff --git a/example/05_conditions_test.go b/example/06_conditions_test.go similarity index 100% rename from example/05_conditions_test.go rename to example/06_conditions_test.go diff --git a/example/06_branching_test.go b/example/07_branching_test.go similarity index 100% rename from example/06_branching_test.go rename to example/07_branching_test.go diff --git a/example/07_retry_and_timeout_test.go b/example/08_retry_and_timeout_test.go similarity index 100% rename from example/07_retry_and_timeout_test.go rename to example/08_retry_and_timeout_test.go diff --git a/example/08_workflow_in_workflow_test.go b/example/09_workflow_in_workflow_test.go similarity index 100% rename from example/08_workflow_in_workflow_test.go rename to example/09_workflow_in_workflow_test.go diff --git a/example/09_workflow_options_test.go b/example/10_workflow_options_test.go similarity index 100% rename from example/09_workflow_options_test.go rename to example/10_workflow_options_test.go diff --git a/example/10_observability_test.go b/example/11_observability_test.go similarity index 98% rename from example/10_observability_test.go rename to example/11_observability_test.go index 1da77ea..ea98aef 100644 --- a/example/10_observability_test.go +++ b/example/11_observability_test.go @@ -32,9 +32,9 @@ import ( // **When to reach for which mechanism** // // Need to log/trace every Step? → Interceptor (this file). -// Need to react to upstream's terminal status → Condition (05_conditions). +// Need to react to upstream's terminal status → Condition (06_conditions). // Need behaviour for one specific Step? → BeforeStep / AfterStep -// (04_callbacks). +// (05_callbacks). // // **Caveats** // - Steps settled inline as Skipped/Canceled by their Condition bypass diff --git a/example/11_debugging_test.go b/example/12_debugging_test.go similarity index 99% rename from example/11_debugging_test.go rename to example/12_debugging_test.go index 155c01f..0754ab9 100644 --- a/example/11_debugging_test.go +++ b/example/12_debugging_test.go @@ -16,7 +16,7 @@ import ( // keyed by failed Step. Iterate to print them all, or use `errors.As`. // - Use `Workflow.StateOf(step).GetStatus()` to inspect any Step's // terminal status post-run. -// - For per-Step structured logging, prefer an interceptor (10_observability) +// - For per-Step structured logging, prefer an interceptor (11_observability) // over `AfterStep` so the same logger applies to every Step. // // **The two questions you'll typically ask** diff --git a/example/12_testing_workflows_test.go b/example/13_testing_workflows_test.go similarity index 95% rename from example/12_testing_workflows_test.go rename to example/13_testing_workflows_test.go index 4084967..9ab9e28 100644 --- a/example/12_testing_workflows_test.go +++ b/example/13_testing_workflows_test.go @@ -25,9 +25,9 @@ import ( // You wrote the Workflow in your test ─► substitute Steps directly, // no need for Mock. // Production code built the Workflow ─► flow.Mock to swap one Step. -// You want to assert on per-Step error/status ─► see 11_debugging. +// You want to assert on per-Step error/status ─► see 12_debugging. // You want to assert on Begin/End ordering ─► add a StepInterceptor in -// the test (10_observability). +// the test (11_observability). // ExampleMock shows the typical use: a production workflow assembled // elsewhere, with one Step substituted in the test. diff --git a/example/13_mutators_test.go b/example/14_mutators_test.go similarity index 97% rename from example/13_mutators_test.go rename to example/14_mutators_test.go index e0f5f3d..2290366 100644 --- a/example/13_mutators_test.go +++ b/example/14_mutators_test.go @@ -16,7 +16,7 @@ import ( // `flow.Builder` to register `Input` / `BeforeStep` / `AfterStep` / // `Retry` / `Timeout` / ..., or both. // - Parent-Workflow Mutators reach into sub-Workflows automatically — the -// same WorkflowOptionReceiver mechanism described in 10_observability for +// same WorkflowOptionReceiver mechanism described in 11_observability for // interceptors. // // **Where they fit** @@ -30,9 +30,9 @@ import ( // **When to reach for which mechanism** // // Need behaviour for one specific Step? → BeforeStep / AfterStep -// (04_callbacks). +// (05_callbacks). // Need behaviour for every Step in the Workflow? → Interceptor -// (10_observability). +// (11_observability). // Need behaviour for every Step of a given type, // even Steps added later or inside sub-workflows? → Mutator (this file). // @@ -161,7 +161,7 @@ func ExampleMutator_ctxValue() { // time. // // The propagation mechanism is the same `WorkflowOptionReceiver` interface -// used by interceptors in 10_observability: any Step that contains a +// used by interceptors in 11_observability: any Step that contains a // sub-Workflow (a `*Workflow` used as a Step, or a struct embedding the // deprecated `flow.SubWorkflow`) automatically receives the parent's Option // (Mutators included) before the inner Workflow starts scheduling. diff --git a/example/README.md b/example/README.md index bbdae84..85b863b 100644 --- a/example/README.md +++ b/example/README.md @@ -2,12 +2,23 @@ This directory is the **`go-workflow` learning path** in code form. Each file is a runnable [Go example test](https://pkg.go.dev/testing#hdr-Examples) -focused on one question. Read top to bottom on a first pass; jump around -once you know what you need. +focused on one question. `go test ./example/...` runs everything and verifies the output blocks stay in sync with the library. +## How this path is ordered + +These files are sorted by **how often you'll reach for the feature**, not +by API surface area or implementation depth. The earlier the file, the +more likely you'll touch it on day one. Read top-to-bottom on a first +pass and you'll build the right mental model in the right order; jump +around once you know what you need. + +When adding a new example, place it where its frequency-of-use lands — +even if that pushes everything below it down by one. The numbers are +ordering hints, not stable identifiers. + ## Path ### Get the mental model (read first) @@ -17,36 +28,37 @@ stay in sync with the library. | [01_quickstart](01_quickstart_test.go) | Any struct with a `Do` method is a Step. End-to-end 3-minute tour: parallel fetch + merge into a profile, with data flowing through `Input` callbacks. | | [02_steps_and_deps](02_steps_and_deps_test.go) | `Step` / `Steps` / `DependsOn` / `Pipe` / `BatchPipe` / `Name`. | -### Move data through the graph +### Move data into a Step | File | What you'll learn | |---------------------------------|---| -| [03_data_flow](03_data_flow_test.go) | The standard `Input` callback pattern (with your structs and with `Func`/`FuncIO`/`FuncI`/`FuncO`). | -| [04_callbacks](04_callbacks_test.go) | `BeforeStep` / `AfterStep` and how they relate to `Input`. | +| [03_data_flow](03_data_flow_test.go) | The standard `Input` callback pattern (with your structs and with `Func`/`FuncIO`/`FuncI`/`FuncO`) — typed, point-to-point. | +| [04_context_values](04_context_values_test.go) | `flow.ContextKey[T]` for graph-wide values like a logger, request ID or client; `flow.Logger` and the log interceptors as the canonical case. | +| [05_callbacks](05_callbacks_test.go) | `BeforeStep` / `AfterStep` and how they relate to `Input`. | ### Decide what runs (and what doesn't) | File | What you'll learn | |---------------------------------|---| -| [05_conditions](05_conditions_test.go) | `Condition`, `When`, `flow.Skip` / `flow.Cancel` from inside `Do`. | -| [06_branching](06_branching_test.go) | `If` / `Switch` for runtime-data-driven branches. | -| [07_retry_and_timeout](07_retry_and_timeout_test.go) | `Retry`, per-attempt timeout, step timeout, deterministic-clock testing. | +| [06_conditions](06_conditions_test.go) | `Condition`, `When`, `flow.Skip` / `flow.Cancel` from inside `Do`. | +| [07_branching](07_branching_test.go) | `If` / `Switch` for runtime-data-driven branches. | +| [08_retry_and_timeout](08_retry_and_timeout_test.go) | `Retry`, per-attempt timeout, step timeout, deterministic-clock testing. | ### Build bigger workflows | File | What you'll learn | |---------------------------------|---| -| [08_workflow_in_workflow](08_workflow_in_workflow_test.go) | Use a `*Workflow` as a Step. Why a "composite Step" struct is an antipattern. | -| [09_workflow_options](09_workflow_options_test.go) | `MaxConcurrency`, `DontPanic`. | +| [09_workflow_in_workflow](09_workflow_in_workflow_test.go) | Use a `*Workflow` as a Step. Why a "composite Step" struct is an antipattern. | +| [10_workflow_options](10_workflow_options_test.go) | `MaxConcurrency`, `DontPanic`. | ### Operate, debug, test | File | What you'll learn | |---------------------------------|---| -| [10_observability](10_observability_test.go) | `StepInterceptor` / `AttemptInterceptor` for cross-cutting logging, tracing, metrics. | -| [11_debugging](11_debugging_test.go) | `ErrWorkflow` and `Workflow.StateOf` for post-run inspection. | -| [12_testing_workflows](12_testing_workflows_test.go) | `flow.Mock` to substitute Step behaviour in tests. | -| [13_mutators](13_mutators_test.go) | `Mutate[T]` to contribute config (defaults, Retry, callbacks) to every Step of a type — even inside sub-Workflows. | +| [11_observability](11_observability_test.go) | `StepInterceptor` / `AttemptInterceptor` for cross-cutting logging, tracing, metrics. | +| [12_debugging](12_debugging_test.go) | `ErrWorkflow` and `Workflow.StateOf` for post-run inspection. | +| [13_testing_workflows](13_testing_workflows_test.go) | `flow.Mock` to substitute Step behaviour in tests. | +| [14_mutators](14_mutators_test.go) | `Mutate[T]` to contribute config (defaults, Retry, callbacks) to every Step of a type — even inside sub-Workflows. | ## Conventions used in these examples diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..ee5644a --- /dev/null +++ b/logger.go @@ -0,0 +1,63 @@ +package flow + +import ( + "context" + "log/slog" +) + +// Logger is the canonical ContextKey for a *slog.Logger flowing through a +// Workflow. Inject one with Logger.With and read it back inside a Steper +// with Logger.FromOr(ctx, slog.Default()) so steps that did not bother to +// configure a logger still work: +// +// ctx = flow.Logger.With(ctx, mySlog) +// +// func (s *MyStep) Do(ctx context.Context) error { +// log := flow.Logger.FromOr(ctx, slog.Default()) +// log.Info("doing work", "name", s.Name) +// return nil +// } +// +// Compose with LogStepFields and LogAttemptField to automatically tag +// every log line with step= and attempt=N. +var Logger = ContextKey[*slog.Logger]{} + +// LogStepFields returns a StepInterceptor that derives the ctx logger by +// binding step= (and any extra fields the caller +// supplies) onto it, so Steper implementations can write: +// +// log := flow.Logger.FromOr(ctx, slog.Default()) +// log.Info("creating", "name", s.Name) +// +// and get step= for free without each Steper having to bind it +// manually. Extra functions append slog-style key/value pairs: +// +// flow.LogStepFields(func(ctx context.Context, _ flow.Steper) []any { +// return []any{"tenant", tenantFrom(ctx)} +// }) +// +// If ctx has no logger, slog.Default() is used as the base. The original +// logger in ctx is not mutated — every step run sees a freshly derived +// logger so step-level fields do not accumulate across steps. +func LogStepFields(extra ...func(context.Context, Steper) []any) StepInterceptor { + return StepInterceptorFunc(func(ctx context.Context, step Steper, next func(context.Context) error) error { + base := Logger.FromOr(ctx, slog.Default()) + attrs := []any{"step", String(step)} + for _, fn := range extra { + attrs = append(attrs, fn(ctx, step)...) + } + ctx = Logger.With(ctx, base.With(attrs...)) + return next(ctx) + }) +} + +// LogAttemptField is the AttemptInterceptor counterpart of LogStepFields: +// it binds attempt= onto the ctx logger inside the retry loop. Compose +// with LogStepFields to also tag the step name on every attempt. +func LogAttemptField() AttemptInterceptor { + return AttemptInterceptorFunc(func(ctx context.Context, step Steper, attempt uint64, next func(context.Context) error) error { + base := Logger.FromOr(ctx, slog.Default()) + ctx = Logger.With(ctx, base.With("attempt", attempt)) + return next(ctx) + }) +} diff --git a/logger_test.go b/logger_test.go new file mode 100644 index 0000000..33202a3 --- /dev/null +++ b/logger_test.go @@ -0,0 +1,166 @@ +package flow + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "log/slog" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newCapturingLogger returns a *slog.Logger whose output is captured into buf +// as JSON, one record per line — convenient for asserting on attributes. +func newCapturingLogger(buf *bytes.Buffer) *slog.Logger { + return slog.New(slog.NewJSONHandler(buf, nil)) +} + +// records parses one JSON object per line out of buf. +func records(t *testing.T, buf *bytes.Buffer) []map[string]any { + t.Helper() + var out []map[string]any + for _, line := range strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") { + if line == "" { + continue + } + var rec map[string]any + require.NoError(t, json.Unmarshal([]byte(line), &rec)) + out = append(out, rec) + } + return out +} + +func TestLoggerKey(t *testing.T) { + t.Run("FromOr falls back to default when ctx has no logger", func(t *testing.T) { + def := slog.Default() + got := Logger.FromOr(context.Background(), def) + assert.Same(t, def, got) + }) + + t.Run("With + From round-trips a *slog.Logger", func(t *testing.T) { + var buf bytes.Buffer + want := newCapturingLogger(&buf) + ctx := Logger.With(context.Background(), want) + got, ok := Logger.From(ctx) + assert.True(t, ok) + assert.Same(t, want, got) + }) +} + +func TestLogStepFields(t *testing.T) { + t.Run("binds step= onto the ctx logger", func(t *testing.T) { + var buf bytes.Buffer + base := newCapturingLogger(&buf) + ctx := Logger.With(context.Background(), base) + + step := &NamedStep{Name: "MyStep", Steper: NoOp("inner")} + + ic := LogStepFields() + err := ic.InterceptStep(ctx, step, func(ctx context.Context) error { + Logger.FromOr(ctx, slog.Default()).Info("hello", "k", "v") + return nil + }) + require.NoError(t, err) + + recs := records(t, &buf) + require.Len(t, recs, 1) + assert.Equal(t, "hello", recs[0]["msg"]) + assert.Equal(t, "MyStep", recs[0]["step"]) + assert.Equal(t, "v", recs[0]["k"]) + }) + + t.Run("extra functions append additional fields", func(t *testing.T) { + var buf bytes.Buffer + ctx := Logger.With(context.Background(), newCapturingLogger(&buf)) + + step := &NamedStep{Name: "S", Steper: NoOp("inner")} + + ic := LogStepFields( + func(ctx context.Context, s Steper) []any { return []any{"tenant", "acme"} }, + func(ctx context.Context, s Steper) []any { return []any{"region", "westus2"} }, + ) + err := ic.InterceptStep(ctx, step, func(ctx context.Context) error { + Logger.FromOr(ctx, slog.Default()).Info("hi") + return nil + }) + require.NoError(t, err) + + recs := records(t, &buf) + require.Len(t, recs, 1) + assert.Equal(t, "S", recs[0]["step"]) + assert.Equal(t, "acme", recs[0]["tenant"]) + assert.Equal(t, "westus2", recs[0]["region"]) + }) + + t.Run("uses slog.Default() when ctx has no logger", func(t *testing.T) { + // Replace the default temporarily so we can capture its output. + var buf bytes.Buffer + old := slog.Default() + slog.SetDefault(newCapturingLogger(&buf)) + t.Cleanup(func() { slog.SetDefault(old) }) + + step := &NamedStep{Name: "Default", Steper: NoOp("inner")} + ic := LogStepFields() + err := ic.InterceptStep(context.Background(), step, func(ctx context.Context) error { + Logger.FromOr(ctx, slog.Default()).Info("from default") + return nil + }) + require.NoError(t, err) + + recs := records(t, &buf) + require.Len(t, recs, 1) + assert.Equal(t, "Default", recs[0]["step"]) + }) + + t.Run("does not pollute the original ctx logger", func(t *testing.T) { + var buf bytes.Buffer + base := newCapturingLogger(&buf) + ctx := Logger.With(context.Background(), base) + step := &NamedStep{Name: "S", Steper: NoOp("inner")} + + ic := LogStepFields() + err := ic.InterceptStep(ctx, step, func(ctx context.Context) error { return nil }) + require.NoError(t, err) + + // The base logger should not have step=... attached. Logging through + // it directly produces a record without "step". + base.Info("plain") + recs := records(t, &buf) + require.Len(t, recs, 1) + _, has := recs[0]["step"] + assert.False(t, has, "base logger must not be mutated") + }) + + t.Run("propagates next error", func(t *testing.T) { + ctx := Logger.With(context.Background(), slog.Default()) + step := &NamedStep{Name: "S", Steper: NoOp("inner")} + want := errors.New("boom") + + ic := LogStepFields() + got := ic.InterceptStep(ctx, step, func(ctx context.Context) error { return want }) + assert.ErrorIs(t, got, want) + }) +} + +func TestLogAttemptField(t *testing.T) { + t.Run("binds attempt=N onto the ctx logger", func(t *testing.T) { + var buf bytes.Buffer + ctx := Logger.With(context.Background(), newCapturingLogger(&buf)) + step := &NamedStep{Name: "S", Steper: NoOp("inner")} + + ic := LogAttemptField() + err := ic.InterceptAttempt(ctx, step, 2, func(ctx context.Context) error { + Logger.FromOr(ctx, slog.Default()).Info("try") + return nil + }) + require.NoError(t, err) + + recs := records(t, &buf) + require.Len(t, recs, 1) + assert.EqualValues(t, 2, recs[0]["attempt"]) + }) +} diff --git a/openspec/specs/context-values/spec.md b/openspec/specs/context-values/spec.md new file mode 100644 index 0000000..0355fea --- /dev/null +++ b/openspec/specs/context-values/spec.md @@ -0,0 +1,182 @@ +# context-values Specification + +## Purpose + +This capability defines the contract for flowing typed values through a +`context.Context` across go-workflow and any user / contrib code that +participates in a Workflow. It exists so that: + +- Cross-cutting values (logger, identity, tenant, tracer, …) have a single, + conventional way to be injected by the caller and read by `Steper` + implementations — without each contrib package inventing its own + unexported context key. +- The convention is **type-safe** (no `interface{}` casts at the call site) + and **zero-dependency on go-workflow internals** (a value type can be + defined in any package and still get a stable key). +- A first-class `*slog.Logger` key is published by go-workflow itself, so + contrib packages can rely on a single convention for structured logging + rather than threading a logger through their own constructors. + +## Requirements + +### Requirement: Generic ContextKey[T] helper + +`flow` SHALL expose a generic struct type `ContextKey[T any]` that, when +declared as a package-level variable, acts as a unique, type-safe key for a +value of type `T` in `context.Context`: + +```go +type ContextKey[T any] struct{} + +func (k ContextKey[T]) With(ctx context.Context, v T) context.Context +func (k ContextKey[T]) From(ctx context.Context) (T, bool) +func (k ContextKey[T]) FromOr(ctx context.Context, def T) T +``` + +Semantics: + +- `With` returns a new `context.Context` carrying `v` under the key + identified by the `ContextKey[T]` value used as receiver. It SHALL NOT + mutate the passed `ctx`. +- `From` returns the value stored under the key and `ok=true` if present; + the zero value of `T` and `ok=false` otherwise. +- `FromOr` returns the stored value, or `def` if no value is present. It + SHALL NOT panic when the value is absent. + +`ContextKey[T]` is a zero-size struct, and the underlying `context` key is +the receiver value itself. This means uniqueness is determined by `T` +(the generic instantiation), **not** by the variable's identity. Packages +that own a value type SHALL declare exactly one canonical `ContextKey[T]` +variable for that type and document it as the conventional key. Two +unrelated packages choosing the same `T` will collide; this is by design +and matches the "one key per type" convention. + +#### Scenario: With + From round-trips a value +- **WHEN** `ctx2 := key.With(ctx, v)` is called +- **THEN** `key.From(ctx2)` returns `(v, true)` +- **AND** `key.From(ctx)` (the original context) still returns `(zero, false)` + +#### Scenario: FromOr returns default when unset +- **WHEN** `key.FromOr(ctx, def)` is called on a context without the key +- **THEN** the return value equals `def` +- **AND** no panic is raised + +#### Scenario: Keys with different T do not collide +- **GIVEN** `var ka = ContextKey[A]{}` and `var kb = ContextKey[B]{}` + where `A` and `B` are distinct types +- **WHEN** both `ka.With(ctx, a)` and `kb.With(ctx, b)` are applied +- **THEN** `ka.From(ctx)` returns `a` and `kb.From(ctx)` returns `b` — + neither sees the other's value + +#### Scenario: Two ContextKey[T] vars of the same T share the same key +- **GIVEN** `var k1 = ContextKey[string]{}` and `var k2 = ContextKey[string]{}` +- **WHEN** `k1.With(ctx, "x")` is applied +- **THEN** `k2.From(ctx)` returns `("x", true)` — uniqueness is by `T` + alone; package-level variables of the same `ContextKey[T]` are + interchangeable keys + +--- + +### Requirement: Canonical Logger key + +`flow` SHALL expose a package-level variable: + +```go +var Logger = ContextKey[*slog.Logger]{} +``` + +as the conventional context key for a `*slog.Logger` flowing through a +Workflow. Callers SHALL inject a logger using `flow.Logger.With(ctx, l)` +and `Steper` implementations SHALL read it using +`flow.Logger.FromOr(ctx, slog.Default())`. + +go-workflow itself does not require the ctx to carry a logger; the +convention exists so that contrib packages and user steps can agree on a +single key, avoiding a proliferation of per-package logger keys and +constructor parameters. + +#### Scenario: Steper reads a logger injected by the caller +- **GIVEN** `ctx = flow.Logger.With(ctx, mySlog)` +- **WHEN** a step's `Do(ctx)` calls `flow.Logger.FromOr(ctx, slog.Default())` +- **THEN** it receives `mySlog` + +#### Scenario: Steper falls back to slog.Default() when no logger injected +- **GIVEN** a context with no logger injected +- **WHEN** a step's `Do(ctx)` calls `flow.Logger.FromOr(ctx, slog.Default())` +- **THEN** it receives `slog.Default()` (or whichever default the caller passes) + +--- + +### Requirement: LogStepFields interceptor + +`flow` SHALL provide a `StepInterceptor` constructor: + +```go +func LogStepFields(extra ...func(context.Context, Steper) []any) StepInterceptor +``` + +that derives the ctx logger by calling `base.With("step", flow.String(step), )` +and re-injects the derived logger via `flow.Logger.With` for the duration +of the wrapped step. The base logger SHALL be `flow.Logger.FromOr(ctx, slog.Default())`. + +The `extra` variadic parameter accepts caller-supplied functions, each +returning a slice of slog-style `key, value, key, value, …` `any` pairs to +append to the derived logger's fields. This lets callers tag steps with +business attributes (tenant, region, request ID, …) without writing a +custom interceptor. + +The original logger stored in `ctx` (if any) SHALL NOT be mutated; each +step run sees a freshly derived logger built from the base, so step-level +fields do not accumulate across steps within a single workflow run. + +#### Scenario: Interceptor binds step= onto the ctx logger +- **GIVEN** `ctx` carries a `*slog.Logger`, and a step `s` whose + `flow.String(s)` returns `"MyStep"` +- **WHEN** `LogStepFields()` wraps `s.Do(ctx)` +- **THEN** any log emitted via `flow.Logger.FromOr(ctx, ...)` inside `Do` + carries the attribute `step=MyStep` + +#### Scenario: Extra functions append additional fields +- **GIVEN** `LogStepFields(func(_, _) []any { return []any{"tenant", "acme"} })` +- **WHEN** the interceptor wraps a step's execution +- **THEN** logs emitted inside the step carry both `step=...` and + `tenant=acme` + +#### Scenario: Falls back to slog.Default() when ctx has no logger +- **GIVEN** a context with no logger injected +- **WHEN** `LogStepFields()` wraps a step's execution +- **THEN** the derived logger is built on top of `slog.Default()` and is + re-injected so the step still observes a non-nil logger via + `flow.Logger.FromOr` + +#### Scenario: Original ctx logger is not mutated +- **GIVEN** a base logger `L` injected via `flow.Logger.With(ctx, L)` +- **WHEN** `LogStepFields()` wraps any number of steps +- **THEN** logging via `L` directly produces records without any `step=...` + attribute attached by the interceptor + +#### Scenario: Errors from next propagate unchanged +- **WHEN** the wrapped `next(ctx)` returns an error `err` +- **THEN** `LogStepFields(...).InterceptStep` returns the same error + +--- + +### Requirement: LogAttemptField interceptor + +`flow` SHALL provide an `AttemptInterceptor` constructor: + +```go +func LogAttemptField() AttemptInterceptor +``` + +that derives the ctx logger by calling `base.With("attempt", attempt)` and +re-injects the derived logger for the duration of the wrapped attempt. The +base logger SHALL be `flow.Logger.FromOr(ctx, slog.Default())`. Composing +this with `LogStepFields` (registered separately on +`Option.StepInterceptors`) gives logs both `step=...` and `attempt=N`. + +#### Scenario: Interceptor binds attempt=N onto the ctx logger +- **GIVEN** an attempt with index `2` +- **WHEN** `LogAttemptField()` wraps the attempt +- **THEN** any log emitted via `flow.Logger.FromOr(ctx, ...)` inside the + attempt carries the attribute `attempt=2`