-
Notifications
You must be signed in to change notification settings - Fork 239
Forward passthrough headers on the Serve path #5561
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
6ca46c6
Restore session-stable passthrough-header forwarding on the Serve path
tgrunnagle 55d67a2
Address code review feedback for #5560
tgrunnagle f18835f
Fix defensive copy, document anti-pattern #1 exception, clarify comments
tgrunnagle 4ae5e52
Add unit test coverage for new session-header path
tgrunnagle f08f3d0
Switch passthrough headers to per-request forwarding
tgrunnagle 025ee28
Fix stale comment describing removed session-stable machinery
tgrunnagle e7adb86
Fix stale doc comments in integration test files
tgrunnagle File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| package core | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "time" | ||
|
|
||
| "go.opentelemetry.io/otel/attribute" | ||
| "go.opentelemetry.io/otel/codes" | ||
| "go.opentelemetry.io/otel/metric" | ||
| "go.opentelemetry.io/otel/trace" | ||
|
|
||
| "github.com/stacklok/toolhive/pkg/telemetry" | ||
| "github.com/stacklok/toolhive/pkg/vmcp/composer" | ||
| ) | ||
|
|
||
| // instrumentationName is the OTEL scope used for all metrics and traces emitted | ||
| // by the core. Must match the scope used by pkg/vmcp/internal/backendtelemetry | ||
| // and pkg/vmcp/server/sessionmanager so they share the same Prometheus namespace. | ||
| const instrumentationName = "github.com/stacklok/toolhive/pkg/vmcp" | ||
|
|
||
| // workflowInstruments holds pre-built OTEL instruments for workflow execution | ||
| // telemetry. Created once in core.New when TelemetryProvider is set and reused | ||
| // across all per-call composer factories. | ||
| type workflowInstruments struct { | ||
| tracer trace.Tracer | ||
| executionsTotal metric.Int64Counter | ||
| errorsTotal metric.Int64Counter | ||
| executionDuration metric.Float64Histogram | ||
| } | ||
|
|
||
| // newWorkflowInstruments creates the OTEL instruments for workflow execution | ||
| // telemetry. Returns nil if provider is nil (telemetry disabled). | ||
| // The metric names match those emitted by the session-factory composite-tool | ||
| // decorator in pkg/vmcp/server/sessionmanager so all telemetry appears under | ||
| // the same Prometheus metrics regardless of which path executed the workflow. | ||
| func newWorkflowInstruments(provider *telemetry.Provider) (*workflowInstruments, error) { | ||
| if provider == nil { | ||
| return nil, nil | ||
| } | ||
|
|
||
| meter := provider.MeterProvider().Meter(instrumentationName) | ||
|
|
||
| executions, err := meter.Int64Counter( | ||
| "toolhive_vmcp_workflow_executions", | ||
| metric.WithDescription("Total number of workflow executions"), | ||
| ) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to create workflow executions counter: %w", err) | ||
| } | ||
|
|
||
| errors, err := meter.Int64Counter( | ||
| "toolhive_vmcp_workflow_errors", | ||
| metric.WithDescription("Total number of workflow execution errors"), | ||
| ) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to create workflow errors counter: %w", err) | ||
| } | ||
|
|
||
| duration, err := meter.Float64Histogram( | ||
| "toolhive_vmcp_workflow_duration", | ||
| metric.WithDescription("Duration of workflow executions in seconds"), | ||
| metric.WithUnit("s"), | ||
| metric.WithExplicitBucketBoundaries(telemetry.MCPHistogramBuckets...), | ||
| ) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to create workflow duration histogram: %w", err) | ||
| } | ||
|
|
||
| return &workflowInstruments{ | ||
| tracer: provider.TracerProvider().Tracer(instrumentationName), | ||
| executionsTotal: executions, | ||
| errorsTotal: errors, | ||
| executionDuration: duration, | ||
| }, nil | ||
| } | ||
|
|
||
| // telemetryComposer wraps composer.Composer.ExecuteWorkflow with OTEL metrics | ||
| // and tracing. ValidateWorkflow is delegated to the base without instrumentation | ||
| // (validation is called once at startup, not on the hot path). | ||
| type telemetryComposer struct { | ||
| base composer.Composer | ||
| instruments *workflowInstruments | ||
| } | ||
|
|
||
| var _ composer.Composer = (*telemetryComposer)(nil) | ||
|
|
||
| // ExecuteWorkflow records execution count, duration, and errors before delegating | ||
| // to the wrapped composer. Uses def.Name as the workflow_name metric attribute to | ||
| // match the label emitted by the session-factory path in sessionmanager. | ||
| func (c *telemetryComposer) ExecuteWorkflow( | ||
| ctx context.Context, def *composer.WorkflowDefinition, params map[string]any, | ||
| ) (*composer.WorkflowResult, error) { | ||
| commonAttrs := []attribute.KeyValue{attribute.String("workflow.name", def.Name)} | ||
|
|
||
| ctx, span := c.instruments.tracer.Start(ctx, "core.ExecuteWorkflow", | ||
| trace.WithAttributes(commonAttrs...), | ||
| ) | ||
| defer span.End() | ||
|
|
||
| metricAttrs := metric.WithAttributes(commonAttrs...) | ||
| start := time.Now() | ||
| c.instruments.executionsTotal.Add(ctx, 1, metricAttrs) | ||
|
|
||
| result, err := c.base.ExecuteWorkflow(ctx, def, params) | ||
|
|
||
| c.instruments.executionDuration.Record(ctx, time.Since(start).Seconds(), metricAttrs) | ||
|
|
||
| if err != nil { | ||
| c.instruments.errorsTotal.Add(ctx, 1, metricAttrs) | ||
| span.RecordError(err) | ||
| span.SetStatus(codes.Error, err.Error()) | ||
| } | ||
|
|
||
| return result, err | ||
| } | ||
|
|
||
| // ValidateWorkflow delegates to the base composer without instrumentation. | ||
| func (c *telemetryComposer) ValidateWorkflow(ctx context.Context, def *composer.WorkflowDefinition) error { | ||
| return c.base.ValidateWorkflow(ctx, def) | ||
| } | ||
|
|
||
| // GetWorkflowStatus delegates to the base composer without instrumentation. | ||
| func (c *telemetryComposer) GetWorkflowStatus(ctx context.Context, workflowID string) (*composer.WorkflowStatus, error) { | ||
| return c.base.GetWorkflowStatus(ctx, workflowID) | ||
| } | ||
|
|
||
| // CancelWorkflow delegates to the base composer without instrumentation. | ||
| func (c *telemetryComposer) CancelWorkflow(ctx context.Context, workflowID string) error { | ||
| return c.base.CancelWorkflow(ctx, workflowID) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,171 @@ | ||
| // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| package core | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| sdkmetric "go.opentelemetry.io/otel/sdk/metric" | ||
| "go.opentelemetry.io/otel/sdk/metric/metricdata" | ||
| tracesdk "go.opentelemetry.io/otel/sdk/trace" | ||
| tracenoop "go.opentelemetry.io/otel/trace/noop" | ||
|
|
||
| "github.com/stacklok/toolhive/pkg/vmcp/composer" | ||
| ) | ||
|
|
||
| // stubComposer is already declared in core_calls_test.go (same package). | ||
| // Its fields are: result *composer.WorkflowResult, err error. | ||
|
|
||
| // newTestInstruments creates a workflowInstruments backed by an in-memory OTEL SDK | ||
| // and returns the instruments plus a ManualReader for metric assertions. | ||
| // The metric names match production names exactly so the assertions mirror what | ||
| // Prometheus would expose. | ||
| func newTestInstruments(t *testing.T) (*workflowInstruments, *sdkmetric.ManualReader) { | ||
| t.Helper() | ||
| reader := sdkmetric.NewManualReader() | ||
| mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) | ||
| tp := tracesdk.NewTracerProvider() | ||
| meter := mp.Meter(instrumentationName) | ||
|
|
||
| executions, err := meter.Int64Counter("toolhive_vmcp_workflow_executions") | ||
| require.NoError(t, err) | ||
| errs, err := meter.Int64Counter("toolhive_vmcp_workflow_errors") | ||
| require.NoError(t, err) | ||
| duration, err := meter.Float64Histogram("toolhive_vmcp_workflow_duration") | ||
| require.NoError(t, err) | ||
|
|
||
| return &workflowInstruments{ | ||
| tracer: tp.Tracer(instrumentationName), | ||
| executionsTotal: executions, | ||
| errorsTotal: errs, | ||
| executionDuration: duration, | ||
| }, reader | ||
| } | ||
|
|
||
| // collectMetrics gathers all metrics from the reader into a snapshot. | ||
| func collectMetrics(t *testing.T, reader *sdkmetric.ManualReader) metricdata.ResourceMetrics { | ||
| t.Helper() | ||
| var rm metricdata.ResourceMetrics | ||
| require.NoError(t, reader.Collect(context.Background(), &rm)) | ||
| return rm | ||
| } | ||
|
|
||
| // findMetricByName returns the first metric with the given name, or nil. | ||
| func findMetricByName(rm metricdata.ResourceMetrics, name string) *metricdata.Metrics { | ||
| for _, sm := range rm.ScopeMetrics { | ||
| for i := range sm.Metrics { | ||
| if sm.Metrics[i].Name == name { | ||
| return &sm.Metrics[i] | ||
| } | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func int64CounterValue(m *metricdata.Metrics) int64 { | ||
| if m == nil { | ||
| return 0 | ||
| } | ||
| s, ok := m.Data.(metricdata.Sum[int64]) | ||
| if !ok { | ||
| return 0 | ||
| } | ||
| var total int64 | ||
| for _, dp := range s.DataPoints { | ||
| total += dp.Value | ||
| } | ||
| return total | ||
| } | ||
|
|
||
| func float64HistogramCount(m *metricdata.Metrics) uint64 { | ||
| if m == nil { | ||
| return 0 | ||
| } | ||
| h, ok := m.Data.(metricdata.Histogram[float64]) | ||
| if !ok { | ||
| return 0 | ||
| } | ||
| var total uint64 | ||
| for _, dp := range h.DataPoints { | ||
| total += dp.Count | ||
| } | ||
| return total | ||
| } | ||
|
|
||
| // TestTelemetryComposer_Success verifies that on a successful ExecuteWorkflow call: | ||
| // - the executions counter increments by 1 | ||
| // - the error counter stays at 0 | ||
| // - the duration histogram records 1 observation | ||
| // - the result from the inner composer is returned unchanged | ||
| func TestTelemetryComposer_Success(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| instruments, reader := newTestInstruments(t) | ||
| want := &composer.WorkflowResult{Output: map[string]any{"key": "val"}} | ||
| tc := &telemetryComposer{ | ||
| base: stubComposer{result: want}, | ||
| instruments: instruments, | ||
| } | ||
|
|
||
| def := &composer.WorkflowDefinition{Name: "test-workflow"} | ||
| got, err := tc.ExecuteWorkflow(context.Background(), def, nil) | ||
| require.NoError(t, err) | ||
| assert.Equal(t, want, got) | ||
|
|
||
| rm := collectMetrics(t, reader) | ||
| assert.Equal(t, int64(1), int64CounterValue(findMetricByName(rm, "toolhive_vmcp_workflow_executions")), | ||
| "executions counter must increment on success") | ||
| assert.Equal(t, int64(0), int64CounterValue(findMetricByName(rm, "toolhive_vmcp_workflow_errors")), | ||
| "errors counter must remain zero on success") | ||
| assert.Equal(t, uint64(1), float64HistogramCount(findMetricByName(rm, "toolhive_vmcp_workflow_duration")), | ||
| "duration histogram must record exactly one observation") | ||
| } | ||
|
|
||
| // TestTelemetryComposer_Error verifies that on a failed ExecuteWorkflow call: | ||
| // - the executions counter still increments by 1 | ||
| // - the error counter also increments by 1 | ||
| // - the duration histogram records 1 observation | ||
| // - the error from the inner composer is propagated | ||
| func TestTelemetryComposer_Error(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| instruments, reader := newTestInstruments(t) | ||
| boom := errors.New("backend exploded") | ||
| tc := &telemetryComposer{ | ||
| base: stubComposer{err: boom}, | ||
| instruments: instruments, | ||
| } | ||
|
|
||
| def := &composer.WorkflowDefinition{Name: "failing-workflow"} | ||
| _, err := tc.ExecuteWorkflow(context.Background(), def, nil) | ||
| require.ErrorIs(t, err, boom) | ||
|
|
||
| rm := collectMetrics(t, reader) | ||
| assert.Equal(t, int64(1), int64CounterValue(findMetricByName(rm, "toolhive_vmcp_workflow_executions")), | ||
| "executions counter must increment even on failure") | ||
| assert.Equal(t, int64(1), int64CounterValue(findMetricByName(rm, "toolhive_vmcp_workflow_errors")), | ||
| "errors counter must increment on failure") | ||
| assert.Equal(t, uint64(1), float64HistogramCount(findMetricByName(rm, "toolhive_vmcp_workflow_duration")), | ||
| "duration histogram must record one observation even on failure") | ||
| } | ||
|
|
||
| // TestTelemetryComposer_DelegatesNonExecuteMethods verifies that ValidateWorkflow, | ||
| // GetWorkflowStatus, and CancelWorkflow delegate to the base without instrumentation. | ||
| func TestTelemetryComposer_DelegatesNonExecuteMethods(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| tc := &telemetryComposer{ | ||
| base: stubComposer{}, | ||
| instruments: &workflowInstruments{tracer: tracenoop.Tracer{}}, | ||
| } | ||
|
|
||
| require.NoError(t, tc.ValidateWorkflow(context.Background(), &composer.WorkflowDefinition{})) | ||
| _, err := tc.GetWorkflowStatus(context.Background(), "any-id") | ||
| require.NoError(t, err) | ||
| require.NoError(t, tc.CancelWorkflow(context.Background(), "any-id")) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.