Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ff2a629
fix(deps): migrate docker executor to moby/moby client+api modules
skhaz Jun 27, 2026
e5d910e
fix(otel): flush tracer provider on shutdown
skhaz Jun 27, 2026
5ab2184
fix(metrics): enable function-call metrics interceptor
skhaz Jun 27, 2026
556c1cd
test(telemetrytest): real span harness and gauge regression test
skhaz Jun 27, 2026
973248a
test(otel): cover the OTel metrics bridge exporter
skhaz Jun 27, 2026
e42f4c3
test(otel): assert real spans for HTTP, interceptor, and queue
skhaz Jun 27, 2026
f33d98d
test(otel): prove cross-surface trace propagation
skhaz Jun 27, 2026
17a0477
test(metrics): verify dual exporter fan-out does not double-count
skhaz Jun 27, 2026
8d5ed38
fix(otel): capture HTTP response status code in server span
skhaz Jun 27, 2026
3bb3c16
feat(queue): add consumer lifecycle metrics
skhaz Jun 27, 2026
81171d6
feat(process): add process lifecycle metrics and root spans
skhaz Jun 27, 2026
e463fe6
feat(temporal): bridge SDK metrics onto the wippy collector
skhaz Jun 27, 2026
367b214
feat(cdc): emit stream error counter
skhaz Jun 27, 2026
ad3e654
feat(http): trace outbound client requests via otelhttp
skhaz Jun 27, 2026
fd50307
fix(otel): temporal uses the global propagator and surfaces load errors
skhaz Jun 27, 2026
b87fc19
test(otel): OTLP traces round-trip through a live Jaeger collector
skhaz Jun 27, 2026
7bb54cb
fix(http): set route label before pre-match middleware
skhaz Jun 27, 2026
23e581d
fix(otel): address review findings on HTTP middleware and metrics war…
skhaz Jun 27, 2026
fefb24d
fix(metrics,otel): correct gauge Inc/Dec semantics and harden HTTP tr…
skhaz Jun 27, 2026
11cf605
fix(lint): scope G124 exclude to outbound-cookie handler
skhaz Jun 27, 2026
0a3ddf9
feat(otel): enrich resource with standard semconv and complete env co…
skhaz Jun 27, 2026
38c43d7
feat(store/kv): emit operation count and latency metrics
skhaz Jun 27, 2026
009b425
feat(sql): emit query/exec count and latency metrics
skhaz Jun 27, 2026
c00b3d5
docs(otel): document gauge type constraint in MetricsExporter
skhaz Jun 27, 2026
8af8e64
fix(store/memory): add KV metrics to memory store backend
skhaz Jun 28, 2026
7c0fb05
fix(store/memory): wire collector from boot through manager to stores
skhaz Jun 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ linters:
- linters:
- gosec
text: "G118"
- linters:
- gosec
text: "G124"
path: service/http/client/handler\.go
- linters:
- revive
text: "unexported-return"
Expand Down
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ otel-up:
otel-down:
cd tests && docker-compose down

# otel-e2e starts only Jaeger and runs the OTLP round-trip test (needs docker).
otel-e2e:
cd tests && docker-compose up -d jaeger
go test -tags integration -run TestOTLP_TracesReachJaeger ./tests/ -timeout 120s

# Wippy CLI build targets
WIPPY_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
WIPPY_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
Expand Down
3 changes: 2 additions & 1 deletion api/metrics/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ package metrics
// MetricType constants.
const (
TypeCounter MetricType = "counter"
TypeGauge MetricType = "gauge"
TypeGauge MetricType = "gauge" // absolute set (GaugeSet)
TypeGaugeAdd MetricType = "gauge_add" // relative delta (GaugeInc/GaugeDec)
TypeHistogram MetricType = "histogram"
)

Expand Down
2 changes: 2 additions & 0 deletions boot/components/dispatchers/sql_dispatcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/wippyai/runtime/api/boot"
dispatcherapi "github.com/wippyai/runtime/api/dispatcher"
metricsapi "github.com/wippyai/runtime/api/metrics"
"github.com/wippyai/runtime/service/sql"
)

