Skip to content

Commit a9cbdb2

Browse files
committed
add OTel span for picker in scheduling pipeline
Signed-off-by: chinmaychahar <chinmay.cc.06@gmail.com>
1 parent 48b7dff commit a9cbdb2

3 files changed

Lines changed: 310 additions & 2 deletions

File tree

pkg/epp/scheduling/scheduler.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ import (
3030
)
3131

3232
const (
33+
// tracerScope is the OTel instrumentation scope for spans emitted by the scheduling engine.
34+
tracerScope = "llm-d-router/pkg/epp/scheduling"
35+
3336
profilePickerExtensionPoint = "ProfilePicker"
3437
filterExtensionPoint = "Filter"
3538
scorerExtensionPoint = "Scorer"

pkg/epp/scheduling/scheduler_profile.go

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,13 @@ import (
2222
"strings"
2323
"time"
2424

25+
"go.opentelemetry.io/otel/attribute"
26+
"go.opentelemetry.io/otel/trace"
2527
"sigs.k8s.io/controller-runtime/pkg/log"
2628

2729
errcommon "github.com/llm-d/llm-d-router/pkg/common/error"
2830
logutil "github.com/llm-d/llm-d-router/pkg/common/observability/logging"
31+
"github.com/llm-d/llm-d-router/pkg/common/observability/tracing"
2932
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/plugin"
3033
fwksched "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/scheduling"
3134
"github.com/llm-d/llm-d-router/pkg/epp/metrics"
@@ -122,7 +125,7 @@ func (p *SchedulerProfile) Run(ctx context.Context, request *fwksched.InferenceR
122125
// if we got here, there is at least one endpoint to score
123126
weightedScorePerEndpoint := p.runScorerPlugins(ctx, request, endpoints)
124127

125-
result := p.runPickerPlugin(ctx, weightedScorePerEndpoint)
128+
result := p.runPickerPlugin(ctx, request, weightedScorePerEndpoint)
126129

127130
return result, nil
128131
}
@@ -184,9 +187,14 @@ func (p *SchedulerProfile) runScorerPlugins(ctx context.Context, request *fwksch
184187
return weightedScorePerEndpoint
185188
}
186189

187-
func (p *SchedulerProfile) runPickerPlugin(ctx context.Context, weightedScorePerEndpoint map[fwksched.Endpoint]float64) *fwksched.ProfileRunResult {
190+
func (p *SchedulerProfile) runPickerPlugin(ctx context.Context, req *fwksched.InferenceRequest, weightedScorePerEndpoint map[fwksched.Endpoint]float64) *fwksched.ProfileRunResult {
188191
logger := log.FromContext(ctx)
189192

193+
ctx, span := tracing.Tracer(tracerScope).Start(ctx, "pick_endpoints",
194+
trace.WithSpanKind(trace.SpanKindInternal),
195+
)
196+
defer span.End()
197+
190198
// Allocate the ScoredEndpoint values as a single contiguous backing array
191199
// and build the picker's pointer slice by indexing into it. Previously each
192200
// per-endpoint &ScoredEndpoint{...} was a separate heap allocation, which
@@ -202,13 +210,30 @@ func (p *SchedulerProfile) runPickerPlugin(ctx context.Context, weightedScorePer
202210
scoredEndpoints[i] = &storage[i]
203211
i++
204212
}
213+
214+
span.SetAttributes(attribute.Int("llm_d.epp.picker.candidate_endpoints", n))
215+
if req != nil {
216+
if req.TargetModel != "" {
217+
span.SetAttributes(attribute.String("gen_ai.request.model", req.TargetModel))
218+
}
219+
if req.RequestID != "" {
220+
span.SetAttributes(attribute.String("gen_ai.request.id", req.RequestID))
221+
}
222+
}
223+
205224
logger.V(logutil.VERBOSE).Info("Running picker plugin", "plugin", p.picker.TypedName())
206225
logger.V(logutil.DEBUG).Info("Candidate pods for picking", "endpoints-weighted-score", scoredEndpoints)
207226
before := time.Now()
208227
result := p.picker.Pick(ctx, scoredEndpoints)
209228
metrics.RecordPluginProcessingLatency(pickerExtensionPoint, p.picker.TypedName().Type, p.picker.TypedName().Name, time.Since(before))
210229
logger.V(logutil.DEBUG).Info("Completed running picker plugin successfully", "plugin", p.picker.TypedName(), "result", result)
211230

231+
selected := 0
232+
if result != nil {
233+
selected = len(result.TargetEndpoints)
234+
}
235+
span.SetAttributes(attribute.Int("llm_d.epp.picker.selected_endpoints", selected))
236+
212237
return result
213238
}
214239

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package scheduling
18+
19+
import (
20+
"context"
21+
"testing"
22+
23+
"go.opentelemetry.io/otel"
24+
"go.opentelemetry.io/otel/attribute"
25+
sdktrace "go.opentelemetry.io/otel/sdk/trace"
26+
"go.opentelemetry.io/otel/sdk/trace/tracetest"
27+
"go.opentelemetry.io/otel/trace"
28+
k8stypes "k8s.io/apimachinery/pkg/types"
29+
30+
fwkdl "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/datalayer"
31+
fwkplugin "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/plugin"
32+
fwksched "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/scheduling"
33+
)
34+
35+
func setupSpanRecorder(t *testing.T) *tracetest.SpanRecorder {
36+
t.Helper()
37+
recorder := tracetest.NewSpanRecorder()
38+
tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
39+
prev := otel.GetTracerProvider()
40+
otel.SetTracerProvider(tp)
41+
t.Cleanup(func() { otel.SetTracerProvider(prev) })
42+
return recorder
43+
}
44+
45+
func spansByName(spans []sdktrace.ReadOnlySpan, name string) []sdktrace.ReadOnlySpan {
46+
var out []sdktrace.ReadOnlySpan
47+
for _, s := range spans {
48+
if s.Name() == name {
49+
out = append(out, s)
50+
}
51+
}
52+
return out
53+
}
54+
55+
func spanAttrs(span sdktrace.ReadOnlySpan) map[attribute.Key]attribute.Value {
56+
m := make(map[attribute.Key]attribute.Value)
57+
for _, kv := range span.Attributes() {
58+
m[kv.Key] = kv.Value
59+
}
60+
return m
61+
}
62+
63+
func makeWeightedScores(names ...string) map[fwksched.Endpoint]float64 {
64+
scores := make(map[fwksched.Endpoint]float64, len(names))
65+
for i, name := range names {
66+
ep := fwksched.NewEndpoint(
67+
&fwkdl.EndpointMetadata{NamespacedName: k8stypes.NamespacedName{Name: name}}, nil, nil)
68+
scores[ep] = float64(i + 1)
69+
}
70+
return scores
71+
}
72+
73+
// stubPicker is a minimal Picker for tracing tests.
74+
type stubPicker struct {
75+
typedName fwkplugin.TypedName
76+
numPick int
77+
nilResult bool
78+
emitSpan bool
79+
}
80+
81+
var _ fwksched.Picker = &stubPicker{}
82+
83+
func (s *stubPicker) TypedName() fwkplugin.TypedName { return s.typedName }
84+
85+
func (s *stubPicker) Pick(ctx context.Context, scored []*fwksched.ScoredEndpoint) *fwksched.ProfileRunResult {
86+
if s.emitSpan {
87+
_, child := otel.Tracer("test").Start(ctx, "child_picker_op")
88+
child.End()
89+
}
90+
if s.nilResult {
91+
return nil
92+
}
93+
n := s.numPick
94+
if n > len(scored) {
95+
n = len(scored)
96+
}
97+
targets := make([]fwksched.Endpoint, n)
98+
for i := 0; i < n; i++ {
99+
targets[i] = scored[i].Endpoint
100+
}
101+
return &fwksched.ProfileRunResult{TargetEndpoints: targets}
102+
}
103+
104+
func TestPickerSpan_Basic(t *testing.T) {
105+
recorder := setupSpanRecorder(t)
106+
107+
profile := NewSchedulerProfile().WithPicker(&stubPicker{
108+
typedName: fwkplugin.TypedName{Type: "max-score-picker", Name: "default"},
109+
numPick: 1,
110+
})
111+
scores := makeWeightedScores("pod1", "pod2", "pod3")
112+
req := &fwksched.InferenceRequest{TargetModel: "llama-7b", RequestID: "req-123"}
113+
114+
ctx, root := otel.Tracer("test").Start(context.Background(), "root")
115+
result := profile.runPickerPlugin(ctx, req, scores)
116+
root.End()
117+
118+
if result == nil || len(result.TargetEndpoints) != 1 {
119+
t.Fatalf("expected 1 target endpoint, got %v", result)
120+
}
121+
122+
spans := spansByName(recorder.Ended(), "pick_endpoints")
123+
if len(spans) != 1 {
124+
t.Fatalf("expected 1 pick_endpoints span, got %d", len(spans))
125+
}
126+
127+
span := spans[0]
128+
if span.SpanKind() != trace.SpanKindInternal {
129+
t.Errorf("span kind = %v, want Internal", span.SpanKind())
130+
}
131+
if span.Parent().SpanID() != root.SpanContext().SpanID() {
132+
t.Errorf("span parent = %v, want root %v", span.Parent().SpanID(), root.SpanContext().SpanID())
133+
}
134+
135+
attrs := spanAttrs(span)
136+
if got := attrs["llm_d.epp.picker.candidate_endpoints"].AsInt64(); got != 3 {
137+
t.Errorf("candidate_endpoints = %d, want 3", got)
138+
}
139+
if got := attrs["llm_d.epp.picker.selected_endpoints"].AsInt64(); got != 1 {
140+
t.Errorf("selected_endpoints = %d, want 1", got)
141+
}
142+
if got := attrs["gen_ai.request.model"].AsString(); got != "llama-7b" {
143+
t.Errorf("gen_ai.request.model = %q, want %q", got, "llama-7b")
144+
}
145+
if got := attrs["gen_ai.request.id"].AsString(); got != "req-123" {
146+
t.Errorf("gen_ai.request.id = %q, want %q", got, "req-123")
147+
}
148+
}
149+
150+
func TestPickerSpan_MultipleSelected(t *testing.T) {
151+
recorder := setupSpanRecorder(t)
152+
153+
profile := NewSchedulerProfile().WithPicker(&stubPicker{
154+
typedName: fwkplugin.TypedName{Type: "max-score-picker", Name: "multi"},
155+
numPick: 2,
156+
})
157+
scores := makeWeightedScores("pod1", "pod2", "pod3")
158+
159+
ctx, root := otel.Tracer("test").Start(context.Background(), "root")
160+
result := profile.runPickerPlugin(ctx, &fwksched.InferenceRequest{TargetModel: "m1", RequestID: "r1"}, scores)
161+
root.End()
162+
163+
if result == nil || len(result.TargetEndpoints) != 2 {
164+
t.Fatalf("expected 2 target endpoints, got %v", result)
165+
}
166+
167+
spans := spansByName(recorder.Ended(), "pick_endpoints")
168+
if len(spans) != 1 {
169+
t.Fatalf("expected 1 span, got %d", len(spans))
170+
}
171+
if got := spanAttrs(spans[0])["llm_d.epp.picker.selected_endpoints"].AsInt64(); got != 2 {
172+
t.Errorf("selected_endpoints = %d, want 2", got)
173+
}
174+
}
175+
176+
func TestPickerSpan_NilResult(t *testing.T) {
177+
recorder := setupSpanRecorder(t)
178+
179+
profile := NewSchedulerProfile().WithPicker(&stubPicker{
180+
typedName: fwkplugin.TypedName{Type: "noop", Name: "noop"},
181+
nilResult: true,
182+
})
183+
scores := makeWeightedScores("pod1", "pod2")
184+
185+
ctx, root := otel.Tracer("test").Start(context.Background(), "root")
186+
result := profile.runPickerPlugin(ctx, &fwksched.InferenceRequest{RequestID: "r1"}, scores)
187+
root.End()
188+
189+
if result != nil {
190+
t.Fatalf("expected nil result, got %v", result)
191+
}
192+
193+
spans := spansByName(recorder.Ended(), "pick_endpoints")
194+
if len(spans) != 1 {
195+
t.Fatalf("expected 1 span even on nil result, got %d", len(spans))
196+
}
197+
if got := spanAttrs(spans[0])["llm_d.epp.picker.selected_endpoints"].AsInt64(); got != 0 {
198+
t.Errorf("selected_endpoints = %d, want 0 on nil result", got)
199+
}
200+
}
201+
202+
func TestPickerSpan_OmitsEmptyGenAIFields(t *testing.T) {
203+
recorder := setupSpanRecorder(t)
204+
205+
profile := NewSchedulerProfile().WithPicker(&stubPicker{
206+
typedName: fwkplugin.TypedName{Type: "p", Name: "n"},
207+
numPick: 1,
208+
})
209+
scores := makeWeightedScores("pod1")
210+
211+
ctx, root := otel.Tracer("test").Start(context.Background(), "root")
212+
profile.runPickerPlugin(ctx, &fwksched.InferenceRequest{}, scores)
213+
root.End()
214+
215+
spans := spansByName(recorder.Ended(), "pick_endpoints")
216+
if len(spans) != 1 {
217+
t.Fatalf("expected 1 span, got %d", len(spans))
218+
}
219+
attrs := spanAttrs(spans[0])
220+
if _, ok := attrs["gen_ai.request.model"]; ok {
221+
t.Error("gen_ai.request.model should not be set when TargetModel is empty")
222+
}
223+
if _, ok := attrs["gen_ai.request.id"]; ok {
224+
t.Error("gen_ai.request.id should not be set when RequestID is empty")
225+
}
226+
}
227+
228+
func TestPickerSpan_NilRequest(t *testing.T) {
229+
recorder := setupSpanRecorder(t)
230+
231+
profile := NewSchedulerProfile().WithPicker(&stubPicker{
232+
typedName: fwkplugin.TypedName{Type: "p", Name: "n"},
233+
numPick: 1,
234+
})
235+
scores := makeWeightedScores("pod1")
236+
237+
ctx, root := otel.Tracer("test").Start(context.Background(), "root")
238+
profile.runPickerPlugin(ctx, nil, scores)
239+
root.End()
240+
241+
spans := spansByName(recorder.Ended(), "pick_endpoints")
242+
if len(spans) != 1 {
243+
t.Fatalf("expected 1 span, got %d", len(spans))
244+
}
245+
attrs := spanAttrs(spans[0])
246+
if _, ok := attrs["gen_ai.request.model"]; ok {
247+
t.Error("gen_ai.request.model should not be set when request is nil")
248+
}
249+
if _, ok := attrs["gen_ai.request.id"]; ok {
250+
t.Error("gen_ai.request.id should not be set when request is nil")
251+
}
252+
if got := attrs["llm_d.epp.picker.candidate_endpoints"].AsInt64(); got != 1 {
253+
t.Errorf("candidate_endpoints = %d, want 1", got)
254+
}
255+
}
256+
257+
func TestPickerSpan_ChildSpanNests(t *testing.T) {
258+
recorder := setupSpanRecorder(t)
259+
260+
profile := NewSchedulerProfile().WithPicker(&stubPicker{
261+
typedName: fwkplugin.TypedName{Type: "nested", Name: "nested"},
262+
numPick: 1,
263+
emitSpan: true,
264+
})
265+
scores := makeWeightedScores("pod1")
266+
267+
ctx, root := otel.Tracer("test").Start(context.Background(), "root")
268+
profile.runPickerPlugin(ctx, &fwksched.InferenceRequest{}, scores)
269+
root.End()
270+
271+
pickerSpans := spansByName(recorder.Ended(), "pick_endpoints")
272+
childSpans := spansByName(recorder.Ended(), "child_picker_op")
273+
if len(pickerSpans) != 1 || len(childSpans) != 1 {
274+
t.Fatalf("expected 1 pick_endpoints and 1 child span, got %d and %d", len(pickerSpans), len(childSpans))
275+
}
276+
if childSpans[0].Parent().SpanID() != pickerSpans[0].SpanContext().SpanID() {
277+
t.Errorf("child span parent = %v, want pick_endpoints span %v",
278+
childSpans[0].Parent().SpanID(), pickerSpans[0].SpanContext().SpanID())
279+
}
280+
}

0 commit comments

Comments
 (0)