Repository navigation
Replies: 2 comments
Revision: telemetry owns its lifecycle integrationThis revision applies the decisions in the lifecycle proposal's revision:
Each section below replaces the section of the same name in the post, and Lifecycle integration is new. Summary, Background, Migration from today, Out of scope and References are unchanged and still apply. Proposed APIpackage telemetry // github.com/z5labs/humus/telemetry
// Telemetry is the set of OpenTelemetry providers an App hands its
// Services. It replaces OTel's globals: every humus package takes one.
type Telemetry struct {
TracerProvider trace.TracerProvider
MeterProvider metric.MeterProvider
LoggerProvider log.LoggerProvider
Propagator propagation.TextMapPropagator
}
// Logger returns a *slog.Logger named name whose records go to
// t.LoggerProvider, filtered by the App's log level.
func (t Telemetry) Logger(name string) *slog.Logger
// Noop returns a Telemetry that discards everything.
func Noop() Telemetry
// SDK is a Telemetry built from the OTel SDK, with the function that flushes
// and stops it.
type SDK struct {
Telemetry
Shutdown func(ctx context.Context) error
}
// Dialer opens connections, like (*net.Dialer).DialContext. It is the Dial
// field of humus.Host.
type Dialer func(ctx context.Context, network, address string) (net.Conn, error)
// Builder builds an App's telemetry. It is the Telemetry field of humus.App.
type Builder func(ctx context.Context, p bedrock.Process, dial Dialer) (SDK, error)
// FromEnvironment is the Builder of a production App: ReadSettings from
// p.Environment, then New with p.Stdout as the console. When
// OTEL_SERVICE_NAME is unset, it names the service
// unknown_service:<executable name>, from the base name of p.Args[0].
func FromEnvironment(ctx context.Context, p bedrock.Process, dial Dialer) (SDK, error)
// NoopBuilder is a Builder whose Telemetry discards everything, for tests
// that don't assert on telemetry.
func NoopBuilder(ctx context.Context, p bedrock.Process, dial Dialer) (SDK, error)
type Exporter string
const (
ExporterNone Exporter = "none"
ExporterConsole Exporter = "console"
ExporterOTLP Exporter = "otlp"
)
type Settings struct {
ServiceName string // OTEL_SERVICE_NAME; empty when unset
ResourceAttributes []attribute.KeyValue // OTEL_RESOURCE_ATTRIBUTES
Traces Exporter // OTEL_TRACES_EXPORTER
Metrics Exporter // OTEL_METRICS_EXPORTER
Logs Exporter // OTEL_LOGS_EXPORTER
OTLP OTLPSettings // OTEL_EXPORTER_OTLP_ENDPOINT, _PROTOCOL, _HEADERS, _TIMEOUT, _INSECURE
Sampler sdktrace.Sampler // OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG
LogLevel slog.Level // HUMUS_LOG_LEVEL
ShutdownTimeout time.Duration // HUMUS_TELEMETRY_SHUTDOWN_TIMEOUT
}
func ReadSettings(ctx context.Context, env bedrock.Environment) (Settings, error)
// New builds SDK providers from s. Console exporters write to stdout, and
// OTLP exporters connect through dial. An empty s.ServiceName becomes
// unknown_service, as in the OTel spec when the executable name is unknown.
func New(ctx context.Context, s Settings, dial Dialer, stdout io.Writer) (SDK, error)A Service gets its instrumentation from h := otelhttp.NewHandler(mux, "rest",
otelhttp.WithTracerProvider(s.Telemetry.TracerProvider),
otelhttp.WithMeterProvider(s.Telemetry.MeterProvider),
otelhttp.WithPropagators(s.Telemetry.Propagator),
)Settings
The exporter defaults keep today's behaviour: traces and metrics are discarded, and logs go to standard output. The OTel spec's default is Lifecycle integrationThe lifecycle lands without telemetry. This proposal's stories add it: package humus
type App struct {
// Host, DrainDelay and Init, as in the lifecycle proposal.
// Telemetry builds the App's telemetry. It is required:
// telemetry.FromEnvironment in main, and telemetry.NoopBuilder or
// humustest's recorder in tests.
Telemetry telemetry.Builder
}
type Setup struct {
// Process and Host, as in the lifecycle proposal.
// Telemetry is the App's telemetry. Init and the Services get their
// tracers, meters and loggers from it.
Telemetry telemetry.Telemetry
}
The integration's tests show the following:
Design decisions
Testing
func TestCreateCart_RecordsASpan(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
host := humustest.NewHost(t)
tel := humustest.NewTelemetry()
app := humus.App{Host: host.Host(), Telemetry: tel.Builder(), Init: app.Init}
p := humustest.Start(t, app)
synctest.Wait()
resp, err := host.HTTPClient().Post("http://carts:8080/carts", "application/json", nil)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
p.Signal(syscall.SIGTERM)
require.Zero(t, p.Wait().Code())
require.Equal(t, "POST /carts", tel.Spans()[0].Name())
})
}An end-to-end test can instead set Open questions
|
Revision 2: the open questions answered, and settings the end user sourcesThis revision answers the seven open questions of the lifecycle integration revision. It also applies where Proposal: configuration for humus apps landed in its final revision: humus packages take plain Go values, and the end user sources them. So Each section below replaces the section of the same name in that revision, and Summary and Migration from today replace the post's. Background, Out of scope and References from the post still apply. Decisions on the open questions
Question 2: humus's console log formatOTel's Console logs are humus's default, in production too, so humus owns their format, as #182 did. {"timestamp":"2026-10-16T06:56:32.447829601Z","severity_text":"INFO","severity_number":9,"body":"cart created","trace_id":"e2f668b6c06f2de82e61e2517c2d7a07","span_id":"ff25eb972a5557d5","trace_flags":"01","scope":"carts","attributes":{"cart.id":"c-123"}}The keys are written in this order:
Traces and metrics on the console keep OTel's SummaryA new package,
Proposed APIpackage telemetry // github.com/z5labs/humus/telemetry
// SDK is the set of OpenTelemetry providers an App hands its Services. It
// replaces OTel's globals: every humus package takes one.
type SDK struct {
TracerProvider trace.TracerProvider
MeterProvider metric.MeterProvider
LoggerProvider log.LoggerProvider
Propagator propagation.TextMapPropagator
// Shutdown flushes and stops the providers. humus.App.Run calls it after
// the OnStop functions. Init and the Services never call it.
Shutdown func(ctx context.Context) error
}
// Noop returns an SDK whose providers discard everything and whose Shutdown
// does nothing. Its propagator is still W3C trace context plus baggage, so a
// request's trace passes through a service whose telemetry is off.
func Noop() SDK
// Dialer opens connections, like (*net.Dialer).DialContext. It is the Dial
// field of humus.Host.
type Dialer func(ctx context.Context, network, address string) (net.Conn, error)
// Builder builds an App's SDK. It is the Telemetry field of humus.App. The
// end user writes it: it sources Settings from p and calls New.
type Builder func(ctx context.Context, p bedrock.Process, dial Dialer) (SDK, error)
// NoopBuilder is a Builder that returns Noop(), for tests that don't assert
// on telemetry.
func NoopBuilder(ctx context.Context, p bedrock.Process, dial Dialer) (SDK, error)
type Exporter string
const (
ExporterNone Exporter = "none"
ExporterConsole Exporter = "console"
ExporterOTLP Exporter = "otlp"
)
type Protocol string
const (
ProtocolGRPC Protocol = "grpc"
ProtocolHTTPProtobuf Protocol = "http/protobuf"
)
// Settings configures New. Every field is a plain value, and a field's zero
// value means zero, never "use the default".
type Settings struct {
Disabled bool
ServiceName string
ResourceOptions []resource.Option
Traces Exporter
Metrics Exporter
Logs Exporter
OTLP OTLPSettings
Sampler sdktrace.Sampler
LogLevel log.Severity
ShutdownTimeout time.Duration
}
type OTLPSettings struct {
Endpoint string
Protocol Protocol
Headers map[string]string
TLS *tls.Config
Timeout time.Duration
}
// DefaultSettings returns New's defaults.
func DefaultSettings() Settings
// New builds SDK providers from s. It runs every check, and reports every bad
// field in one error, naming each one. With s.Disabled, it ignores every
// other field and returns Noop(). Console exporters write to stdout, which
// need not be safe for concurrent use. OTLP exporters connect through dial.
func New(ctx context.Context, s Settings, dial Dialer, stdout io.Writer) (SDK, error)
// The checks, one for each field with a valid range, as #631 sets out. Each
// reports what is wrong, without naming the field.
func CheckServiceName(name string) error
func CheckExporter(e Exporter) error // Traces, Metrics and Logs share it
func CheckOTLPEndpoint(addr string) error
func CheckOTLPProtocol(p Protocol) error
func CheckOTLPTimeout(d time.Duration) error
func CheckSampler(s sdktrace.Sampler) error
func CheckLogLevel(l log.Severity) error
func CheckShutdownTimeout(d time.Duration) errorThe end user writes the func buildTelemetry(ctx context.Context, p bedrock.Process, dial telemetry.Dialer) (telemetry.SDK, error) {
d := telemetry.DefaultSettings()
env := func(name string) config.Value[string] { return config.Env(p.Environment, name) }
exporter := func(name string, def telemetry.Exporter) config.Value[telemetry.Exporter] {
return env(name).
Map(func(s string) telemetry.Exporter { return telemetry.Exporter(s) }).
Default(def).
Validate(telemetry.CheckExporter)
}
s, err := config.Struct(func(r *config.Resolver) telemetry.Settings {
s := d
s.Disabled = r.Get(env("OTEL_SDK_DISABLED").Parse(strconv.ParseBool).Default(d.Disabled))
s.ServiceName = r.Get(env("OTEL_SERVICE_NAME").Default("carts").Validate(telemetry.CheckServiceName))
s.Traces = r.Get(exporter("OTEL_TRACES_EXPORTER", d.Traces))
s.Metrics = r.Get(exporter("OTEL_METRICS_EXPORTER", d.Metrics))
s.Logs = r.Get(exporter("OTEL_LOGS_EXPORTER", d.Logs))
s.ResourceOptions = []resource.Option{resource.WithHost(), resource.WithContainer()} // opting in to detectors
return s
}).Read(ctx)
if err != nil {
return telemetry.SDK{}, err // exit 78
}
return telemetry.New(ctx, s, dial, p.Stdout)
}
func main() {
bedrock.Main(humus.App{
Host: humus.OSHost(),
ShutdownGracePeriod: 10 * time.Second,
Telemetry: buildTelemetry,
Init: app.Init,
})
}A Service gets its instrumentation from h := otelhttp.NewHandler(mux, "rest",
otelhttp.WithTracerProvider(s.Telemetry.TracerProvider),
otelhttp.WithMeterProvider(s.Telemetry.MeterProvider),
otelhttp.WithPropagators(s.Telemetry.Propagator),
)
logger := otelslog.NewLogger("carts", otelslog.WithLoggerProvider(s.Telemetry.LoggerProvider))
// In a handler: logger.InfoContext(r.Context(), "cart created"), so the record carries the request's span.SettingsThe field godoc replaces the variable table. The last column lists the OTel spec variable that has the same meaning, for an end user who wants to stay compatible. humus reads none of them.
Lifecycle integrationpackage humus
type App struct {
// Host, ShutdownGracePeriod, DrainDelay and Init, as in the lifecycle
// and configuration proposals.
// Telemetry builds the App's SDK. It is required: the end user's own
// Builder in main, and telemetry.NoopBuilder or humustest's recorder in
// tests.
Telemetry telemetry.Builder
}
type Setup struct {
// Process and Host, as in the lifecycle proposal.
// Telemetry is the App's SDK. Init and the Services get their tracers,
// meters and loggers from it.
Telemetry telemetry.SDK
}
The integration's tests show the following:
Design decisions
TestingProposal: humustest's last comment hands the telemetry recorder to this proposal, so this proposal adds it to humustest: package humustest
// Telemetry records everything an App emits, using the real SDK with
// in-memory exporters: tracetest's span recorder, an sdkmetric.ManualReader
// and an in-memory log exporter. It records every severity.
type Telemetry struct{ /* unexported */ }
func NewTelemetry() *Telemetry
func (t *Telemetry) Builder() telemetry.Builder
func (t *Telemetry) Spans() []sdktrace.ReadOnlySpan
func (t *Telemetry) Metrics(ctx context.Context) (metricdata.ResourceMetrics, error)
func (t *Telemetry) Logs() []sdklog.RecordA whole-App test sees exactly what production would export: func TestCreateCart_RecordsASpan(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
host := humustest.NewHost(t)
tel := humustest.NewTelemetry()
app := humus.App{
Host: host.Host(),
ShutdownGracePeriod: 10 * time.Second,
Telemetry: tel.Builder(),
Init: app.Init,
}
p, status := bedrocktest.Start(t, app)
synctest.Wait()
resp, err := host.HTTPClient().Post("http://carts:8080/carts", "application/json", nil)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
p.Signal(syscall.SIGTERM)
require.Zero(t, (<-status).Code())
require.Equal(t, "POST /carts", tel.Spans()[0].Name())
})
}A golden test pins the console format from question 2, byte for byte. It passes against a prototype of the exporter on func TestConsoleExporter_WritesOneJSONLinePerRecord(t *testing.T) {
t.Parallel()
var out bytes.Buffer
lp := sdklog.NewLoggerProvider(sdklog.WithProcessor(sdklog.NewSimpleProcessor(newConsoleExporter(&out))))
sc := trace.NewSpanContext(trace.SpanContextConfig{
TraceID: trace.TraceID{0xe2, 0xf6, 0x68, 0xb6, 0xc0, 0x6f, 0x2d, 0xe8, 0x2e, 0x61, 0xe2, 0x51, 0x7c, 0x2d, 0x7a, 0x07},
SpanID: trace.SpanID{0xff, 0x25, 0xeb, 0x97, 0x2a, 0x55, 0x57, 0xd5},
TraceFlags: trace.FlagsSampled,
})
var r log.Record
r.SetTimestamp(time.Date(2026, 10, 16, 6, 56, 32, 447829601, time.UTC))
r.SetSeverity(log.SeverityInfo)
r.SetSeverityText("INFO")
r.SetBody(attribute.StringValue("cart created"))
r.AddAttributes(attribute.String("cart.id", "c-123"))
lp.Logger("carts").Emit(trace.ContextWithSpanContext(t.Context(), sc), r)
require.NoError(t, lp.Shutdown(t.Context()))
require.Equal(t, `{"timestamp":"2026-10-16T06:56:32.447829601Z","severity_text":"INFO","severity_number":9,`+
`"body":"cart created","trace_id":"e2f668b6c06f2de82e61e2517c2d7a07","span_id":"ff25eb972a5557d5",`+
`"trace_flags":"01","scope":"carts","attributes":{"cart.id":"c-123"}}`+"\n", out.String())
}The console exporter's other tests show the following:
The package's own tests follow #631's list:
An end-to-end test can leave Migration from today
Consequences for the other proposals
Open questionsNone. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Note
This post is superseded by two revisions below, read in order: telemetry owns its lifecycle integration, and the open questions answered, and settings the end user sources, which settles every open question. humus reads no variables for telemetry. The end user's
Builderfills a plaintelemetry.Settingsand callstelemetry.New, and Services receive atelemetry.SDK. By default only logs are exported, top.Stdout, as JSON lines in a format humus owns, with the trace context on every record logged in a span. The Background, Out of scope and References sections still apply. The rest of this post is kept for context.Note
Part of the humus re-evaluation; see Proposal: humus on the bedrock process model. It depends on the charter and on Proposal: configuration for humus apps. It supplies the
Telemetryfield of Proposal: the humus App lifecycle, and it takes over the scope of #623.Summary
A new package,
telemetry, builds an App's OpenTelemetry providers with direct SDK calls, from settings read throughp.Environment.telemetry.Telemetryvalue, and the lifecycle shuts them down last.slog.Default.p.Stdout.Background
humus relies on OTel's global state everywhere:
restbuilds its providers with bedrock'sruntime/otel, whoseBuildRuntimeregisters them globally (rest/rest.go#L256-L266). The error handler it registers discards every error:otel.ErrorHandlerFunc(func(err error) {}). Failed exports are invisible today.grpcreadsotel.GetMeterProvider,otel.GetTracerProviderandotel.GetTextMapPropagator(grpc/api.go#L45-L50).queue/kafkagets its logger, tracer and meter from package-level functions over the globals (queue/kafka/otel.go#L17-L27).humus.Loggerandhumus.LogHandleruse otelslog's global logger provider (humus.go#L19-L26), and so doesinternal/grpchealth.The real process leaks in.
resource.Default()readsOTEL_SERVICE_NAMEandOTEL_RESOURCE_ATTRIBUTESfrom the real environment (rest/rest.go#L235), and the stdout log exporter is wired toos.Stdout(rest/rest.go#L341).bedrock lists these as ambient leaks. Its package documentation says "OpenTelemetry's resource.Default reads the real environment, and its global providers are process-wide state".
runtime/otelis gone, and MIGRATING.md maps eachBuild*function onto its SDK constructor.The SDK reads the real environment on its own (v1.46.0):
sdktrace.NewTracerProviderappliesOTEL_TRACES_SAMPLER,OTEL_TRACES_SAMPLER_ARGand theOTEL_SPAN_*limits before its options (sdk/trace/provider.go:110).OTEL_BSP_*andOTEL_BLRP_*.OTEL_METRIC_EXPORT_*, and the meter provider reads its exemplar filter and cardinality limit.OTEL_EXPORTER_OTLP_*before their options, including certificate paths, which they then read from disk.OTEL_GO_X_*.Explicit options override most of these, but not all.
What story(issue-484): rest migrate to bedrock v0210 #485 deleted. Before it, humus had log-level filtering (
internal/otel/filter.go) and its own stdout log exporter (feat(issue-181): implement custom stdout log exporter #182).Proposed API
A Service gets its instrumentation from
s.Telemetry, never from the globals:Settings
OTEL_SERVICE_NAMENameservice.nameOTEL_RESOURCE_ATTRIBUTESkey=value,…OTEL_TRACES_EXPORTERnonenone,consoleorotlpOTEL_METRICS_EXPORTERnonenone,consoleorotlpOTEL_LOGS_EXPORTERconsolenone,consoleorotlpOTEL_EXPORTER_OTLP_ENDPOINTlocalhost:4317OTEL_EXPORTER_OTLP_PROTOCOLgrpcgrpcorhttp/protobufOTEL_EXPORTER_OTLP_HEADERSkey=value,…OTEL_TRACES_SAMPLER,OTEL_TRACES_SAMPLER_ARGparentbased_always_onHUMUS_LOG_LEVELinfodebug,info,warnorerrorHUMUS_TELEMETRY_SHUTDOWN_TIMEOUT5sThe exporter defaults keep today's behaviour: traces and metrics are discarded, and logs go to standard output. The OTel spec's default is
otlp; see the open questions.Design decisions
Telemetry, and third-party instrumentation gets its providers passed explicitly: otelhttp's options, grpc'sstats/opentelemetryoptions, kotel, andotelslog.WithLoggerProvider. Lint forbidsotel.Set*,otel.Get*,otel.Tracer,otel.Meterandglobal.SetLoggerProviderin humus. A user whose libraries read only the globals can set them inInit. That makes process-wide state their choice, and humus offers no helper for it.p.Environment. humus passes every relevant SDK option explicitly, so the SDK's own environment reads are overridden: the sampler, span limits, batch settings, OTLP headers, TLS, compression, timeout and resource. The residual leaks, such asOTEL_GO_X_*and anything the SDK starts reading in a later release, are documented. A conformance test proves that explicit options win for the variables that matter. It runs on its own, not in parallel, against the real environment.Settings, withresource.NewWithAttributesand semconv. There is noresource.Defaultand no host or process detectors, because detectors read the filesystem and the real process.p.Stdout, so bedrocktest captures it.OnStop, then telemetry, so spans that record a slow shutdown are still exported.telemetry, nototel, so it doesn't clash withgo.opentelemetry.io/otelat every import site.Testing
humustest.NewTelemetry()(Proposal: humustest) is aBuilderover the real SDK with in-memory exporters:tracetest's span recorder, asdkmetric.ManualReaderand an in-memory log exporter. A test sees exactly what production would export:An end-to-end test can instead set
OTEL_LOGS_EXPORTER=consolewithbedrocktest.WithEnvand assert onp.Stdout().Migration from today
rest.OTLPExporter(target)becomesOTEL_TRACES_EXPORTER,OTEL_METRICS_EXPORTERandOTEL_LOGS_EXPORTERset tootlp, plusOTEL_EXPORTER_OTLP_ENDPOINT.humus.Logger(name)andhumus.LogHandler(name)becomes.Telemetry.Logger(name)and itsHandler().grpcandqueue/kafkatake aTelemetryinstead of reading the globals (in their wave-2 proposals).runtime/otelBuild*functions become the SDK calls insidetelemetry.New, as mapped in bedrock's MIGRATING.md.Open questions
none,none,console), or follow the spec's default ofotlp, which tries to reach a collector onlocalhost:4317?stdoutlogJSON is verbose. Should humus bring back its own compact exporter (feat(issue-181): implement custom stdout log exporter #182), or use slog's JSON handler and bridge to the provider only for OTLP?otel.Handle. Today humus registers a handler that throws them away; the default handler writes to the real stderr throughlog. Neither can be injected per provider. Should humus accept the leak, set the handler once (process-wide state), or log those errors through the App's logger by some other route?Telemetry.Logger. ShouldTelemetryexpose aLoggermethod, or only theLoggerProviderplus a bridge helper?OTEL_SDK_DISABLED. Honour it?telemetry,otelorobservability?Out of scope
References
doc.go, "Ambient leaks"bedrock/runtime/otelAll reactions