Skip to content
SHC Docs

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 by core/log enrichment, core/otel span 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 through modules/tenant’s separate private key (WithActiveTenant). Unifying those writers onto reqctx is 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
// ...
}

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
}

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)

Every entry point into the system creates context before executing business logic. Business logic reads context but does not set it.

Design intent (not yet wired — see the status note above): HTTP middleware sets context from request headers:

  • traceparenttrace_id, parent_span_id (W3C Trace Context — handled by OTel today)
  • X-SHC-Operationoperation_id
  • X-SHC-Tenanttenant_name
  • X-SHC-Stackstack_name
  • X-SHC-Environmentenvironment
  • X-SHC-Actoractor

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())
// ...
}

The CLI base runner sets context with a fresh trace_id and output format derived from flags.

The scheduler sets context with a fresh trace_id, actor="scheduler", and tenant/stack/environment from the schedule definition.

When an event handler executes, context includes:

  • trace_id from the event
  • a new span_id (child span)
  • event_code, event_message, event_payload
  • tenant/stack/environment from the event
FieldTypeDescription
trace_idstring32 hex chars, identifies the overall operation
span_idstring16 hex chars, identifies this step
parent_span_idstringparent span for tracing hierarchy
tenant_namestringtenant (default: "default")
stack_namestringstack
environmentstringenvironment
hostnamestringnode hostname
actorstringwho initiated — user, scheduler, shc
FieldTypeDescription
event_codestringevent code being handled
event_messagestringevent message
event_payloadmapevent payload
task_idstringtask ID for tracking
FieldTypeDescription
output_formatstringoutput format — json, text, yaml
debugbooldebug mode
verboseboolverbose mode

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.

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)

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_id

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)
// ...
}
  1. Always pass context.Context as the first parameter to functions that need it.
  2. Handle empty strings for stack_name / environment where used.
  3. Pass context explicitly when spawning goroutines.
  4. Use ctx.WithChildSpan() for nested tracing.
  5. Only entry points set context values. Business logic reads, does not mutate.