Skip to content

Commit 19a00f9

Browse files
feat(observability): add comprehensive monitoring for image generation
- Implement image generation metrics collection and reportz - Add distributed tracing support for image generation requests - Integrate OpenInference tracing for OpenAI image gen - Add API tracing support for image generation endpoints - Include comprehensive test coverage for observability features Signed-off-by: Hrushikesh Patil <[email protected]>
1 parent 969f3b0 commit 19a00f9

File tree

7 files changed

+260
-36
lines changed

7 files changed

+260
-36
lines changed

internal/metrics/image_generation_metrics.go

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"go.opentelemetry.io/otel/metric"
1414

1515
"github.com/envoyproxy/ai-gateway/internal/filterapi"
16+
"github.com/envoyproxy/ai-gateway/internal/internalapi"
1617
)
1718

1819
// imageGeneration is the implementation for the image generation AI Gateway metrics.
@@ -24,14 +25,16 @@ type imageGeneration struct {
2425
type ImageGenerationMetrics interface {
2526
// StartRequest initializes timing for a new request.
2627
StartRequest(headers map[string]string)
27-
// SetModel sets the model the request. This is usually called after parsing the request body .
28-
SetModel(model string)
28+
// SetRequestModel sets the request model name.
29+
SetRequestModel(requestModel internalapi.RequestModel)
30+
// SetResponseModel sets the response model name.
31+
SetResponseModel(responseModel internalapi.ResponseModel)
2932
// SetBackend sets the selected backend when the routing decision has been made. This is usually called
3033
// after parsing the request body to determine the model and invoke the routing logic.
3134
SetBackend(backend *filterapi.Backend)
3235

33-
// RecordTokenUsage records token usage metrics (for image generation, this will typically be 0).
34-
RecordTokenUsage(ctx context.Context, inputTokens, outputTokens, totalTokens uint32, requestHeaderLabelMapping map[string]string)
36+
// RecordTokenUsage records token usage metrics (image gen typically 0, but supported).
37+
RecordTokenUsage(ctx context.Context, inputTokens, outputTokens uint32, requestHeaderLabelMapping map[string]string)
3538
// RecordRequestCompletion records latency metrics for the entire request.
3639
RecordRequestCompletion(ctx context.Context, success bool, requestHeaderLabelMapping map[string]string)
3740
// RecordImageGeneration records metrics specific to image generation.
@@ -50,13 +53,18 @@ func (i *imageGeneration) StartRequest(headers map[string]string) {
5053
i.baseMetrics.StartRequest(headers)
5154
}
5255

53-
// SetModel sets the model for the request.
54-
func (i *imageGeneration) SetModel(model string) {
55-
i.baseMetrics.SetModel(model, model)
56+
// SetRequestModel sets the request model for the request.
57+
func (i *imageGeneration) SetRequestModel(requestModel internalapi.RequestModel) {
58+
i.baseMetrics.SetRequestModel(requestModel)
59+
}
60+
61+
// SetResponseModel sets the response model for the request.
62+
func (i *imageGeneration) SetResponseModel(responseModel internalapi.ResponseModel) {
63+
i.baseMetrics.SetResponseModel(responseModel)
5664
}
5765

5866
// RecordTokenUsage implements [ImageGeneration.RecordTokenUsage].
59-
func (i *imageGeneration) RecordTokenUsage(ctx context.Context, inputTokens, outputTokens, totalTokens uint32, requestHeaders map[string]string) {
67+
func (i *imageGeneration) RecordTokenUsage(ctx context.Context, inputTokens, outputTokens uint32, requestHeaders map[string]string) {
6068
attrs := i.buildBaseAttributes(requestHeaders)
6169

6270
// For image generation, token usage is typically 0, but we still record it for consistency
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Copyright Envoy AI Gateway Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
// The full text of the Apache license is available in the LICENSE file at
4+
// the root of the repo.
5+
6+
package metrics
7+
8+
import (
9+
"testing"
10+
"testing/synctest"
11+
"time"
12+
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
"go.opentelemetry.io/otel/attribute"
16+
"go.opentelemetry.io/otel/sdk/metric"
17+
18+
"github.com/envoyproxy/ai-gateway/internal/filterapi"
19+
)
20+
21+
func TestImageGeneration_RecordTokenUsage(t *testing.T) {
22+
// Mirrors chat/embeddings token usage tests, but for image_generation.
23+
var (
24+
mr = metric.NewManualReader()
25+
meter = metric.NewMeterProvider(metric.WithReader(mr)).Meter("test")
26+
im = NewImageGeneration(meter, nil).(*imageGeneration)
27+
28+
attrsBase = []attribute.KeyValue{
29+
attribute.Key(genaiAttributeOperationName).String(genaiOperationImageGeneration),
30+
attribute.Key(genaiAttributeProviderName).String(genaiProviderOpenAI),
31+
attribute.Key(genaiAttributeRequestModel).String("test-model"),
32+
attribute.Key(genaiAttributeResponseModel).String("test-model"),
33+
}
34+
inputAttrs = attribute.NewSet(append(attrsBase, attribute.Key(genaiAttributeTokenType).String(genaiTokenTypeInput))...)
35+
outputAttrs = attribute.NewSet(append(attrsBase, attribute.Key(genaiAttributeTokenType).String(genaiTokenTypeOutput))...)
36+
)
37+
38+
// Set labels and record usage.
39+
im.SetModel("test-model", "test-model")
40+
im.SetBackend(&filterapi.Backend{Schema: filterapi.VersionedAPISchema{Name: filterapi.APISchemaOpenAI}})
41+
im.RecordTokenUsage(t.Context(), 3, 7, nil)
42+
43+
count, sum := getHistogramValues(t, mr, genaiMetricClientTokenUsage, inputAttrs)
44+
assert.Equal(t, uint64(1), count)
45+
assert.Equal(t, 3.0, sum)
46+
47+
count, sum = getHistogramValues(t, mr, genaiMetricClientTokenUsage, outputAttrs)
48+
assert.Equal(t, uint64(1), count)
49+
assert.Equal(t, 7.0, sum)
50+
}
51+
52+
func TestImageGeneration_RecordImageGeneration(t *testing.T) {
53+
// Use synctest to keep time-based assertions deterministic.
54+
synctest.Test(t, func(t *testing.T) {
55+
mr := metric.NewManualReader()
56+
meter := metric.NewMeterProvider(metric.WithReader(mr)).Meter("test")
57+
im := NewImageGeneration(meter, nil).(*imageGeneration)
58+
59+
// Base attributes plus image-specific ones
60+
attrs := attribute.NewSet(
61+
attribute.Key(genaiAttributeOperationName).String(genaiOperationImageGeneration),
62+
attribute.Key(genaiAttributeProviderName).String(genaiProviderOpenAI),
63+
attribute.Key(genaiAttributeRequestModel).String("img-model"),
64+
attribute.Key(genaiAttributeResponseModel).String("img-model"),
65+
attribute.Key("gen_ai.image.count").Int(2),
66+
attribute.Key("gen_ai.image.model").String("img-model"),
67+
attribute.Key("gen_ai.image.size").String("1024x1024"),
68+
)
69+
70+
im.StartRequest(nil)
71+
im.SetModel("img-model", "img-model")
72+
im.SetBackend(&filterapi.Backend{Schema: filterapi.VersionedAPISchema{Name: filterapi.APISchemaOpenAI}})
73+
74+
time.Sleep(10 * time.Millisecond)
75+
im.RecordImageGeneration(t.Context(), 2, "img-model", "1024x1024", nil)
76+
77+
count, sum := getHistogramValues(t, mr, genaiMetricServerRequestDuration, attrs)
78+
assert.Equal(t, uint64(1), count)
79+
assert.Equal(t, 10*time.Millisecond.Seconds(), sum)
80+
})
81+
}
82+
83+
func TestImageGeneration_HeaderLabelMapping(t *testing.T) {
84+
// Verify header mapping is honored for token usage metrics.
85+
var (
86+
mr = metric.NewManualReader()
87+
meter = metric.NewMeterProvider(metric.WithReader(mr)).Meter("test")
88+
headerMapping = map[string]string{"x-user-id": "user_id", "x-org-id": "org_id"}
89+
im = NewImageGeneration(meter, headerMapping).(*imageGeneration)
90+
)
91+
92+
requestHeaders := map[string]string{
93+
"x-user-id": "user123",
94+
"x-org-id": "org456",
95+
}
96+
97+
im.SetModel("test-model", "test-model")
98+
im.SetBackend(&filterapi.Backend{Schema: filterapi.VersionedAPISchema{Name: filterapi.APISchemaOpenAI}})
99+
im.RecordTokenUsage(t.Context(), 5, 0, requestHeaders)
100+
101+
attrs := attribute.NewSet(
102+
attribute.Key(genaiAttributeOperationName).String(genaiOperationImageGeneration),
103+
attribute.Key(genaiAttributeProviderName).String(genaiProviderOpenAI),
104+
attribute.Key(genaiAttributeRequestModel).String("test-model"),
105+
attribute.Key(genaiAttributeResponseModel).String("test-model"),
106+
attribute.Key(genaiAttributeTokenType).String(genaiTokenTypeInput),
107+
attribute.Key("user_id").String("user123"),
108+
attribute.Key("org_id").String("org456"),
109+
)
110+
111+
count, _ := getHistogramValues(t, mr, genaiMetricClientTokenUsage, attrs)
112+
require.Equal(t, uint64(1), count)
113+
}

internal/tracing/api/api.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"go.opentelemetry.io/otel/trace"
1616

1717
"github.com/envoyproxy/ai-gateway/internal/apischema/openai"
18+
openaisdk "github.com/openai/openai-go/v2"
1819
)
1920

2021
var _ Tracing = NoopTracing{}
@@ -184,13 +185,13 @@ type ImageGenerationTracer interface {
184185
// - req: The OpenAI image generation request. Used to record request attributes.
185186
//
186187
// Returns nil unless the span is sampled.
187-
StartSpanAndInjectHeaders(ctx context.Context, headers map[string]string, headerMutation *extprocv3.HeaderMutation, req *openai.ImageGenerationRequest, body []byte) ImageGenerationSpan
188+
StartSpanAndInjectHeaders(ctx context.Context, headers map[string]string, headerMutation *extprocv3.HeaderMutation, req *openaisdk.ImageGenerateParams, body []byte) ImageGenerationSpan
188189
}
189190

190191
// ImageGenerationSpan represents an OpenAI image generation.
191192
type ImageGenerationSpan interface {
192193
// RecordResponse records the response attributes to the span.
193-
RecordResponse(resp *openai.ImageGenerationResponse)
194+
RecordResponse(resp *openaisdk.ImagesResponse)
194195

195196
// EndSpanOnError finalizes and ends the span with an error status.
196197
EndSpanOnError(statusCode int, body []byte)
@@ -210,17 +211,17 @@ type ImageGenerationRecorder interface {
210211
//
211212
// Note: Do not do any expensive data conversions as the span might not be
212213
// sampled.
213-
StartParams(req *openai.ImageGenerationRequest, body []byte) (spanName string, opts []trace.SpanStartOption)
214+
StartParams(req *openaisdk.ImageGenerateParams, body []byte) (spanName string, opts []trace.SpanStartOption)
214215

215216
// RecordRequest records request attributes to the span.
216217
//
217218
// Parameters:
218219
// - req: contains the image generation request
219220
// - body: contains the complete request body.
220-
RecordRequest(span trace.Span, req *openai.ImageGenerationRequest, body []byte)
221+
RecordRequest(span trace.Span, req *openaisdk.ImageGenerateParams, body []byte)
221222

222223
// RecordResponse records response attributes to the span.
223-
RecordResponse(span trace.Span, resp *openai.ImageGenerationResponse)
224+
RecordResponse(span trace.Span, resp *openaisdk.ImagesResponse)
224225

225226
// RecordResponseOnError ends recording the span with an error status.
226227
RecordResponseOnError(span trace.Span, statusCode int, body []byte)
@@ -257,7 +258,7 @@ type EmbeddingsRecorder interface {
257258
type NoopImageGenerationTracer struct{}
258259

259260
// StartSpanAndInjectHeaders implements ImageGenerationTracer.StartSpanAndInjectHeaders.
260-
func (NoopImageGenerationTracer) StartSpanAndInjectHeaders(context.Context, map[string]string, *extprocv3.HeaderMutation, *openai.ImageGenerationRequest, []byte) ImageGenerationSpan {
261+
func (NoopImageGenerationTracer) StartSpanAndInjectHeaders(context.Context, map[string]string, *extprocv3.HeaderMutation, *openaisdk.ImageGenerateParams, []byte) ImageGenerationSpan {
261262
return nil
262263
}
263264

internal/tracing/image_generation_span.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ package tracing
88
import (
99
"go.opentelemetry.io/otel/trace"
1010

11-
"github.com/envoyproxy/ai-gateway/internal/apischema/openai"
1211
tracing "github.com/envoyproxy/ai-gateway/internal/tracing/api"
12+
openaisdk "github.com/openai/openai-go/v2"
1313
)
1414

1515
// Ensure imageGenerationSpan implements ImageGenerationSpan.
@@ -21,7 +21,7 @@ type imageGenerationSpan struct {
2121
}
2222

2323
// RecordResponse invokes [tracing.ImageGenerationRecorder.RecordResponse].
24-
func (s *imageGenerationSpan) RecordResponse(resp *openai.ImageGenerationResponse) {
24+
func (s *imageGenerationSpan) RecordResponse(resp *openaisdk.ImagesResponse) {
2525
s.recorder.RecordResponse(s.span, resp)
2626
}
2727

internal/tracing/image_generation_tracer.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import (
1313
"go.opentelemetry.io/otel/trace"
1414
"go.opentelemetry.io/otel/trace/noop"
1515

16-
"github.com/envoyproxy/ai-gateway/internal/apischema/openai"
1716
tracing "github.com/envoyproxy/ai-gateway/internal/tracing/api"
17+
openaisdk "github.com/openai/openai-go/v2"
1818
)
1919

2020
// Ensure imageGenerationTracer implements ImageGenerationTracer.
@@ -39,7 +39,7 @@ type imageGenerationTracer struct {
3939
}
4040

4141
// StartSpanAndInjectHeaders implements ImageGenerationTracer.StartSpanAndInjectHeaders.
42-
func (t *imageGenerationTracer) StartSpanAndInjectHeaders(ctx context.Context, headers map[string]string, mutableHeaders *extprocv3.HeaderMutation, req *openai.ImageGenerationRequest, body []byte) tracing.ImageGenerationSpan {
42+
func (t *imageGenerationTracer) StartSpanAndInjectHeaders(ctx context.Context, headers map[string]string, mutableHeaders *extprocv3.HeaderMutation, req *openaisdk.ImageGenerateParams, body []byte) tracing.ImageGenerationSpan {
4343
// Extract trace context from incoming headers.
4444
parentCtx := t.propagator.Extract(ctx, propagation.MapCarrier(headers))
4545

internal/tracing/openinference/openai/image_generation.go

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ import (
1414
"go.opentelemetry.io/otel/codes"
1515
"go.opentelemetry.io/otel/trace"
1616

17-
"github.com/envoyproxy/ai-gateway/internal/apischema/openai"
1817
tracing "github.com/envoyproxy/ai-gateway/internal/tracing/api"
1918
"github.com/envoyproxy/ai-gateway/internal/tracing/openinference"
19+
openaisdk "github.com/openai/openai-go/v2"
2020
)
2121

2222
// ImageGenerationRecorder implements recorders for OpenInference image generation spans.
@@ -51,17 +51,17 @@ func NewImageGenerationRecorder(config *openinference.TraceConfig) tracing.Image
5151
var imageGenStartOpts = []trace.SpanStartOption{trace.WithSpanKind(trace.SpanKindInternal)}
5252

5353
// StartParams implements the same method as defined in tracing.ImageGenerationRecorder.
54-
func (r *ImageGenerationRecorder) StartParams(*openai.ImageGenerationRequest, []byte) (spanName string, opts []trace.SpanStartOption) {
54+
func (r *ImageGenerationRecorder) StartParams(*openaisdk.ImageGenerateParams, []byte) (spanName string, opts []trace.SpanStartOption) {
5555
return "ImageGeneration", imageGenStartOpts
5656
}
5757

5858
// RecordRequest implements the same method as defined in tracing.ImageGenerationRecorder.
59-
func (r *ImageGenerationRecorder) RecordRequest(span trace.Span, req *openai.ImageGenerationRequest, body []byte) {
59+
func (r *ImageGenerationRecorder) RecordRequest(span trace.Span, req *openaisdk.ImageGenerateParams, body []byte) {
6060
span.SetAttributes(buildImageGenerationRequestAttributes(req, string(body), r.traceConfig)...)
6161
}
6262

6363
// RecordResponse implements the same method as defined in tracing.ImageGenerationRecorder.
64-
func (r *ImageGenerationRecorder) RecordResponse(span trace.Span, resp *openai.ImageGenerationResponse) {
64+
func (r *ImageGenerationRecorder) RecordResponse(span trace.Span, resp *openaisdk.ImagesResponse) {
6565
// Set output attributes.
6666
var attrs []attribute.KeyValue
6767
attrs = buildImageGenerationResponseAttributes(resp, r.traceConfig)
@@ -84,11 +84,11 @@ func (r *ImageGenerationRecorder) RecordResponseOnError(span trace.Span, statusC
8484
}
8585

8686
// buildImageGenerationRequestAttributes builds OpenInference attributes from the image generation request.
87-
func buildImageGenerationRequestAttributes(req *openai.ImageGenerationRequest, body string, config *openinference.TraceConfig) []attribute.KeyValue {
87+
func buildImageGenerationRequestAttributes(req *openaisdk.ImageGenerateParams, body string, config *openinference.TraceConfig) []attribute.KeyValue {
8888
attrs := []attribute.KeyValue{
8989
attribute.String(openinference.SpanKind, openinference.SpanKindLLM),
9090
attribute.String(openinference.LLMSystem, openinference.LLMSystemOpenAI),
91-
attribute.String(openinference.LLMModelName, req.Model),
91+
attribute.String(openinference.LLMModelName, string(req.Model)),
9292
}
9393

9494
if config.HideInputs {
@@ -101,34 +101,31 @@ func buildImageGenerationRequestAttributes(req *openai.ImageGenerationRequest, b
101101
// Add image generation specific attributes
102102
attrs = append(attrs, attribute.String("gen_ai.operation.name", "image_generation"))
103103
attrs = append(attrs, attribute.String("gen_ai.image.prompt", req.Prompt))
104-
attrs = append(attrs, attribute.String("gen_ai.image.size", req.Size))
105-
attrs = append(attrs, attribute.String("gen_ai.image.quality", req.Quality))
106-
attrs = append(attrs, attribute.String("gen_ai.image.style", req.Style))
107-
attrs = append(attrs, attribute.String("gen_ai.image.response_format", req.ResponseFormat))
108-
if req.N != nil {
109-
attrs = append(attrs, attribute.Int("gen_ai.image.n", *req.N))
104+
attrs = append(attrs, attribute.String("gen_ai.image.size", string(req.Size)))
105+
attrs = append(attrs, attribute.String("gen_ai.image.quality", string(req.Quality)))
106+
attrs = append(attrs, attribute.String("gen_ai.image.response_format", string(req.ResponseFormat)))
107+
if req.N.Valid() {
108+
attrs = append(attrs, attribute.Int("gen_ai.image.n", int(req.N.Value)))
110109
}
111110

112111
return attrs
113112
}
114113

115114
// buildImageGenerationResponseAttributes builds OpenInference attributes from the image generation response.
116-
func buildImageGenerationResponseAttributes(resp *openai.ImageGenerationResponse, config *openinference.TraceConfig) []attribute.KeyValue {
115+
func buildImageGenerationResponseAttributes(resp *openaisdk.ImagesResponse, config *openinference.TraceConfig) []attribute.KeyValue {
117116
attrs := []attribute.KeyValue{
118-
attribute.String("gen_ai.response.model", resp.Model),
119117
attribute.Int("gen_ai.image.count", len(resp.Data)),
120118
}
121119

122-
// Add image URLs if not hidden
120+
// Add image URLs if not hidden (SDK uses string field for URL)
123121
if !config.HideOutputs && resp.Data != nil {
124122
urls := make([]string, 0, len(resp.Data))
125123
for _, data := range resp.Data {
126-
if data.URL != nil {
127-
urls = append(urls, *data.URL)
124+
if data.URL != "" {
125+
urls = append(urls, data.URL)
128126
}
129127
}
130128
if len(urls) > 0 {
131-
// Join URLs with comma for attribute storage
132129
urlStr := ""
133130
for i, url := range urls {
134131
if i > 0 {

0 commit comments

Comments
 (0)