Skip to content

Commit ffe7280

Browse files
committed
feat: add OpenTelemetry spans for tokenization and KV-cache event processing
Signed-off-by: satyamg1620 <Satyam.Gupta.3@ibm.com>
1 parent 1026043 commit ffe7280

14 files changed

Lines changed: 545 additions & 21 deletions

File tree

pkg/epp/framework/plugins/requestcontrol/dataproducer/tokenizer/tokenizer.go

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,18 @@ import (
3131
"github.com/llm-d/llm-d-router/pkg/kvcache/kvblock"
3232
"github.com/llm-d/llm-d-router/pkg/kvcache/tokenization"
3333
tokenizerTypes "github.com/llm-d/llm-d-router/pkg/kvcache/tokenization/types"
34+
"go.opentelemetry.io/otel/attribute"
35+
"go.opentelemetry.io/otel/codes"
36+
"go.opentelemetry.io/otel/trace"
3437
"sigs.k8s.io/controller-runtime/pkg/log"
3538

39+
"github.com/llm-d/llm-d-router/pkg/common/observability/tracing"
3640
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/plugin"
3741
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/requestcontrol"
3842
fwkrh "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/requesthandling"
3943
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/scheduling"
44+
mmobs "github.com/llm-d/llm-d-router/pkg/epp/framework/observability/multimodal"
45+
rcplugins "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/requestcontrol"
4046
)
4147

