github.com/grafana/pyroscope@v1.18.0/pkg/ingester/otlp/ingest_handler.go (about) 1 package otlp 2 3 import ( 4 "compress/gzip" 5 "context" 6 "errors" 7 "fmt" 8 "io" 9 "net/http" 10 "strings" 11 12 "google.golang.org/grpc" 13 "google.golang.org/grpc/codes" 14 "google.golang.org/grpc/keepalive" 15 "google.golang.org/grpc/status" 16 "google.golang.org/protobuf/encoding/protojson" 17 "google.golang.org/protobuf/proto" 18 19 "github.com/dustin/go-humanize" 20 "github.com/go-kit/log" 21 "github.com/go-kit/log/level" 22 "github.com/google/uuid" 23 "github.com/grafana/dskit/server" 24 "github.com/grafana/dskit/tenant" 25 pprofileotlp "go.opentelemetry.io/proto/otlp/collector/profiles/v1development" 26 v1 "go.opentelemetry.io/proto/otlp/common/v1" 27 28 typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1" 29 distributormodel "github.com/grafana/pyroscope/pkg/distributor/model" 30 "github.com/grafana/pyroscope/pkg/model" 31 "github.com/grafana/pyroscope/pkg/pprof" 32 httputil "github.com/grafana/pyroscope/pkg/util/http" 33 "github.com/grafana/pyroscope/pkg/validation" 34 ) 35 36 type ingestHandler struct { 37 pprofileotlp.UnimplementedProfilesServiceServer 38 svc PushService 39 log log.Logger 40 handler http.Handler 41 limits Limits 42 } 43 44 type Handler interface { 45 http.Handler 46 pprofileotlp.ProfilesServiceServer 47 } 48 49 type PushService interface { 50 PushBatch(ctx context.Context, req *distributormodel.PushRequest) error 51 } 52 53 type Limits interface { 54 IngestionBodyLimitBytes(tenantID string) int64 55 } 56 57 func NewOTLPIngestHandler(cfg server.Config, svc PushService, l log.Logger, limits Limits) Handler { 58 h := &ingestHandler{ 59 svc: svc, 60 log: l, 61 limits: limits, 62 } 63 64 grpcServer := newGrpcServer(cfg) 65 pprofileotlp.RegisterProfilesServiceServer(grpcServer, h) 66 67 h.handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 68 if r.ProtoMajor == 2 && strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc") { 69 grpcServer.ServeHTTP(w, r) 70 return 71 } 72 73 // Handle HTTP/JSON and HTTP/Protobuf requests 74 contentType := r.Header.Get("Content-Type") 75 if contentType == "application/json" || contentType == "application/x-protobuf" || contentType == "application/protobuf" { 76 h.handleHTTPRequest(w, r) 77 return 78 } 79 80 http.Error(w, fmt.Sprintf("Unsupported Content-Type: %s", contentType), http.StatusUnsupportedMediaType) 81 }) 82 83 return h 84 } 85 86 func newGrpcServer(cfg server.Config) *grpc.Server { 87 grpcKeepAliveOptions := keepalive.ServerParameters{ 88 MaxConnectionIdle: cfg.GRPCServerMaxConnectionIdle, 89 MaxConnectionAge: cfg.GRPCServerMaxConnectionAge, 90 MaxConnectionAgeGrace: cfg.GRPCServerMaxConnectionAgeGrace, 91 Time: cfg.GRPCServerTime, 92 Timeout: cfg.GRPCServerTimeout, 93 } 94 95 grpcKeepAliveEnforcementPolicy := keepalive.EnforcementPolicy{ 96 MinTime: cfg.GRPCServerMinTimeBetweenPings, 97 PermitWithoutStream: cfg.GRPCServerPingWithoutStreamAllowed, 98 } 99 100 grpcOptions := []grpc.ServerOption{ 101 grpc.KeepaliveParams(grpcKeepAliveOptions), 102 grpc.KeepaliveEnforcementPolicy(grpcKeepAliveEnforcementPolicy), 103 grpc.MaxRecvMsgSize(cfg.GRPCServerMaxRecvMsgSize), 104 grpc.MaxSendMsgSize(cfg.GRPCServerMaxSendMsgSize), 105 grpc.MaxConcurrentStreams(uint32(cfg.GRPCServerMaxConcurrentStreams)), 106 grpc.NumStreamWorkers(uint32(cfg.GRPCServerNumWorkers)), 107 } 108 109 grpcOptions = append(grpcOptions, cfg.GRPCOptions...) 110 111 return grpc.NewServer(grpcOptions...) 112 } 113 114 func (h *ingestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 115 h.handler.ServeHTTP(w, r) 116 } 117 118 func isHTTPRequestBodyTooLarge(err error) error { 119 herr := new(http.MaxBytesError) 120 if errors.As(err, &herr) { 121 return validation.NewErrorf(validation.BodySizeLimit, "profile payload size exceeds limit of %s", humanize.Bytes(uint64(herr.Limit))) 122 } 123 return nil 124 } 125 126 func isKnownValidationError(err error) bool { 127 return validation.ReasonOf(err) != validation.Unknown 128 } 129 130 func (h *ingestHandler) handleHTTPRequest(w http.ResponseWriter, r *http.Request) { 131 tenantID, err := tenant.TenantID(r.Context()) 132 if err != nil { 133 httputil.ErrorWithStatus(w, err, http.StatusUnauthorized) 134 return 135 } 136 maxBodyBytes := h.limits.IngestionBodyLimitBytes(tenantID) 137 138 defer r.Body.Close() 139 140 var ( 141 errMsgBodyRead = "failed to read request body" 142 reader = r.Body 143 ) 144 145 if strings.EqualFold(r.Header.Get("Content-Encoding"), "gzip") { 146 gzipReader, gzipErr := gzip.NewReader(r.Body) 147 if gzipErr != nil { 148 level.Error(h.log).Log("msg", "failed to create gzip reader", "err", gzipErr) 149 http.Error(w, "Failed to read request body", http.StatusBadRequest) 150 return 151 } 152 defer gzipReader.Close() 153 errMsgBodyRead = "failed to read gzip-compressed request body" 154 155 reader = gzipReader 156 // Limit after decompression size 157 if maxBodyBytes > 0 { 158 reader = io.NopCloser(io.LimitReader(reader, maxBodyBytes+1)) 159 } 160 } 161 162 body, err := io.ReadAll(reader) 163 if maxBodyBytes > 0 && int64(len(body)) > maxBodyBytes { 164 validation.DiscardedBytes.WithLabelValues(string(validation.BodySizeLimit), tenantID).Add(float64(maxBodyBytes)) 165 validation.DiscardedProfiles.WithLabelValues(string(validation.BodySizeLimit), tenantID).Add(1) 166 err := validation.NewErrorf(validation.BodySizeLimit, "uncompressed profile payload size exceeds limit of %s", humanize.Bytes(uint64(maxBodyBytes))) 167 http.Error(w, err.Error(), http.StatusRequestEntityTooLarge) 168 return 169 } 170 if err != nil { 171 level.Error(h.log).Log("msg", errMsgBodyRead, "err", err) 172 // handle if body size limit is hit with correct status code 173 if herr := isHTTPRequestBodyTooLarge(err); herr != nil { 174 validation.DiscardedBytes.WithLabelValues(string(validation.BodySizeLimit), tenantID).Add(float64(maxBodyBytes)) 175 validation.DiscardedProfiles.WithLabelValues(string(validation.BodySizeLimit), tenantID).Add(1) 176 http.Error(w, herr.Error(), http.StatusRequestEntityTooLarge) 177 return 178 } 179 http.Error(w, errMsgBodyRead, http.StatusBadRequest) 180 return 181 } 182 183 req := &pprofileotlp.ExportProfilesServiceRequest{} 184 185 isJSONRequest := r.Header.Get("Content-Type") == "application/json" 186 if isJSONRequest { 187 if err := protojson.Unmarshal(body, req); err != nil { 188 level.Error(h.log).Log("msg", "failed to unmarshal JSON request", "err", err) 189 http.Error(w, "Failed to parse JSON request", http.StatusBadRequest) 190 return 191 } 192 } else { 193 if err := proto.Unmarshal(body, req); err != nil { 194 level.Error(h.log).Log("msg", "failed to unmarshal protobuf request", "err", err) 195 http.Error(w, "Failed to parse protobuf request", http.StatusBadRequest) 196 return 197 } 198 } 199 200 resp, err := h.export(r.Context(), req) 201 if err != nil { 202 level.Error(h.log).Log("msg", "failed to process profiles", "err", err) 203 if isKnownValidationError(err) { 204 http.Error(w, err.Error(), http.StatusBadRequest) 205 } else { 206 http.Error(w, err.Error(), http.StatusInternalServerError) 207 } 208 return 209 } 210 211 respBytes, err := proto.Marshal(resp) 212 if err != nil { 213 level.Error(h.log).Log("msg", "failed to marshal response", "err", err) 214 http.Error(w, "Failed to marshal response", http.StatusInternalServerError) 215 return 216 } 217 218 w.Header().Set("Content-Type", "application/x-protobuf") 219 w.WriteHeader(http.StatusOK) 220 if _, err := w.Write(respBytes); err != nil { 221 level.Error(h.log).Log("msg", "failed to write response", "err", err) 222 } 223 } 224 225 func (h *ingestHandler) Export(ctx context.Context, er *pprofileotlp.ExportProfilesServiceRequest) (*pprofileotlp.ExportProfilesServiceResponse, error) { 226 return h.export(ctx, er) 227 } 228 229 func (h *ingestHandler) export(ctx context.Context, er *pprofileotlp.ExportProfilesServiceRequest) (*pprofileotlp.ExportProfilesServiceResponse, error) { 230 _, err := tenant.TenantID(ctx) 231 if err != nil { 232 return &pprofileotlp.ExportProfilesServiceResponse{}, status.Errorf(codes.Unauthenticated, "failed to extract tenant ID from context: %s", err.Error()) 233 } 234 235 dc := er.Dictionary 236 if dc == nil { 237 return &pprofileotlp.ExportProfilesServiceResponse{}, status.Errorf(codes.InvalidArgument, "missing profile metadata dictionary") 238 } 239 240 rps := er.ResourceProfiles 241 if rps == nil { 242 return &pprofileotlp.ExportProfilesServiceResponse{}, status.Errorf(codes.InvalidArgument, "missing resource profiles") 243 } 244 245 for _, rp := range rps { 246 serviceName := getServiceNameFromAttributes(rp.Resource.GetAttributes()) 247 for _, sp := range rp.ScopeProfiles { 248 for _, p := range sp.Profiles { 249 pprofProfiles, err := ConvertOtelToGoogle(p, dc) 250 if err != nil { 251 grpcError := status.Errorf(codes.InvalidArgument, "failed to convert otel profile: %s", err.Error()) 252 return &pprofileotlp.ExportProfilesServiceResponse{}, grpcError 253 } 254 255 req := &distributormodel.PushRequest{ 256 ReceivedCompressedProfileSize: proto.Size(p), 257 RawProfileType: distributormodel.RawProfileTypeOTEL, 258 } 259 260 for samplesServiceName, pprofProfile := range pprofProfiles { 261 labels := getDefaultLabels() 262 labels = append(labels, pprofProfile.name) 263 processedKeys := map[string]bool{model.LabelNameProfileName: true} 264 labels = appendAttributesUnique(labels, rp.Resource.GetAttributes(), processedKeys) 265 labels = appendAttributesUnique(labels, sp.Scope.GetAttributes(), processedKeys) 266 svc := samplesServiceName 267 if svc == "" { 268 svc = serviceName 269 } 270 labels = append(labels, &typesv1.LabelPair{ 271 Name: model.LabelNameServiceName, 272 Value: svc, 273 }) 274 275 s := &distributormodel.ProfileSeries{ 276 Labels: labels, 277 RawProfile: nil, 278 Profile: pprof.RawFromProto(pprofProfile.profile), 279 ID: uuid.New().String(), 280 } 281 req.Series = append(req.Series, s) 282 } 283 if len(req.Series) == 0 { 284 continue 285 } 286 err = h.svc.PushBatch(ctx, req) 287 if err != nil { 288 h.log.Log("msg", "failed to push profile", "err", err) 289 // Note: Validation metrics are already tracked by the distributor for errors 290 // returned from PushBatch, so we don't track them here to avoid double-counting. 291 return &pprofileotlp.ExportProfilesServiceResponse{}, fmt.Errorf("failed to make a GRPC request: %w", err) 292 } 293 } 294 } 295 } 296 297 return &pprofileotlp.ExportProfilesServiceResponse{}, nil 298 } 299 300 // getServiceNameFromAttributes extracts service name from OTLP resource attributes. 301 // according to otel spec https://github.com/open-telemetry/opentelemetry-go/blob/ecfb73581f1b05af85fc393c3ce996a90cf2a5e2/semconv/v1.30.0/attribute_group.go#L10011-L10025 302 // Returns "unknown_service:$process_name" if no service.name, but there is a process.executable.name 303 // Returns "unknown_service" if no service.name and no process.executable.name 304 func getServiceNameFromAttributes(attrs []*v1.KeyValue) string { 305 fallback := model.AttrServiceNameFallback 306 for _, attr := range attrs { 307 if attr.Key == string(model.AttrServiceName) { 308 val := attr.GetValue() 309 if sv := val.GetStringValue(); sv != "" { 310 return sv 311 } 312 } 313 if attr.Key == string(model.AttrProcessExecutableName) { 314 val := attr.GetValue() 315 if sv := val.GetStringValue(); sv != "" { 316 fallback += ":" + sv 317 } 318 } 319 320 } 321 return fallback 322 } 323 324 // getDefaultLabels returns the required base labels for Pyroscope profiles 325 func getDefaultLabels() []*typesv1.LabelPair { 326 return []*typesv1.LabelPair{ 327 { 328 Name: model.LabelNameDelta, 329 Value: "false", 330 }, 331 { 332 Name: model.LabelNameOTEL, 333 Value: "true", 334 }, 335 } 336 } 337 338 func appendAttributesUnique(labels []*typesv1.LabelPair, attrs []*v1.KeyValue, processedKeys map[string]bool) []*typesv1.LabelPair { 339 for _, attr := range attrs { 340 // Skip if we've already seen this key at any level 341 if processedKeys[attr.Key] { 342 continue 343 } 344 345 val := attr.GetValue() 346 if sv := val.GetStringValue(); sv != "" { 347 labels = append(labels, &typesv1.LabelPair{ 348 Name: attr.Key, 349 Value: sv, 350 }) 351 processedKeys[attr.Key] = true 352 } 353 } 354 return labels 355 }