Implicit context (Ctx)
SHC uses Go’s context.Context to propagate an ambient execution
context — tenant, stack, trace IDs, and other fields — through the
call chain.
Entry points (HTTP middleware, CLI runner, scheduler, event handlers) set the context once; business logic reads it via helper functions.
Status (2026-07-03): the identity half of this pattern lives in
core/reqctx(tenant/stack/environment keys + accessors), read bycore/logenrichment,core/otelspan attributes, metrics labels, and auth scope resolution. Trace/span propagation is handled by OpenTelemetry (core/otel), not hand-rolled ctx fields. The header→ctx middleware described under “Entry points” is design intent carried over from the Python tree — it is not wired in Go yet, and production tenant resolution still flows throughmodules/tenant’s separate private key (WithActiveTenant). Unifying those writers ontoreqctxis owed work; do not add another parallel ctx key.
func InstallStack(ctx context.Context, stackName string) error { if err := pullImages(ctx, stackName); err != nil { return err } if err := startContainers(ctx, stackName); err != nil { return err } events.Publish(ctx, A200100STKINS(stackName)) return nil}
func pullImages(ctx context.Context, stackName string) error { tenantName := reqctx.Tenant(ctx) // available implicitly // ...}The reqctx package
Section titled “The reqctx package”core/reqctx provides helpers to get/set the identity
context values:
import "gitlab.com/bitspur/selfhosted-cloud/ce/core/reqctx"
func myFunction(ctx context.Context) { tenant := reqctx.Tenant(ctx) // "" when unset (tenant.Resolve applies the "default" fallback) stack := reqctx.Stack(ctx) // may be empty env := reqctx.Environment(ctx) // may be empty}Lifecycle — setting context
Section titled “Lifecycle — setting context”Entry points create and set context values using With* functions
(an empty value is a no-op, so unresolved values pass through without
clearing an outer scope):
import "gitlab.com/bitspur/selfhosted-cloud/ce/core/reqctx"
ctx := context.Background()ctx = reqctx.WithTenant(ctx, "acme")ctx = reqctx.WithStack(ctx, "postgres")
doWork(ctx)Entry points
Section titled “Entry points”Every entry point into the system creates context before executing business logic. Business logic reads context but does not set it.
API requests
Section titled “API requests”Design intent (not yet wired — see the status note above): HTTP middleware sets context from request headers:
traceparent→trace_id,parent_span_id(W3C Trace Context — handled by OTel today)X-SHC-Operation→operation_idX-SHC-Tenant→tenant_nameX-SHC-Stack→stack_nameX-SHC-Environment→environmentX-SHC-Actor→actor
What exists today: auth.WithStackEnvFromChi stamps stack/env from
chi route params into reqctx (mounted on no production router yet),
and several routers read X-SHC-Tenant directly. Handlers receive
context via r.Context():
func installHandler(w http.ResponseWriter, r *http.Request) { tenant := reqctx.Tenant(r.Context()) // ...}CLI commands
Section titled “CLI commands”The CLI base runner sets context with a fresh trace_id and output
format derived from flags.
Scheduled tasks
Section titled “Scheduled tasks”The scheduler sets context with a fresh trace_id, actor="scheduler",
and tenant/stack/environment from the schedule definition.
Event handlers
Section titled “Event handlers”When an event handler executes, context includes:
trace_idfrom the event- a new
span_id(child span) event_code,event_message,event_payload- tenant/stack/environment from the event
Context fields
Section titled “Context fields”Serialized cross-node
Section titled “Serialized cross-node”| Field | Type | Description |
|---|---|---|
trace_id | string | 32 hex chars, identifies the overall operation |
span_id | string | 16 hex chars, identifies this step |
parent_span_id | string | parent span for tracing hierarchy |
tenant_name | string | tenant (default: "default") |
stack_name | string | stack |
environment | string | environment |
hostname | string | node hostname |
actor | string | who initiated — user, scheduler, shc |
Event handler
Section titled “Event handler”| Field | Type | Description |
|---|---|---|
event_code | string | event code being handled |
event_message | string | event message |
event_payload | map | event payload |
task_id | string | task ID for tracking |
| Field | Type | Description |
|---|---|---|
output_format | string | output format — json, text, yaml |
debug | bool | debug mode |
verbose | bool | verbose mode |
Cross-node propagation
Section titled “Cross-node propagation”Outgoing requests serialize context to HTTP headers:
headers := ctx.ToHeaders(c)// Returns:// {// "traceparent": "00-{trace_id}-{span_id}-01",// "X-SHC-Operation": "abc123...",// "X-SHC-Tenant": "default",// "X-SHC-Stack": "postgres",// "X-SHC-Environment": "prod",// "X-SHC-Actor": "admin",// }Receiving nodes reconstruct with ctx.FromHeaders(r.Header) —
the caller’s span_id becomes parent_span_id, and a new span_id
is generated for the receiving side.
Goroutine propagation
Section titled “Goroutine propagation”Go’s context.Context propagates through function calls automatically.
When spawning goroutines, pass the context explicitly:
go func(c context.Context) { // context available here doWork(c)}(ctx)Child spans
Section titled “Child spans”For nested operations that should appear as a separate span in traces:
childCtx := ctx.WithChildSpan(c)// same trace_id, new span_id, parent_span_id = original span_idTesting
Section titled “Testing”Set context in tests using the With* helpers:
func TestMyFunction(t *testing.T) { ctx := context.Background() ctx = reqctx.WithTenant(ctx, "test") ctx = reqctx.WithStack(ctx, "mystack")
result := myFunction(ctx) // ...}- Always pass
context.Contextas the first parameter to functions that need it. - Handle empty strings for
stack_name/environmentwhere used. - Pass context explicitly when spawning goroutines.
- Use
ctx.WithChildSpan()for nested tracing. - Only entry points set context values. Business logic reads, does not mutate.