Expand All @@ -20,6 +21,7 @@ func SQL() boot.Component {
return ctx, ErrDispatcherNotFound
}
svc := sql.NewDispatcher()
svc.SetCollector(metricsapi.GetCollector(ctx))
svc.RegisterAll(reg.Register)
return ctx, nil
},
Expand Down
3 changes: 2 additions & 1 deletion boot/components/metrics/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import "github.com/wippyai/runtime/api/boot"
func All() []boot.Component {
return []boot.Component{
Metrics(),
// MetricsInterceptor(),
Interceptor(),
Process(),
}
}
14 changes: 14 additions & 0 deletions boot/components/metrics/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,17 @@ import "github.com/wippyai/runtime/api/boot"

// Name is the component name for metrics.
const Name boot.Name = "metrics"

// InterceptorName is the component name for the function-call metrics interceptor.
const InterceptorName boot.Name = "metrics-interceptor"

// ProcessName is the component name for the process lifecycle metrics handler.
const ProcessName boot.Name = "metrics-process"

// interceptorName is the external dependency name of the function interceptor
// registry component (boot/components/system.Interceptor).
const interceptorName boot.Name = "interceptor"

// lifecycleName is the external dependency name of the process lifecycle
// registry component (boot/components/system.LifecycleName).
const lifecycleName boot.Name = "system.lifecycle"
57 changes: 57 additions & 0 deletions boot/components/metrics/interceptor.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,60 @@
// SPDX-License-Identifier: MPL-2.0

package metrics

import (
"context"

"github.com/wippyai/runtime/api/boot"
apiinterceptor "github.com/wippyai/runtime/api/function"
metricsapi "github.com/wippyai/runtime/api/metrics"
metricsinterceptor "github.com/wippyai/runtime/service/metrics/interceptor"
)

// Interceptor wires the function-call metrics interceptor
// (wippy_function_calls / wippy_function_duration / wippy_function_in_flight)
// into the function interceptor registry. It runs at order 50 so the duration
// timer wraps the full call, including the OTel tracing interceptor (order 100).
func Interceptor() boot.Component {
return boot.New(boot.P{
Name: InterceptorName,
DependsOn: []boot.Name{Name, interceptorName},
Load: func(ctx context.Context) (context.Context, error) {
if !loadInterceptorEnabled(ctx) {
return ctx, nil
}

collector := metricsapi.GetCollector(ctx)
if collector == nil {
return ctx, nil
}

registry := apiinterceptor.GetInterceptorRegistry(ctx)
if registry == nil {
return ctx, nil
}

if err := registry.Register("metrics", metricsinterceptor.NewFunctionInterceptor(collector, true), 50); err != nil {
return ctx, err
}

return ctx, nil
},
})
}

// loadInterceptorEnabled reads metrics.interceptor.enabled, defaulting to true
// so function-call metrics emit out of the box.
func loadInterceptorEnabled(ctx context.Context) bool {
bootCfg := boot.GetConfig(ctx)
if bootCfg == nil {
return true
}

metricsCfg := bootCfg.Sub("metrics")
if metricsCfg == nil {
return true
}

return metricsCfg.GetBool("interceptor.enabled", true)
}
112 changes: 112 additions & 0 deletions boot/components/metrics/interceptor_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// SPDX-License-Identifier: MPL-2.0

package metrics

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/wippyai/runtime/api/boot"
ctxapi "github.com/wippyai/runtime/api/context"
"github.com/wippyai/runtime/api/function"
logapi "github.com/wippyai/runtime/api/logs"
metricsapi "github.com/wippyai/runtime/api/metrics"
"github.com/wippyai/runtime/api/registry"
"github.com/wippyai/runtime/api/runtime"
"github.com/wippyai/runtime/internal/telemetrytest"
metricsinterceptor "github.com/wippyai/runtime/service/metrics/interceptor"
"go.uber.org/zap"
)

type mockInterceptorRegistry struct {
registrations []registeredInterceptor
}

type registeredInterceptor struct {
interceptor function.Interceptor
name string
order int
}

func (m *mockInterceptorRegistry) Register(name string, i function.Interceptor, order int) error {
m.registrations = append(m.registrations, registeredInterceptor{name: name, interceptor: i, order: order})
return nil
}

