diff --git a/app/app_test.go b/app/app_test.go index 3b3904cc4c..a078196dec 100644 --- a/app/app_test.go +++ b/app/app_test.go @@ -10,6 +10,7 @@ import ( "net/http" "net/http/httptest" "os" + "runtime" "strconv" "strings" "sync" @@ -27,6 +28,7 @@ import ( "github.com/honeycombio/libhoney-go" "github.com/honeycombio/libhoney-go/transmission" + "github.com/honeycombio/libhoney-go/version" "github.com/honeycombio/refinery/collect" "github.com/honeycombio/refinery/config" "github.com/honeycombio/refinery/internal/health" @@ -392,6 +394,7 @@ func TestPeerRouting(t *testing.T) { "field10": float64(10), "long": "this is a test of the emergency broadcast system", "meta.refinery.original_sample_rate": uint(2), + "meta.refinery.incoming_user_agent": getLibhoneyUserAgent(), "foo": "bar", }, Metadata: map[string]any{ @@ -517,6 +520,7 @@ func TestEventsEndpoint(t *testing.T) { "trace.trace_id": "1", "foo": "bar", "meta.refinery.original_sample_rate": uint(10), + "meta.refinery.incoming_user_agent": getLibhoneyUserAgent(), }, Metadata: map[string]any{ "api_host": "http://api.honeycomb.io", @@ -566,6 +570,7 @@ func TestEventsEndpoint(t *testing.T) { "trace.trace_id": "1", "foo": "bar", "meta.refinery.original_sample_rate": uint(10), + "meta.refinery.incoming_user_agent": getLibhoneyUserAgent(), }, Metadata: map[string]any{ "api_host": "http://api.honeycomb.io", @@ -639,6 +644,7 @@ func TestEventsEndpointWithNonLegacyKey(t *testing.T) { "trace.trace_id": traceID, "foo": "bar", "meta.refinery.original_sample_rate": uint(10), + "meta.refinery.incoming_user_agent": getLibhoneyUserAgent(), }, Metadata: map[string]any{ "api_host": "http://api.honeycomb.io", @@ -688,6 +694,7 @@ func TestEventsEndpointWithNonLegacyKey(t *testing.T) { "trace.trace_id": traceID, "foo": "bar", "meta.refinery.original_sample_rate": uint(10), + "meta.refinery.incoming_user_agent": getLibhoneyUserAgent(), }, Metadata: map[string]any{ "api_host": "http://api.honeycomb.io", @@ -908,3 +915,11 @@ func BenchmarkDistributedTraces(b *testing.B) { sender.waitForCount(b, b.N) }) } + +// ideally we should get this from libhoney, but we don't have a way to get it yet +// this can be removed if libhoney does provide it +func getLibhoneyUserAgent() string { + baseUserAgent := fmt.Sprintf("libhoney-go/%s", version.Version) + runtimeInfo := fmt.Sprintf("%s (%s/%s)", strings.Replace(runtime.Version(), "go", "go/", 1), runtime.GOOS, runtime.GOARCH) + return fmt.Sprintf("%s %s", baseUserAgent, runtimeInfo) +} diff --git a/route/otlp_logs.go b/route/otlp_logs.go index 371e1f1af9..3b2c2b2889 100644 --- a/route/otlp_logs.go +++ b/route/otlp_logs.go @@ -38,7 +38,7 @@ func (r *Router) postOTLPLogs(w http.ResponseWriter, req *http.Request) { return } - if err := r.processOTLPRequest(req.Context(), result.Batches, keyToUse); err != nil { + if err := r.processOTLPRequest(req.Context(), result.Batches, keyToUse, ri.UserAgent); err != nil { r.handleOTLPFailureResponse(w, req, huskyotlp.OTLPError{Message: err.Error(), HTTPStatusCode: http.StatusInternalServerError}) return } @@ -74,7 +74,7 @@ func (l *LogsServer) Export(ctx context.Context, req *collectorlogs.ExportLogsSe return nil, huskyotlp.AsGRPCError(err) } - if err := l.router.processOTLPRequest(ctx, result.Batches, keyToUse); err != nil { + if err := l.router.processOTLPRequest(ctx, result.Batches, keyToUse, ri.UserAgent); err != nil { return nil, huskyotlp.AsGRPCError(err) } diff --git a/route/otlp_logs_test.go b/route/otlp_logs_test.go index 5ba74ab758..0a0789a374 100644 --- a/route/otlp_logs_test.go +++ b/route/otlp_logs_test.go @@ -375,6 +375,63 @@ func TestLogsOTLPHandler(t *testing.T) { assert.Equal(t, 0, len(router.Collector.(*collect.MockCollector).Spans)) mockCollector.Flush() }) + + t.Run("logs record incoming user agent - gRPC", func(t *testing.T) { + md := metadata.New(map[string]string{"x-honeycomb-team": legacyAPIKey, "x-honeycomb-dataset": "ds", "user-agent": "my-user-agent"}) + ctx := metadata.NewIncomingContext(context.Background(), md) + + req := &collectorlogs.ExportLogsServiceRequest{ + ResourceLogs: []*logs.ResourceLogs{{ + ScopeLogs: []*logs.ScopeLogs{{ + LogRecords: createLogsRecords(), + }}, + }}, + } + _, err := logsServer.Export(ctx, req) + if err != nil { + t.Errorf(`Unexpected error: %s`, err) + } + assert.Equal(t, 1, len(mockTransmission.Events)) + event := mockTransmission.Events[0] + assert.Equal(t, "my-user-agent", event.Data["meta.refinery.incoming_user_agent"]) + + mockTransmission.Flush() + assert.Equal(t, 0, len(router.Collector.(*collect.MockCollector).Spans)) + mockCollector.Flush() + }) + + t.Run("logs record incoming user agent - HTTP", func(t *testing.T) { + req := &collectorlogs.ExportLogsServiceRequest{ + ResourceLogs: []*logs.ResourceLogs{{ + ScopeLogs: []*logs.ScopeLogs{{ + LogRecords: createLogsRecords(), + }}, + }}, + } + body, err := protojson.Marshal(req) + if err != nil { + t.Error(err) + } + + request, _ := http.NewRequest("POST", "/v1/logs", bytes.NewReader(body)) + request.Header = http.Header{} + request.Header.Set("content-type", "application/json") + request.Header.Set("x-honeycomb-team", legacyAPIKey) + request.Header.Set("x-honeycomb-dataset", "dataset") + request.Header.Set("user-agent", "my-user-agent") + + w := httptest.NewRecorder() + router.postOTLPLogs(w, request) + assert.Equal(t, w.Code, http.StatusOK) + + assert.Equal(t, 1, len(mockTransmission.Events)) + event := mockTransmission.Events[0] + assert.Equal(t, "my-user-agent", event.Data["meta.refinery.incoming_user_agent"]) + + mockTransmission.Flush() + assert.Equal(t, 0, len(router.Collector.(*collect.MockCollector).Spans)) + mockCollector.Flush() + }) } func createLogsRecords() []*logs.LogRecord { diff --git a/route/otlp_trace.go b/route/otlp_trace.go index c0137b0a76..8fcbdaae7c 100644 --- a/route/otlp_trace.go +++ b/route/otlp_trace.go @@ -38,7 +38,7 @@ func (r *Router) postOTLPTrace(w http.ResponseWriter, req *http.Request) { return } - if err := r.processOTLPRequest(req.Context(), result.Batches, keyToUse); err != nil { + if err := r.processOTLPRequest(req.Context(), result.Batches, keyToUse, ri.UserAgent); err != nil { r.handleOTLPFailureResponse(w, req, huskyotlp.OTLPError{Message: err.Error(), HTTPStatusCode: http.StatusInternalServerError}) return } @@ -74,7 +74,7 @@ func (t *TraceServer) Export(ctx context.Context, req *collectortrace.ExportTrac return nil, huskyotlp.AsGRPCError(err) } - if err := t.router.processOTLPRequest(ctx, result.Batches, keyToUse); err != nil { + if err := t.router.processOTLPRequest(ctx, result.Batches, keyToUse, ri.UserAgent); err != nil { return nil, huskyotlp.AsGRPCError(err) } diff --git a/route/otlp_trace_test.go b/route/otlp_trace_test.go index 93fbb390e6..ce318944c2 100644 --- a/route/otlp_trace_test.go +++ b/route/otlp_trace_test.go @@ -484,6 +484,13 @@ func TestOTLPHandler(t *testing.T) { ReceiveKeys: []string{}, AcceptOnlyListedKeys: true, } + defer func() { + router.Config.(*config.MockConfig).GetAccessKeyConfigVal = config.AccessKeyConfig{ + ReceiveKeys: []string{legacyAPIKey}, + AcceptOnlyListedKeys: false, + } + }() + req := &collectortrace.ExportTraceServiceRequest{ ResourceSpans: []*trace.ResourceSpans{{ ScopeSpans: []*trace.ScopeSpans{{ @@ -498,6 +505,57 @@ func TestOTLPHandler(t *testing.T) { assert.Equal(t, 0, len(mockTransmission.Events)) mockTransmission.Flush() }) + + t.Run("spans record incoming user agent - gRPC", func(t *testing.T) { + md := metadata.New(map[string]string{"x-honeycomb-team": legacyAPIKey, "x-honeycomb-dataset": "ds", "user-agent": "my-user-agent"}) + ctx := metadata.NewIncomingContext(context.Background(), md) + + req := &collectortrace.ExportTraceServiceRequest{ + ResourceSpans: []*trace.ResourceSpans{{ + ScopeSpans: []*trace.ScopeSpans{{ + Spans: helperOTLPRequestSpansWithStatus(), + }}, + }}, + } + traceServer := NewTraceServer(router) + _, err := traceServer.Export(ctx, req) + if err != nil { + t.Errorf(`Unexpected error: %s`, err) + } + assert.Equal(t, 2, len(mockTransmission.Events)) + event := mockTransmission.Events[0] + assert.Equal(t, "my-user-agent", event.Data["meta.refinery.incoming_user_agent"]) + mockTransmission.Flush() + }) + + t.Run("spans record incoming user agent - HTTP", func(t *testing.T) { + req := &collectortrace.ExportTraceServiceRequest{ + ResourceSpans: []*trace.ResourceSpans{{ + ScopeSpans: []*trace.ScopeSpans{{ + Spans: helperOTLPRequestSpansWithStatus(), + }}, + }}, + } + body, err := protojson.Marshal(req) + if err != nil { + t.Error(err) + } + + request, _ := http.NewRequest("POST", "/v1/traces", bytes.NewReader(body)) + request.Header = http.Header{} + request.Header.Set("content-type", "application/json") + request.Header.Set("x-honeycomb-team", legacyAPIKey) + request.Header.Set("x-honeycomb-dataset", "dataset") + request.Header.Set("user-agent", "my-user-agent") + + w := httptest.NewRecorder() + router.postOTLPTrace(w, request) + + assert.Equal(t, 2, len(mockTransmission.Events)) + event := mockTransmission.Events[0] + assert.Equal(t, "my-user-agent", event.Data["meta.refinery.incoming_user_agent"]) + mockTransmission.Flush() + }) } func helperOTLPRequestSpansWithoutStatus() []*trace.Span { diff --git a/route/route.go b/route/route.go index 249d727917..484b7840d8 100644 --- a/route/route.go +++ b/route/route.go @@ -381,6 +381,7 @@ func (r *Router) event(w http.ResponseWriter, req *http.Request) { r.handlerReturnWithError(w, ErrReqToEvent, err) return } + addIncomingUserAgent(ev, getUserAgentFromRequest(req)) reqID := req.Context().Value(types.RequestIDContextKey{}) err = r.processEvent(ev, reqID) @@ -476,6 +477,7 @@ func (r *Router) batch(w http.ResponseWriter, req *http.Request) { r.handlerReturnWithError(w, ErrReqToEvent, err) } + userAgent := getUserAgentFromRequest(req) batchedResponses := make([]*BatchResponse, 0, len(batchedEvents)) for _, bev := range batchedEvents { ev := &types.Event{ @@ -489,6 +491,7 @@ func (r *Router) batch(w http.ResponseWriter, req *http.Request) { Data: bev.Data, } + addIncomingUserAgent(ev, userAgent) err = r.processEvent(ev, reqID) var resp BatchResponse @@ -515,7 +518,8 @@ func (r *Router) batch(w http.ResponseWriter, req *http.Request) { func (router *Router) processOTLPRequest( ctx context.Context, batches []huskyotlp.Batch, - apiKey string) error { + apiKey string, + incomingUserAgent string) error { var requestID types.RequestIDContextKey apiHost := router.Config.GetHoneycombAPI() @@ -538,6 +542,7 @@ func (router *Router) processOTLPRequest( Timestamp: ev.Timestamp, Data: ev.Attributes, } + addIncomingUserAgent(event, incomingUserAgent) if err = router.processEvent(event, requestID); err != nil { router.Logger.Error().Logf("Error processing event: " + err.Error()) } @@ -1013,3 +1018,13 @@ func getDatasetFromRequest(req *http.Request) (string, error) { } return dataset, nil } + +func getUserAgentFromRequest(req *http.Request) string { + return req.Header.Get("User-Agent") +} + +func addIncomingUserAgent(ev *types.Event, userAgent string) { + if userAgent != "" { + ev.Data["meta.refinery.incoming_user_agent"] = userAgent + } +}