4248
type tokenizer interface {
@@ -57,6 +63,13 @@ const (
5763
tokenizedPromptKeyID = "TokenizedPrompt"
5864
)
5965

66+
// Backend identifiers reported on the tokenize span.
67+
const (
68+
backendUDS = "uds"
69+
backendVLLM = "vllm"
70+
backendEstimate = "estimate"
71+
)
72+
6073
var TokenizedPromptDataKey = plugin.NewDataKey(tokenizedPromptKeyID, PluginType)
6174

6275
// tokenizerPluginConfig holds the configuration for the tokenizer plugin.
@@ -264,6 +277,7 @@ func LegacyPluginFactory(name string, rawParameters *json.Decoder, handle plugin
264277
// (the default when no backend is set).
265278
func NewPlugin(ctx context.Context, name string, config *tokenizerPluginConfig) (*Plugin, error) {
266279
var backend tokenInputProducer
280+
var backendName string
267281
switch {
268282
case config.TokenizerConfig.IsEnabled():
269283
log.FromContext(ctx).Info(
@@ -275,6 +289,7 @@ func NewPlugin(ctx context.Context, name string, config *tokenizerPluginConfig)
275289
return nil, fmt.Errorf("failed to initialize UDS tokenizer for '%s' plugin - %w", PluginType, err)
276290
}
277291
backend = renderBackend{tk: uds}
292+
backendName = backendUDS
278293
case config.VLLM != nil || config.ModelName != "":
279294
cfg := config.VLLM
280295
if cfg == nil {
@@ -285,14 +300,17 @@ func NewPlugin(ctx context.Context, name string, config *tokenizerPluginConfig)
285300
return nil, fmt.Errorf("failed to initialize vLLM HTTP renderer for '%s' plugin - %w", PluginType, err)
286301
}
287302
backend = renderBackend{tk: renderer}
303+
backendName = backendVLLM
288304
default:
289305
backend = estimateBackend{img: newImageEstimator(config.Estimate), vid: newVideoEstimator(config.Estimate)}
306+
backendName = backendEstimate
290307
}
291308

292309
p := &Plugin{
293-
typedName: plugin.TypedName{Type: PluginType, Name: name},
294-
backend: backend,
295-
dk: TokenizedPromptDataKey.WithNonEmptyProducerName(name),
310+
typedName: plugin.TypedName{Type: PluginType, Name: name},
311+
backend: backend,
312+
backendName: backendName,
313+
dk: TokenizedPromptDataKey.WithNonEmptyProducerName(name),
296314
}
297315
if w, ok := backend.(warmer); ok {
298316
go w.warmup(ctx)
@@ -305,7 +323,9 @@ func NewPlugin(ctx context.Context, name string, config *tokenizerPluginConfig)
305323
type Plugin struct {
306324
typedName plugin.TypedName
307325
backend tokenInputProducer
308-
dk plugin.DataKey
326+
// backendName identifies the configured backend on the tokenize span.
327+
backendName string
328+
dk plugin.DataKey
309329
}
310330

311331
// compile-time assertions.
@@ -337,6 +357,9 @@ func (p *Plugin) ProduceTimeout() time.Duration {
337357
// Produce derives the request's TokenizedPrompt via the configured backend and
338358
// stores it on the body. Skips when one is already present; errors propagate to
339359
// the Director, which logs and continues.
360+
//
361+
// The tokenize span covers the backend call only, so the skip path stays
362+
// untraced and the span always represents tokenization work.
340363
func (p *Plugin) Produce(ctx context.Context, request *scheduling.InferenceRequest, _ []scheduling.Endpoint) error {
341364
if request.Body == nil {
342365
return errors.New("request body is nil")
@@ -350,16 +373,33 @@ func (p *Plugin) Produce(ctx context.Context, request *scheduling.InferenceReque
350373
return nil
351374
}
352375

376+
ctx, span := tracing.Tracer(rcplugins.TracerScope).Start(ctx, "tokenize",
377+
trace.WithSpanKind(trace.SpanKindInternal),
378+
)
379+
defer span.End()
380+
381+
span.SetAttributes(attribute.String("llm_d.epp.token_producer.backend", p.backendName))
382+
if request.TargetModel != "" {
383+
span.SetAttributes(attribute.String("gen_ai.request.model", request.TargetModel))
384+
}
385+
if request.RequestID != "" {
386+
span.SetAttributes(attribute.String("gen_ai.request.id", request.RequestID))
387+
}
388+
353389
ctx = withMMMetadata(ctx, parseMMMetadataHeaders(request.Headers))
354390
tp, err := p.backend.produce(ctx, request.Body)
355391
if err != nil {
392+
span.SetStatus(codes.Error, err.Error())
356393
return err
357394
}
358395
if tp == nil || tp.TokenCount() == 0 {
359396
return nil
360397
}
361398
tp.CacheSalt = CacheSaltFromBody(request.Body)
362399
request.Body.TokenizedPrompt = tp
400+
401+
span.SetAttributes(attribute.Int("llm_d.epp.token_producer.token_count", tp.TokenCount()))
402+
span.SetAttributes(mmobs.SpanAttributes(request)...)
363403
return nil
364404
}
365405

pkg/epp/framework/plugins/requestcontrol/dataproducer/tokenizer/tokenizer_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,9 @@ func (m *mockTokenizer) RenderChat(_ context.Context, payload fwkrh.RequestPaylo
5050

5151
func newTestPlugin(tok tokenizer) *Plugin {
5252
return &Plugin{
53-
typedName: plugin.TypedName{Type: PluginType, Name: "test"},
54-
backend: renderBackend{tk: tok},
53+
typedName: plugin.TypedName{Type: PluginType, Name: "test"},
54+
backend: renderBackend{tk: tok},
55+
backendName: backendVLLM,
5556
}
5657
}
5758

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/*
2+
Copyright 2026 The llm-d 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 tokenizer
18+
19+
import (
20+
"context"
21+
"errors"
22+
"testing"
23+
24+
"github.com/stretchr/testify/assert"
25+
"github.com/stretchr/testify/require"
26+
"go.opentelemetry.io/otel"
27+
"go.opentelemetry.io/otel/attribute"
28+
"go.opentelemetry.io/otel/codes"
29+
sdktrace "go.opentelemetry.io/otel/sdk/trace"
30+
"go.opentelemetry.io/otel/sdk/trace/tracetest"
31+
32+
fwkrh "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/requesthandling"
33+
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/scheduling"
34+
"github.com/llm-d/llm-d-router/pkg/kvcache/kvblock"
35+
"github.com/llm-d/llm-d-router/pkg/kvcache/tokenization"
36+
)
37+
38+
// setupSpanRecorder installs an in-memory span recorder as the global tracer
39+
// provider and returns it, restoring the previous provider on cleanup.
40+
func setupSpanRecorder(t *testing.T) *tracetest.SpanRecorder {
41+
t.Helper()
42+
recorder := tracetest.NewSpanRecorder()
43+
tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
44+
origTP := otel.GetTracerProvider()
45+
otel.SetTracerProvider(tp)
46+
t.Cleanup(func() { otel.SetTracerProvider(origTP) })
47+
return recorder
48+
}
49+
50+
func tokenizeSpan(t *testing.T, recorder *tracetest.SpanRecorder) sdktrace.ReadOnlySpan {
51+
t.Helper()
52+
for _, s := range recorder.Ended() {
53+
if s.Name() == "tokenize" {
54+
return s
55+
}
56+
}
57+
t.Fatal("no tokenize span recorded")
58+
return nil
59+
}
60+
61+
func spanAttrs(span sdktrace.ReadOnlySpan) map[attribute.Key]attribute.Value {
62+
attrs := make(map[attribute.Key]attribute.Value)
63+
for _, kv := range span.Attributes() {
64+
attrs[kv.Key] = kv.Value
65+
}
66+
return attrs
67+
}
68+
69+
func chatRequest() *scheduling.InferenceRequest {
70+
return &scheduling.InferenceRequest{
71+
RequestID: "req-1",
72+
TargetModel: "model-a",
73+
Body: &fwkrh.InferenceRequestBody{
74+
ChatCompletions: &fwkrh.ChatCompletionsRequest{
75+
Messages: []fwkrh.Message{{Role: "user", Content: fwkrh.Content{Raw: "hi"}}},
76+
},
77+
Payload: fwkrh.PayloadMap{},
78+
},
79+
}
80+
}
81+
82+
func TestProduce_EmitsTokenizeSpan(t *testing.T) {
83+
recorder := setupSpanRecorder(t)
84+
p := newTestPlugin(&mockTokenizer{
85+
renderChatFunc: func(_ fwkrh.RequestPayload) ([]uint32, *tokenization.MultiModalFeatures, error) {
86+
return []uint32{1, 2, 3}, nil, nil
87+
},
88+
})
89+
90+
require.NoError(t, p.Produce(context.Background(), chatRequest(), nil))
91+
92+
attrs := spanAttrs(tokenizeSpan(t, recorder))
93+
assert.Equal(t, backendVLLM, attrs["llm_d.epp.token_producer.backend"].AsString())
94+
assert.Equal(t, int64(3), attrs["llm_d.epp.token_producer.token_count"].AsInt64())
95+
assert.Equal(t, "model-a", attrs["gen_ai.request.model"].AsString())
96+
assert.Equal(t, "req-1", attrs["gen_ai.request.id"].AsString())
97+
assert.Equal(t, "none", attrs["mm.modality"].AsString())
98+
assert.Equal(t, int64(0), attrs["mm.hash_count"].AsInt64())
99+
}
100+
101+
// The span must parent to the caller's span so tokenization appears under the
102+
// request trace rather than as a detached root.
103+
func TestProduce_TokenizeSpanParentedToCaller(t *testing.T) {
104+
recorder := setupSpanRecorder(t)
105+
p := newTestPlugin(&mockTokenizer{
106+
renderChatFunc: func(_ fwkrh.RequestPayload) ([]uint32, *tokenization.MultiModalFeatures, error) {
107+
return []uint32{1}, nil, nil
108+
},
109+
})
110+
111+
ctx, parent := otel.Tracer("test").Start(context.Background(), "parent")
112+
require.NoError(t, p.Produce(ctx, chatRequest(), nil))
113+
parent.End()
114+
115+
span := tokenizeSpan(t, recorder)
116+
assert.Equal(t, parent.SpanContext().TraceID(), span.SpanContext().TraceID())
117+
assert.Equal(t, parent.SpanContext().SpanID(), span.Parent().SpanID())
118+
}
119+
120+
func TestProduce_TokenizeSpanRecordsMultiModal(t *testing.T) {
121+
recorder := setupSpanRecorder(t)
122+
p := newTestPlugin(&mockTokenizer{
123+
renderChatFunc: func(_ fwkrh.RequestPayload) ([]uint32, *tokenization.MultiModalFeatures, error) {
124+
return []uint32{1, 2}, &tokenization.MultiModalFeatures{
125+
MMHashes: map[string][]string{"image": {"h1"}},
126+
MMPlaceholders: map[string][]kvblock.PlaceholderRange{"image": {{Offset: 0, Length: 2}}},
127+
}, nil
128+
},
129+
})
130+
131+
require.NoError(t, p.Produce(context.Background(), chatRequest(), nil))
132+
133+
attrs := spanAttrs(tokenizeSpan(t, recorder))
134+
assert.Equal(t, "image", attrs["mm.modality"].AsString())
135+
assert.Equal(t, int64(1), attrs["mm.hash_count"].AsInt64())
136+
}
137+
138+
func TestProduce_TokenizeSpanRecordsError(t *testing.T) {
139+
recorder := setupSpanRecorder(t)
140+
p := newTestPlugin(&mockTokenizer{
141+
renderChatFunc: func(_ fwkrh.RequestPayload) ([]uint32, *tokenization.MultiModalFeatures, error) {
142+
return nil, nil, errors.New("render failed")
143+
},
144+
})
145+
146+
require.Error(t, p.Produce(context.Background(), chatRequest(), nil))
147+
148+
span := tokenizeSpan(t, recorder)
149+
assert.Equal(t, codes.Error, span.Status().Code)
150+
assert.Contains(t, span.Status().Description, "render failed")
151+
}
152+
153+
// The skip path does no tokenization work, so it must not emit a span.
154+
func TestProduce_NoSpanWhenAlreadyTokenized(t *testing.T) {
155+
recorder := setupSpanRecorder(t)
156+
p := newTestPlugin(&mockTokenizer{})
157+
158+
req := chatRequest()
159+
req.Body.TokenizedPrompt = &fwkrh.TokenizedPrompt{PerPromptTokens: [][]uint32{{7}}}
160+
require.NoError(t, p.Produce(context.Background(), req, nil))
161+
162+
assert.Empty(t, recorder.Ended())
163+
}

pkg/kvevents/engineadapter/sglang_adapter.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
package engineadapter
1818

1919
import (
20+
"context"
2021
"fmt"
2122

2223
"github.com/vmihailenco/msgpack/v5"
@@ -68,7 +69,7 @@ func (s *SGLangAdapter) ShardingKey(msg *kvevents.RawMessage) string {
6869
// and decodes the msgpack payload into an EventBatch.
6970
//
7071
//nolint:gocritic // unnamedResult: named returns conflict with nonamedreturns linter
71-
func (s *SGLangAdapter) ParseMessage(msg *kvevents.RawMessage) (string, string, kvevents.EventBatch, error) {
72+
func (s *SGLangAdapter) ParseMessage(_ context.Context, msg *kvevents.RawMessage) (string, string, kvevents.EventBatch, error) {
7273
podID, modelName := parseTopic(msg.Topic)
7374

7475
var batch msgpackSGLangEventBatch

pkg/kvevents/engineadapter/sglang_adapter_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
package engineadapter //nolint:testpackage // Tests access unexported functions
1818

1919
import (
20+
"context"
2021
"testing"
2122

2223
"github.com/llm-d/llm-d-router/pkg/kvevents"
@@ -61,7 +62,7 @@ func TestSGLangParseMessage_Valid(t *testing.T) {
6162
Payload: payload,
6263
}
6364

64-
podID, modelName, eventBatch, err := adapter.ParseMessage(msg)
65+
podID, modelName, eventBatch, err := adapter.ParseMessage(context.Background(), msg)
6566
require.NoError(t, err)
6667
assert.Equal(t, "pod-1", podID)
6768
assert.Equal(t, "llama-2-7b", modelName)
@@ -82,7 +83,7 @@ func TestSGLangParseMessage_InvalidPayload(t *testing.T) {
8283
Payload: []byte{0xFF, 0xFF, 0xFF},
8384
}
8485

85-
_, _, _, err := adapter.ParseMessage(msg)
86+
_, _, _, err := adapter.ParseMessage(context.Background(), msg)
8687
assert.Error(t, err)
8788
}
8889

pkg/kvevents/engineadapter/vllm_adapter.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
package engineadapter
1818

1919
import (
20+
"context"
2021
"fmt"
2122

2223
"github.com/vmihailenco/msgpack/v5"
@@ -61,7 +62,7 @@ func (v *VLLMAdapter) ShardingKey(msg *kvevents.RawMessage) string {
6162
// and decodes the msgpack payload into an EventBatch.
6263
//
6364
//nolint:gocritic // unnamedResult: named returns conflict with nonamedreturns linter
64-
func (v *VLLMAdapter) ParseMessage(msg *kvevents.RawMessage) (string, string, kvevents.EventBatch, error) {
65+
func (v *VLLMAdapter) ParseMessage(_ context.Context, msg *kvevents.RawMessage) (string, string, kvevents.EventBatch, error) {
6566
podID, modelName := parseTopic(msg.Topic)
6667

6768
var vllmBatch msgpackVLLMEventBatch

pkg/kvevents/engineadapter/vllm_adapter_bench_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
package engineadapter //nolint:testpackage // Benchmarks access unexported functions
1818

1919
import (
20+
"context"
2021
"fmt"
2122
"testing"
2223

@@ -154,7 +155,7 @@ func BenchmarkParseMessage_Batch(b *testing.B) {
154155
b.SetBytes(int64(len(batchPayload)))
155156
b.ResetTimer()
156157
for i := 0; i < b.N; i++ {
157-
_, _, _, err := adapter.ParseMessage(msg)
158+
_, _, _, err := adapter.ParseMessage(context.Background(), msg)
158159
if err != nil {
159160
b.Fatal(err)
160161
}

0 commit comments

Comments
 (0)