func (m *mockInterceptorRegistry) Unregister(string) error { return nil }

func (m *mockInterceptorRegistry) Execute(ctx context.Context, f function.Func, task runtime.Task) (*runtime.Result, error) {
return f(ctx, task)
}

func TestMetricsInterceptor_RegistersWhenEnabled(t *testing.T) {
component := Interceptor()
assert.Equal(t, InterceptorName, component.Name())

ctx := ctxapi.NewRootContext()
ctx = logapi.WithLogger(ctx, zap.NewNop())
ctx = boot.WithConfig(ctx, boot.NewConfig(boot.WithSection("metrics", map[string]any{
"interceptor.enabled": true,
})))
rec := telemetrytest.NewRecorder()
ctx = metricsapi.WithCollector(ctx, rec)
reg := &mockInterceptorRegistry{}
ctx = function.WithInterceptorRegistry(ctx, reg)

newCtx, err := component.Load(ctx)
require.NoError(t, err)
require.NotNil(t, newCtx)
require.Len(t, reg.registrations, 1)

r := reg.registrations[0]
assert.Equal(t, "metrics", r.name)
assert.Equal(t, 50, r.order)

fi, ok := r.interceptor.(*metricsinterceptor.FunctionInterceptor)
require.True(t, ok, "registered interceptor must be *FunctionInterceptor")

funcID := registry.NewID("ns", "test_func")
task := runtime.Task{ID: funcID}
next := func(_ context.Context, _ runtime.Task) (*runtime.Result, error) {
return &runtime.Result{}, nil
}
_, err = fi.Handle(context.Background(), task, next)
require.NoError(t, err)
assert.Equal(t, 1.0, rec.CounterValue(metricsinterceptor.FunctionCalls,
metricsapi.Labels{"function_id": funcID.String(), "status": "success"}))
}

func TestMetricsInterceptor_NotRegisteredWhenDisabled(t *testing.T) {
component := Interceptor()

ctx := ctxapi.NewRootContext()
ctx = logapi.WithLogger(ctx, zap.NewNop())
ctx = boot.WithConfig(ctx, boot.NewConfig(boot.WithSection("metrics", map[string]any{
"interceptor.enabled": false,
})))
ctx = metricsapi.WithCollector(ctx, telemetrytest.NewRecorder())
reg := &mockInterceptorRegistry{}
ctx = function.WithInterceptorRegistry(ctx, reg)

_, err := component.Load(ctx)
require.NoError(t, err)
assert.Empty(t, reg.registrations)
}

func TestMetricsInterceptor_NoCollector(t *testing.T) {
component := Interceptor()

ctx := ctxapi.NewRootContext()
ctx = logapi.WithLogger(ctx, zap.NewNop())
ctx = boot.WithConfig(ctx, boot.NewConfig(boot.WithSection("metrics", map[string]any{
"interceptor.enabled": true,
})))
reg := &mockInterceptorRegistry{}
ctx = function.WithInterceptorRegistry(ctx, reg)

_, err := component.Load(ctx)
require.NoError(t, err)
assert.Empty(t, reg.registrations, "no registration without a collector")
}
36 changes: 36 additions & 0 deletions boot/components/metrics/process.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: MPL-2.0

package metrics

import (
"context"

"github.com/wippyai/runtime/api/boot"
metricsapi "github.com/wippyai/runtime/api/metrics"
processapi "github.com/wippyai/runtime/api/process"
impl "github.com/wippyai/runtime/service/metrics"
)

// Process registers the process lifecycle metrics handler, which emits
// wippy_process_started_total / wippy_process_terminated_total /
// wippy_process_active alongside the existing OTel lifecycle spans.
func Process() boot.Component {
return boot.New(boot.P{
Name: ProcessName,
DependsOn: []boot.Name{Name, lifecycleName},
Load: func(ctx context.Context) (context.Context, error) {
coll := metricsapi.GetCollector(ctx)
if coll == nil {
return ctx, nil
}

reg := processapi.GetLifecycleRegistry(ctx)
if reg == nil {
return ctx, nil
}

reg.Register("metrics", impl.NewProcessLifecycle(coll))
return ctx, nil
},
})
}
14 changes: 11 additions & 3 deletions boot/components/otel/otel.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ import (
logapi "github.com/wippyai/runtime/api/logs"
otelapi "github.com/wippyai/runtime/api/service/otel"
"github.com/wippyai/runtime/service/otel"
"go.opentelemetry.io/otel/trace"
"go.uber.org/zap"
)

func OTel() boot.Component {
var tp trace.TracerProvider

return boot.New(boot.P{
Name: Name,
Load: func(ctx context.Context) (context.Context, error) {
Expand All @@ -35,7 +38,8 @@ func OTel() boot.Component {
return ctx, nil
}

tp, err := otel.InitializeProvider(ctx, cfg, logger)
var err error
tp, err = otel.InitializeProvider(ctx, cfg, logger)
if err != nil {
return ctx, NewOTELInitError(err)
}
Expand All @@ -53,8 +57,12 @@ func OTel() boot.Component {
Start: func(_ context.Context) error {
return nil
},
Stop: func(_ context.Context) error {
return nil
Stop: func(ctx context.Context) error {
logger := logapi.GetLogger(ctx).Named("otel")
if tp == nil {
return nil
}
return otel.ShutdownTracerProvider(ctx, tp, logger)
},
})
}
18 changes: 8 additions & 10 deletions boot/components/otel/otel_temporal.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@ package otel

import (
"context"
"fmt"

"github.com/wippyai/runtime/api/boot"
logapi "github.com/wippyai/runtime/api/logs"
otelapi "github.com/wippyai/runtime/api/service/otel"
temporalapi "github.com/wippyai/runtime/api/service/temporal"
temporalboot "github.com/wippyai/runtime/boot/components/service/temporal"
"github.com/wippyai/runtime/service/otel"
"go.opentelemetry.io/otel/propagation"
gootel "go.opentelemetry.io/otel"
temporalotel "go.temporal.io/sdk/contrib/opentelemetry"
"go.uber.org/zap"
)

// Temporal creates the OTEL tracing interceptor for Temporal workflows and activities.
Expand Down Expand Up @@ -60,17 +60,15 @@ func Temporal() boot.Component {
return ctx, nil
}

// Create Temporal tracing interceptor with the shared tracer
// Create Temporal tracing interceptor with the shared tracer and the
// globally-configured propagator so Temporal follows the same W3C
// propagation settings as every other surface.
tracingInterceptor, err := temporalotel.NewTracingInterceptor(temporalotel.TracerOptions{
Tracer: tracer,
TextMapPropagator: propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
),
Tracer: tracer,
TextMapPropagator: gootel.GetTextMapPropagator(),
})
if err != nil {
logger.Error("failed to create Temporal tracing interceptor", zap.Error(err))
return ctx, nil
return ctx, fmt.Errorf("failed to create Temporal tracing interceptor: %w", err)
}

// Register with both client and worker registries
Expand Down
3 changes: 3 additions & 0 deletions boot/components/queue/consumers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/wippyai/runtime/api/event"
"github.com/wippyai/runtime/api/function"
logapi "github.com/wippyai/runtime/api/logs"
metricsapi "github.com/wippyai/runtime/api/metrics"
"github.com/wippyai/runtime/api/payload"
queueapi "github.com/wippyai/runtime/api/queue"
regapi "github.com/wippyai/runtime/api/registry"
Expand All @@ -34,6 +35,7 @@ func Consumers() boot.Component {
handlers := bootpkg.GetHandlerRegistry(ctx)
queueMgr := queueapi.GetManager(ctx)
funcReg := function.GetRegistry(ctx)
coll := metricsapi.GetCollector(ctx)

if reg := regapi.GetRegistry(ctx); reg != nil {
consumerPatterns := []regapi.DependencyPattern{
Expand All @@ -53,6 +55,7 @@ func Consumers() boot.Component {
funcReg,
dtt,
logger.Named("queue.consumer"),
coll,
)

handlers.RegisterListener("queue.consumer", manager)
Expand Down
Loading