Repository: prebid/prebid-server
Pull request #2516 (2023-01-05 12:03:59)

Modularity: Enrich Bid Response with hook execution results


Patch:
diff --git a/adapters/outbrain/outbrain.go b/adapters/outbrain/outbrain.go
index a01b535a..94cd1b6a 100644
--- a/adapters/outbrain/outbrain.go
+++ b/adapters/outbrain/outbrain.go
@@ -155,12 +155,14 @@ func getMediaTypeForImp(impID string, imps []openrtb2.Imp) (openrtb_ext.BidType,
 				return openrtb_ext.BidTypeNative, nil
 			} else if imp.Banner != nil {
 				return openrtb_ext.BidTypeBanner, nil
+			} else if imp.Video != nil {
+				return openrtb_ext.BidTypeVideo, nil
 			}
 		}
 	}
 
 	return "", &errortypes.BadInput{
-		Message: fmt.Sprintf("Failed to find native/banner impression \"%s\" ", impID),
+		Message: fmt.Sprintf("Failed to find native/banner/video impression \"%s\" ", impID),
 	}
 }
 
diff --git a/amp/parse.go b/amp/parse.go
index bbbcee79..170e0659 100644
--- a/amp/parse.go
+++ b/amp/parse.go
@@ -31,6 +31,7 @@ type Params struct {
 	StoredRequestID   string
 	Targeting         string
 	Timeout           *uint64
+	Trace             string
 }
 
 // Size defines size information of an AMP request.
@@ -172,6 +173,7 @@ func ParseParams(httpRequest *http.Request) (Params, error) {
 		Slot:            query.Get("slot"),
 		StoredRequestID: tagID,
 		Targeting:       query.Get("targeting"),
+		Trace:           query.Get("trace"),
 	}
 	var err error
 	urlQueryGdprApplies := query.Get("gdpr_applies")
diff --git a/analytics/core.go b/analytics/core.go
index e2d5a071..f19d41ec 100644
--- a/analytics/core.go
+++ b/analytics/core.go
@@ -5,6 +5,7 @@ import (
 
 	"github.com/prebid/openrtb/v17/openrtb2"
 	"github.com/prebid/prebid-server/config"
+	"github.com/prebid/prebid-server/hooks/hookexecution"
 	"github.com/prebid/prebid-server/openrtb_ext"
 )
 
@@ -27,23 +28,25 @@ type PBSAnalyticsModule interface {
 
 // Loggable object of a transaction at /openrtb2/auction endpoint
 type AuctionObject struct {
-	Status    int
-	Errors    []error
-	Request   *openrtb2.BidRequest
-	Response  *openrtb2.BidResponse
-	Account   *config.Account
-	StartTime time.Time
+	Status               int
+	Errors               []error
+	Request              *openrtb2.BidRequest
+	Response             *openrtb2.BidResponse
+	Account              *config.Account
+	StartTime            time.Time
+	HookExecutionOutcome []hookexecution.StageOutcome
 }
 
 // Loggable object of a transaction at /openrtb2/amp endpoint
 type AmpObject struct {
-	Status             int
-	Errors             []error
-	Request            *openrtb2.BidRequest
-	AuctionResponse    *openrtb2.BidResponse
-	AmpTargetingValues map[string]string
-	Origin             string
-	StartTime          time.Time
+	Status               int
+	Errors               []error
+	Request              *openrtb2.BidRequest
+	AuctionResponse      *openrtb2.BidResponse
+	AmpTargetingValues   map[string]string
+	Origin               string
+	StartTime            time.Time
+	HookExecutionOutcome []hookexecution.StageOutcome
 }
 
 // Loggable object of a transaction at /openrtb2/video endpoint
diff --git a/endpoints/openrtb2/amp_auction.go b/endpoints/openrtb2/amp_auction.go
index c2ad1832..7ee9f675 100644
--- a/endpoints/openrtb2/amp_auction.go
+++ b/endpoints/openrtb2/amp_auction.go
@@ -42,10 +42,12 @@ const defaultAmpRequestTimeoutMillis = 900
 var nilBody []byte = nil
 
 type AmpResponse struct {
-	Targeting map[string]string                                         `json:"targeting"`
-	Debug     *openrtb_ext.ExtResponseDebug                             `json:"debug,omitempty"`
-	Errors    map[openrtb_ext.BidderName][]openrtb_ext.ExtBidderMessage `json:"errors,omitempty"`
-	Warnings  map[openrtb_ext.BidderName][]openrtb_ext.ExtBidderMessage `json:"warnings,omitempty"`
+	Targeting map[string]string `json:"targeting"`
+	ORTB2     ORTB2             `json:"ortb2"`
+}
+
+type ORTB2 struct {
+	Ext openrtb_ext.ExtBidResponse `json:"ext"`
 }
 
 // NewAmpEndpoint modifies the OpenRTB endpoint to handle AMP requests. This will basically modify the parsing
@@ -147,13 +149,15 @@ func (deps *endpointDeps) AmpAuction(w http.ResponseWriter, r *http.Request, _ h
 	w.Header().Set("X-Prebid", version.BuildXPrebidHeader(version.Ver))
 
 	// There is no body for AMP requests, so we pass a nil body and ignore the return value.
-	if _, rejectErr := deps.hookExecutor.ExecuteEntrypointStage(r, nilBody); rejectErr != nil {
-		labels, ao = rejectAmpRequest(*rejectErr, w, deps.hookExecutor, nil, labels, ao, nil)
-		return
-	}
-
+	_, rejectErr := deps.hookExecutor.ExecuteEntrypointStage(r, nilBody)
 	reqWrapper, storedAuctionResponses, storedBidResponses, bidderImpReplaceImp, errL := deps.parseAmpRequest(r)
 	ao.Errors = append(ao.Errors, errL...)
+	// Process reject after parsing amp request, so we can use reqWrapper.
+	// There is no body for AMP requests, so we pass a nil body and ignore the return value.
+	if rejectErr != nil {
+		labels, ao = rejectAmpRequest(*rejectErr, w, deps.hookExecutor, reqWrapper, nil, labels, ao, nil)
+		return
+	}
 
 	if errortypes.ContainsFatalError(errL) {
 		w.WriteHeader(http.StatusBadRequest)
@@ -255,11 +259,11 @@ func (deps *endpointDeps) AmpAuction(w http.ResponseWriter, r *http.Request, _ h
 	}
 
 	if isRejectErr {
-		labels, ao = rejectAmpRequest(*rejectErr, w, deps.hookExecutor, reqWrapper, labels, ao, errL)
+		labels, ao = rejectAmpRequest(*rejectErr, w, deps.hookExecutor, reqWrapper, account, labels, ao, errL)
 		return
 	}
 
-	labels, ao = sendAmpResponse(w, deps.hookExecutor, response, reqWrapper, labels, ao, errL)
+	labels, ao = sendAmpResponse(w, deps.hookExecutor, response, reqWrapper, account, labels, ao, errL)
 }
 
 func rejectAmpRequest(
@@ -267,6 +271,7 @@ func rejectAmpRequest(
 	w http.ResponseWriter,
 	hookExecutor hookexecution.HookStageExecutor,
 	reqWrapper *openrtb_ext.RequestWrapper,
+	account *config.Account,
 	labels metrics.Labels,
 	ao analytics.AmpObject,
 	errs []error,
@@ -275,7 +280,7 @@ func rejectAmpRequest(
 	ao.AuctionResponse = response
 	ao.Errors = append(ao.Errors, rejectErr)
 
-	return sendAmpResponse(w, hookExecutor, response, reqWrapper, labels, ao, errs)
+	return sendAmpResponse(w, hookExecutor, response, reqWrapper, account, labels, ao, errs)
 }
 
 func sendAmpResponse(
@@ -283,6 +288,7 @@ func sendAmpResponse(
 	hookExecutor hookexecution.HookStageExecutor,
 	response *openrtb2.BidResponse,
 	reqWrapper *openrtb_ext.RequestWrapper,
+	account *config.Account,
 	labels metrics.Labels,
 	ao analytics.AmpObject,
 	errs []error,
@@ -292,31 +298,62 @@ func sendAmpResponse(
 	// go in the AMP response
 	targets := map[string]string{}
 	byteCache := []byte("\"hb_cache_id")
-	for _, seatBids := range response.SeatBid {
-		for _, bid := range seatBids.Bid {
-			if bytes.Contains(bid.Ext, byteCache) {
-				// Looking for cache_id to be set, as this should only be set on winning bids (or
-				// deal bids), and AMP can only deliver cached ads in any case.
-				// Note, this could cause issues if a targeting key value starts with "hb_cache_id",
-				// but this is a very unlikely corner case. Doing this so we can catch "hb_cache_id"
-				// and "hb_cache_id_{deal}", which allows for deal support in AMP.
-				bidExt := &openrtb_ext.ExtBid{}
-				err := json.Unmarshal(bid.Ext, bidExt)
-				if err != nil {
-					w.WriteHeader(http.StatusInternalServerError)
-					fmt.Fprintf(w, "Critical error while unpacking AMP targets: %v", err)
-					glog.Errorf("/openrtb2/amp Critical error unpacking targets: %v", err)
-					ao.Errors = append(ao.Errors, fmt.Errorf("Critical error while unpacking AMP targets: %v", err))
-					ao.Status = http.StatusInternalServerError
-					return labels, ao
-				}
-				for key, value := range bidExt.Prebid.Targeting {
-					targets[key] = value
+	if response != nil {
+		for _, seatBids := range response.SeatBid {
+			for _, bid := range seatBids.Bid {
+				if bytes.Contains(bid.Ext, byteCache) {
+					// Looking for cache_id to be set, as this should only be set on winning bids (or
+					// deal bids), and AMP can only deliver cached ads in any case.
+					// Note, this could cause issues if a targeting key value starts with "hb_cache_id",
+					// but this is a very unlikely corner case. Doing this so we can catch "hb_cache_id"
+					// and "hb_cache_id_{deal}", which allows for deal support in AMP.
+					bidExt := &openrtb_ext.ExtBid{}
+					err := json.Unmarshal(bid.Ext, bidExt)
+					if err != nil {
+						w.WriteHeader(http.StatusInternalServerError)
+						fmt.Fprintf(w, "Critical error while unpacking AMP targets: %v", err)
+						glog.Errorf("/openrtb2/amp Critical error unpacking targets: %v", err)
+						ao.Errors = append(ao.Errors, fmt.Errorf("Critical error while unpacking AMP targets: %v", err))
+						ao.Status = http.StatusInternalServerError
+						return labels, ao
+					}
+					for key, value := range bidExt.Prebid.Targeting {
+						targets[key] = value
+					}
 				}
 			}
 		}
 	}
 
+	// Now JSONify the targets for the AMP response.
+	ampResponse := AmpResponse{Targeting: targets}
+	ao, ampResponse.ORTB2.Ext = getExtBidResponse(hookExecutor, response, reqWrapper, account, ao, errs)
+
+	ao.AmpTargetingValues = targets
+
+	// Fixes #231
+	enc := json.NewEncoder(w)
+	enc.SetEscapeHTML(false)
+
+	// If an error happens when encoding the response, there isn't much we can do.
+	// If we've sent _any_ bytes, then Go would have sent the 200 status code first.
+	// That status code can't be un-sent... so the best we can do is log the error.
+	if err := enc.Encode(ampResponse); err != nil {
+		labels.RequestStatus = metrics.RequestStatusNetworkErr
+		ao.Errors = append(ao.Errors, fmt.Errorf("/openrtb2/amp Failed to send response: %v", err))
+	}
+
+	return labels, ao
+}
+
+func getExtBidResponse(
+	hookExecutor hookexecution.HookStageExecutor,
+	response *openrtb2.BidResponse,
+	reqWrapper *openrtb_ext.RequestWrapper,
+	account *config.Account,
+	ao analytics.AmpObject,
+	errs []error,
+) (analytics.AmpObject, openrtb_ext.ExtBidResponse) {
 	// Extract any errors
 	var extResponse openrtb_ext.ExtBidResponse
 	eRErr := json.Unmarshal(response.Ext, &extResponse)
@@ -336,38 +373,39 @@ func sendAmpResponse(
 		warnings[openrtb_ext.BidderReservedGeneral] = append(warnings[openrtb_ext.BidderReservedGeneral], bidderErr)
 	}
 
-	// Now JSONify the targets for the AMP response.
-	ampResponse := AmpResponse{
-		Targeting: targets,
-		Errors:    extResponse.Errors,
-		Warnings:  warnings,
+	extBidResponse := openrtb_ext.ExtBidResponse{
+		Errors:   extResponse.Errors,
+		Warnings: warnings,
 	}
 
-	ao.AmpTargetingValues = targets
-
 	// add debug information if requested
-	if reqWrapper != nil && reqWrapper.Test == 1 && eRErr == nil {
-		if extResponse.Debug != nil {
-			ampResponse.Debug = extResponse.Debug
-		} else {
-			glog.Errorf("Test set on request but debug not present in response.")
-			ao.Errors = append(ao.Errors, fmt.Errorf("test set on request but debug not present in response"))
+	if reqWrapper != nil {
+		if reqWrapper.Test == 1 && eRErr == nil {
+			if extResponse.Debug != nil {
+				extBidResponse.Debug = extResponse.Debug
+			} else {
+				glog.Errorf("Test set on request but debug not present in response.")
+				ao.Errors = append(ao.Errors, fmt.Errorf("test set on request but debug not present in response"))
+			}
 		}
-	}
 
-	// Fixes #231
-	enc := json.NewEncoder(w)
-	enc.SetEscapeHTML(false)
+		stageOutcomes := hookExecutor.GetOutcomes()
+		ao.HookExecutionOutcome = stageOutcomes
+		modules, warns, err := hookexecution.GetModulesJSON(stageOutcomes, reqWrapper.BidRequest, account)
+		if err != nil {
+			err := fmt.Errorf("Failed to get modules outcome: %s", err)
+			glog.Errorf(err.Error())
+			ao.Errors = append(ao.Errors, err)
+		} else if modules != nil {
+			extBidResponse.Prebid = &openrtb_ext.ExtResponsePrebid{Modules: modules}
+		}
 
-	// If an error happens when encoding the response, there isn't much we can do.
-	// If we've sent _any_ bytes, then Go would have sent the 200 status code first.
-	// That status code can't be un-sent... so the best we can do is log the error.
-	if err := enc.Encode(ampResponse); err != nil {
-		labels.RequestStatus = metrics.RequestStatusNetworkErr
-		ao.Errors = append(ao.Errors, fmt.Errorf("/openrtb2/amp Failed to send response: %v", err))
+		if len(warns) > 0 {
+			ao.Errors = append(ao.Errors, warns...)
+		}
 	}
 
-	return labels, ao
+	return ao, extBidResponse
 }
 
 // parseRequest turns the HTTP request into an OpenRTB request.
@@ -534,6 +572,10 @@ func (deps *endpointDeps) overrideWithParams(ampParams amp.Params, req *openrtb2
 		return []error{err}
 	}
 
+	if err := setTrace(req, ampParams.Trace); err != nil {
+		return []error{err}
+	}
+
 	return nil
 }
 
@@ -715,3 +757,24 @@ func setEffectiveAmpPubID(req *openrtb2.BidRequest, account string) {
 		pub.ID = account
 	}
 }
+
+func setTrace(req *openrtb2.BidRequest, value string) (err error) {
+	if value == "" {
+		return nil
+	}
+
+	ext, err := json.Marshal(map[string]map[string]string{"prebid": {"trace": value}})
+	if err != nil {
+		return err
+	}
+
+	if len(req.Ext) > 0 {
+		ext, err = jsonpatch.MergePatch(req.Ext, ext)
+		if err != nil {
+			return err
+		}
+	}
+	req.Ext = ext
+
+	return nil
+}
diff --git a/endpoints/openrtb2/auction.go b/endpoints/openrtb2/auction.go
index 50395567..6b8e875a 100644
--- a/endpoints/openrtb2/auction.go
+++ b/endpoints/openrtb2/auction.go
@@ -177,7 +177,7 @@ func (deps *endpointDeps) Auction(w http.ResponseWriter, r *http.Request, _ http
 	}
 
 	if rejectErr := hookexecution.FindFirstRejectOrNil(errL); rejectErr != nil {
-		labels, ao = rejectAuctionRequest(*rejectErr, w, deps.hookExecutor, req.BidRequest, labels, ao)
+		labels, ao = rejectAuctionRequest(*rejectErr, w, deps.hookExecutor, req.BidRequest, account, labels, ao)
 		return
 	}
 
@@ -244,11 +244,11 @@ func (deps *endpointDeps) Auction(w http.ResponseWriter, r *http.Request, _ http
 		ao.Errors = append(ao.Errors, err)
 		return
 	} else if isRejectErr {
-		labels, ao = rejectAuctionRequest(*rejectErr, w, deps.hookExecutor, req.BidRequest, labels, ao)
+		labels, ao = rejectAuctionRequest(*rejectErr, w, deps.hookExecutor, req.BidRequest, account, labels, ao)
 		return
 	}
 
-	labels, ao = sendAuctionResponse(w, deps.hookExecutor, response, labels, ao)
+	labels, ao = sendAuctionResponse(w, deps.hookExecutor, response, req.BidRequest, account, labels, ao)
 }
 
 func rejectAuctionRequest(
@@ -256,6 +256,7 @@ func rejectAuctionRequest(
 	w http.ResponseWriter,
 	hookExecutor hookexecution.HookStageExecutor,
 	request *openrtb2.BidRequest,
+	account *config.Account,
 	labels metrics.Labels,
 	ao analytics.AuctionObject,
 ) (metrics.Labels, analytics.AuctionObject) {
@@ -267,17 +268,38 @@ func rejectAuctionRequest(
 	ao.Response = response
 	ao.Errors = append(ao.Errors, rejectErr)
 
-	return sendAuctionResponse(w, hookExecutor, response, labels, ao)
+	return sendAuctionResponse(w, hookExecutor, response, request, account, labels, ao)
 }
 
 func sendAuctionResponse(
 	w http.ResponseWriter,
 	hookExecutor hookexecution.HookStageExecutor,
 	response *openrtb2.BidResponse,
+	request *openrtb2.BidRequest,
+	account *config.Account,
 	labels metrics.Labels,
 	ao analytics.AuctionObject,
 ) (metrics.Labels, analytics.AuctionObject) {
 	hookExecutor.ExecuteAuctionResponseStage(response)
+
+	if response != nil {
+		stageOutcomes := hookExecutor.GetOutcomes()
+		ao.HookExecutionOutcome = stageOutcomes
+
+		ext, warns, err := hookexecution.EnrichExtBidResponse(response.Ext, stageOutcomes, request, account)
+		if err != nil {
+			err = fmt.Errorf("Failed to enrich Bid Response with hook debug information: %s", err)
+			glog.Errorf(err.Error())
+			ao.Errors = append(ao.Errors, err)
+		} else {
+			response.Ext = ext
+		}
+
+		if len(warns) > 0 {
+			ao.Errors = append(ao.Errors, warns...)
+		}
+	}
+
 	// Fixes #231
 	enc := json.NewEncoder(w)
 	enc.SetEscapeHTML(false)
diff --git a/endpoints/openrtb2/sample-requests/amp/consent-through-query/addtl-consent-through-query.json b/endpoints/openrtb2/sample-requests/amp/consent-through-query/addtl-consent-through-query.json
index 7247e2df..d67df50e 100644
--- a/endpoints/openrtb2/sample-requests/amp/consent-through-query/addtl-consent-through-query.json
+++ b/endpoints/openrtb2/sample-requests/amp/consent-through-query/addtl-consent-through-query.json
@@ -82,10 +82,17 @@
       "hb_pb": "15.00",
       "hb_pb_appnexus": "15.00"
     },
-    "warnings": {
-      "general": [
-        {"code": 10002, "message": "debug turned off for account"}
-      ]
+    "ortb2": {
+      "ext": {
+        "warnings": {
+          "general": [
+            {
+              "code": 10002,
+              "message": "debug turned off for account"
+            }
+          ]
+        }
+      }
     }
   },
   "expectedReturnCode": 200
diff --git a/endpoints/openrtb2/sample-requests/amp/consent-through-query/gdpr-ccpa-through-query.json b/endpoints/openrtb2/sample-requests/amp/consent-through-query/gdpr-ccpa-through-query.json
index d420d19a..cbcd5ed7 100644
--- a/endpoints/openrtb2/sample-requests/amp/consent-through-query/gdpr-ccpa-through-query.json
+++ b/endpoints/openrtb2/sample-requests/amp/consent-through-query/gdpr-ccpa-through-query.json
@@ -124,13 +124,17 @@
       "hb_pb": "15.00",
       "hb_pb_appnexus": "15.00"
     },
-    "warnings": {
-      "general": [
-        {
-          "code": 10002,
-          "message": "debug turned off for account"
+    "ortb2": {
+      "ext": {
+        "warnings": {
+          "general": [
+            {
+              "code": 10002,
+              "message": "debug turned off for account"
+            }
+          ]
         }
-      ]
+      }
     }
   },
   "expectedReturnCode": 200
diff --git a/endpoints/openrtb2/sample-requests/amp/consent-through-query/gdpr-legacy-tcf2-consent-through-query.json b/endpoints/openrtb2/sample-requests/amp/consent-through-query/gdpr-legacy-tcf2-consent-through-query.json
index 2ff32e59..3a3baca9 100644
--- a/endpoints/openrtb2/sample-requests/amp/consent-through-query/gdpr-legacy-tcf2-consent-through-query.json
+++ b/endpoints/openrtb2/sample-requests/amp/consent-through-query/gdpr-legacy-tcf2-consent-through-query.json
@@ -129,13 +129,17 @@
       "hb_pb": "15.00",
       "hb_pb_appnexus": "15.00"
     },
-    "warnings": {
-      "general": [
-        {
-          "code": 10002,
-          "message": "debug turned off for account"
+    "ortb2": {
+      "ext": {
+        "warnings": {
+          "general": [
+            {
+              "code": 10002,
+              "message": "debug turned off for account"
+            }
+          ]
         }
-      ]
+      }
     }
   },
   "expectedReturnCode": 200
diff --git a/endpoints/openrtb2/sample-requests/amp/consent-through-query/gdpr-tcf1-consent-through-query.json b/endpoints/openrtb2/sample-requests/amp/consent-through-query/gdpr-tcf1-consent-through-query.json
index 2d2d8669..c81dff7a 100644
--- a/endpoints/openrtb2/sample-requests/amp/consent-through-query/gdpr-tcf1-consent-through-query.json
+++ b/endpoints/openrtb2/sample-requests/amp/consent-through-query/gdpr-tcf1-consent-through-query.json
@@ -129,13 +129,17 @@
       "hb_pb": "15.00",
       "hb_pb_appnexus": "15.00"
     },
-    "warnings": {
-      "general": [
-        {
-          "code": 10002,
-          "message": "debug turned off for account"
+    "ortb2": {
+      "ext": {
+        "warnings": {
+          "general": [
+            {
+              "code": 10002,
+              "message": "debug turned off for account"
+            }
+          ]
         }
-      ]
+      }
     }
   },
   "expectedReturnCode": 200
diff --git a/endpoints/openrtb2/sample-requests/amp/consent-through-query/gdpr-tcf2-consent-through-query.json b/endpoints/openrtb2/sample-requests/amp/consent-through-query/gdpr-tcf2-consent-through-query.json
index b1731327..7d3d9931 100644
--- a/endpoints/openrtb2/sample-requests/amp/consent-through-query/gdpr-tcf2-consent-through-query.json
+++ b/endpoints/openrtb2/sample-requests/amp/consent-through-query/gdpr-tcf2-consent-through-query.json
@@ -129,13 +129,17 @@
       "hb_pb": "15.00",
       "hb_pb_appnexus": "15.00"
     },
-    "warnings": {
-      "general": [
-        {
-          "code": 10002,
-          "message": "debug turned off for account"
+    "ortb2": {
+      "ext": {
+        "warnings": {
+          "general": [
+            {
+              "code": 10002,
+              "message": "debug turned off for account"
+            }
+          ]
         }
-      ]
+      }
     }
   },
   "expectedReturnCode": 200
diff --git a/endpoints/openrtb2/sample-requests/amp/valid-supplementary/aliased-buyeruids.json b/endpoints/openrtb2/sample-requests/amp/valid-supplementary/aliased-buyeruids.json
index b74faf2d..3d6549fc 100644
--- a/endpoints/openrtb2/sample-requests/amp/valid-supplementary/aliased-buyeruids.json
+++ b/endpoints/openrtb2/sample-requests/amp/valid-supplementary/aliased-buyeruids.json
@@ -67,13 +67,17 @@
       "hb_pb": "2.00",
       "hb_pb_appnexus": "2.00"
     },
-    "warnings": {
-      "general": [
-        {
-          "code": 10002,
-          "message": "debug turned off for account"
+    "ortb2": {
+      "ext": {
+        "warnings": {
+          "general": [
+            {
+              "code": 10002,
+              "message": "debug turned off for account"
+            }
+          ]
         }
-      ]
+      }
     }
   },
   "expectedReturnCode": 200
diff --git a/endpoints/openrtb2/sample-requests/amp/valid-supplementary/aliases.json b/endpoints/openrtb2/sample-requests/amp/valid-supplementary/aliases.json
index 2ada07a1..aebb320a 100644
--- a/endpoints/openrtb2/sample-requests/amp/valid-supplementary/aliases.json
+++ b/endpoints/openrtb2/sample-requests/amp/valid-supplementary/aliases.json
@@ -58,13 +58,17 @@
       "hb_pb": "2.00",
       "hb_pb_unknown": "2.00"
     },
-    "warnings": {
-      "general": [
-        {
-          "code": 10002,
-          "message": "debug turned off for account"
+    "ortb2": {
+      "ext": {
+        "warnings": {
+          "general": [
+            {
+              "code": 10002,
+              "message": "debug turned off for account"
+            }
+          ]
         }
-      ]
+      }
     }
   },
   "expectedReturnCode": 200
diff --git a/endpoints/openrtb2/sample-requests/amp/valid-supplementary/gdpr-no-consentstring.json b/endpoints/openrtb2/sample-requests/amp/valid-supplementary/gdpr-no-consentstring.json
index 833dd6e7..dd961cf2 100644
--- a/endpoints/openrtb2/sample-requests/amp/valid-supplementary/gdpr-no-consentstring.json
+++ b/endpoints/openrtb2/sample-requests/amp/valid-supplementary/gdpr-no-consentstring.json
@@ -56,13 +56,17 @@
       "hb_pb": "3.00",
       "hb_pb_appnexus": "3.00"
     },
-    "warnings": {
-      "general": [
-        {
-          "code": 10002,
-          "message": "debug turned off for account"
+    "ortb2": {
+      "ext": {
+        "warnings": {
+          "general": [
+            {
+              "code": 10002,
+              "message": "debug turned off for account"
+            }
+          ]
         }
-      ]
+      }
     }
   },
   "expectedReturnCode": 200
diff --git a/endpoints/openrtb2/sample-requests/amp/valid-supplementary/gdpr.json b/endpoints/openrtb2/sample-requests/amp/valid-supplementary/gdpr.json
index f692e2bd..137ad150 100644
--- a/endpoints/openrtb2/sample-requests/amp/valid-supplementary/gdpr.json
+++ b/endpoints/openrtb2/sample-requests/amp/valid-supplementary/gdpr.json
@@ -61,13 +61,17 @@
       "hb_pb": "15.00",
       "hb_pb_appnexus": "15.00"
     },
-    "warnings": {
-      "general": [
-        {
-          "code": 10002,
-          "message": "debug turned off for account"
+    "ortb2": {
+      "ext": {
+        "warnings": {
+          "general": [
+            {
+              "code": 10002,
+              "message": "debug turned off for account"
+            }
+          ]
         }
-      ]
+      }
     }
   },
   "expectedReturnCode": 200
diff --git a/endpoints/openrtb2/sample-requests/amp/valid-supplementary/imp-with-stored-resp.json b/endpoints/openrtb2/sample-requests/amp/valid-supplementary/imp-with-stored-resp.json
index eadabcde..80ba523b 100644
--- a/endpoints/openrtb2/sample-requests/amp/valid-supplementary/imp-with-stored-resp.json
+++ b/endpoints/openrtb2/sample-requests/amp/valid-supplementary/imp-with-stored-resp.json
@@ -48,13 +48,17 @@
       "hb_pb": "5.00",
       "hb_pb_appnexus": "5.00"
     },
-    "warnings": {
-      "general": [
-        {
-          "code": 10002,
-          "message": "debug turned off for account"
+    "ortb2": {
+      "ext": {
+        "warnings": {
+          "general": [
+            {
+              "code": 10002,
+              "message": "debug turned off for account"
+            }
+          ]
         }
-      ]
+      }
     }
   },
   "expectedReturnCode": 200
diff --git a/endpoints/openrtb2/sample-requests/hooks/amp.json b/endpoints/openrtb2/sample-requests/hooks/amp.json
new file mode 100644
index 00000000..a865f293
--- /dev/null
+++ b/endpoints/openrtb2/sample-requests/hooks/amp.json
@@ -0,0 +1,146 @@
+{
+  "description": "Amp request",
+  "query": "tag_id=101&trace=verbose",
+  "config": {
+    "mockBidders": [
+      {"bidderName": "appnexus", "currency": "USD", "price": 2}
+    ]
+  },
+  "mockBidRequest": {
+    "id": "my-req-id",
+    "site": {
+      "page": "test.somepage.com"
+    },
+    "imp": [
+      {
+        "id": "my-imp-id",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 600
+            }
+          ]
+        },
+        "ext": {
+          "prebid": {
+            "bidder": {
+              "appnexus": {
+                "placementId": 12883451
+              }
+            }
+          }
+        }
+      }
+    ],
+    "ext": {
+      "prebid": {
+        "debug": true,
+        "aliases": {
+          "unknown": "appnexus"
+        }
+      }
+    }
+  },
+  "expectedAmpResponse": {
+    "targeting": {
+      "hb_bidder": "appnexus",
+      "hb_bidder_appnexus": "appnexus",
+      "hb_cache_host": "www.pbcserver.com",
+      "hb_cache_host_appnex": "www.pbcserver.com",
+      "hb_cache_id": "0",
+      "hb_cache_id_appnexus": "0",
+      "hb_cache_path": "/pbcache/endpoint",
+      "hb_cache_path_appnex": "/pbcache/endpoint",
+      "hb_pb": "2.00",
+      "hb_pb_appnexus": "2.00"
+    },
+    "ortb2": {
+      "ext": {
+        "prebid": {
+          "modules": {
+            "errors": {
+              "foobar": {
+                "foo": [
+                  "error 1"
+                ],
+                "bar": [
+                  "error 1"
+                ]
+              }
+            },
+            "warnings": {
+              "foobar": {
+                "bar": [
+                  "warning 1"
+                ],
+                "baz": [
+                  "Endpoint /openrtb2/amp is not supported by hook."
+                ]
+              }
+            },
+            "trace": {
+              "stages": [
+                {
+                  "stage": "entrypoint",
+                  "outcomes": [
+                    {
+                      "entity": "http-request",
+                      "groups": [
+                        {
+                          "invocation_results": [
+                            {
+                              "analytics_tags": {},
+                              "hook_id": {
+                                "module_code": "foobar",
+                                "hook_impl_code": "foo"
+                              },
+                              "status": "success",
+                              "action": "update",
+                              "message": "",
+                              "debug_messages": [
+                                "Hook mutation successfully applied, affected key: header.foo, mutation type: update"
+                              ]
+                            },
+                            {
+                              "analytics_tags": {},
+                              "hook_id": {
+                                "module_code": "foobar",
+                                "hook_impl_code": "bar"
+                              },
+                              "status": "success",
+                              "action": "update",
+                              "message": "",
+                              "debug_messages": [
+                                "Hook mutation successfully applied, affected key: param.foo, mutation type: update"
+                              ]
+                            }
+                          ]
+                        },
+                        {
+                          "invocation_results": [
+                            {
+                              "analytics_tags": {},
+                              "hook_id": {
+                                "module_code": "foobar",
+                                "hook_impl_code": "baz"
+                              },
+                              "status": "success",
+                              "action": "no_action",
+                              "message": ""
+                            }
+                          ]
+                        }
+                      ]
+                    }
+                  ]
+                }
+              ]
+            }
+          }
+        }
+      }
+    }
+  },
+  "expectedReturnCode": 200
+}
\ No newline at end of file
diff --git a/endpoints/openrtb2/sample-requests/hooks/amp_bidder_reject.json b/endpoints/openrtb2/sample-requests/hooks/amp_bidder_reject.json
new file mode 100644
index 00000000..c3ce226f
--- /dev/null
+++ b/endpoints/openrtb2/sample-requests/hooks/amp_bidder_reject.json
@@ -0,0 +1,135 @@
+{
+  "description": "Amp request",
+  "query": "tag_id=101&trace=verbose",
+  "config": {
+    "mockBidders": [
+      {"bidderName": "appnexus", "currency": "USD", "price": 2},
+      {"bidderName": "applogy", "currency": "USD", "price": 1}
+    ]
+  },
+  "mockBidRequest": {
+    "id": "my-req-id",
+    "site": {
+      "page": "test.somepage.com"
+    },
+    "imp": [
+      {
+        "id": "my-imp-id",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 600
+            }
+          ]
+        },
+        "ext": {
+          "prebid": {
+            "bidder": {
+              "appnexus": {
+                "placementId": 12883451
+              },
+              "applogy": {
+                "placementId": 12883451
+              }
+            }
+          }
+        }
+      }
+    ],
+    "ext": {
+      "prebid": {
+        "debug": true,
+        "aliases": {
+          "unknown": "appnexus",
+          "foo": "applogy"
+        }
+      }
+    }
+  },
+  "expectedAmpResponse": {
+    "targeting": {
+      "hb_bidder": "applogy",
+      "hb_bidder_applogy": "applogy",
+      "hb_cache_host": "www.pbcserver.com",
+      "hb_cache_host_applog": "www.pbcserver.com",
+      "hb_cache_id": "0",
+      "hb_cache_id_applogy": "0",
+      "hb_cache_path": "/pbcache/endpoint",
+      "hb_cache_path_applog": "/pbcache/endpoint",
+      "hb_pb": "1.00",
+      "hb_pb_applogy": "1.00"
+    },
+    "ortb2": {
+      "ext": {
+        "warnings": {
+          "appnexus": [
+            {
+              "code": 11,
+              "message": "Module foobar (hook: foo) rejected request with code 123 at bidder_request stage"
+            }
+          ]
+        },
+        "prebid": {
+          "modules": {
+            "errors": {
+              "foobar": {
+                "foo": [
+                  "Module foobar (hook: foo) rejected request with code 123 at bidder_request stage"
+                ]
+              }
+            },
+            "trace": {
+              "stages": [
+                {
+                  "outcomes": [
+                    {
+                      "entity": "appnexus",
+                      "groups": [
+                        {
+                          "invocation_results": [
+                            {
+                              "action": "reject",
+                              "analytics_tags": {},
+                              "hook_id": {
+                                "hook_impl_code": "foo",
+                                "module_code": "foobar"
+                              },
+                              "message": "",
+                              "status": "success"
+                            }
+                          ]
+                        }
+                      ]
+                    },
+                    {
+                      "entity": "applogy",
+                      "groups": [
+                        {
+                          "invocation_results": [
+                            {
+                              "action": "no_action",
+                              "analytics_tags": {},
+                              "hook_id": {
+                                "hook_impl_code": "foo",
+                                "module_code": "foobar"
+                              },
+                              "message": "",
+                              "status": "success"
+                            }
+                          ]
+                        }
+                      ]
+                    }
+                  ],
+                  "stage": "bidder_request"
+                }
+              ]
+            }
+          }
+        }
+      }
+    }
+  },
+  "expectedReturnCode": 200
+}
\ No newline at end of file
diff --git a/endpoints/openrtb2/sample-requests/hooks/amp_bidder_response_reject.json b/endpoints/openrtb2/sample-requests/hooks/amp_bidder_response_reject.json
new file mode 100644
index 00000000..fe654304
--- /dev/null
+++ b/endpoints/openrtb2/sample-requests/hooks/amp_bidder_response_reject.json
@@ -0,0 +1,135 @@
+{
+  "description": "Amp request",
+  "query": "tag_id=101&trace=verbose",
+  "config": {
+    "mockBidders": [
+      {"bidderName": "appnexus", "currency": "USD", "price": 2},
+      {"bidderName": "applogy", "currency": "USD", "price": 1}
+    ]
+  },
+  "mockBidRequest": {
+    "id": "my-req-id",
+    "site": {
+      "page": "test.somepage.com"
+    },
+    "imp": [
+      {
+        "id": "my-imp-id",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 600
+            }
+          ]
+        },
+        "ext": {
+          "prebid": {
+            "bidder": {
+              "appnexus": {
+                "placementId": 12883451
+              },
+              "applogy": {
+                "placementId": 12883451
+              }
+            }
+          }
+        }
+      }
+    ],
+    "ext": {
+      "prebid": {
+        "debug": true,
+        "aliases": {
+          "unknown": "appnexus",
+          "foo": "applogy"
+        }
+      }
+    }
+  },
+  "expectedAmpResponse": {
+    "targeting": {
+      "hb_bidder": "applogy",
+      "hb_bidder_applogy": "applogy",
+      "hb_cache_host": "www.pbcserver.com",
+      "hb_cache_host_applog": "www.pbcserver.com",
+      "hb_cache_id": "0",
+      "hb_cache_id_applogy": "0",
+      "hb_cache_path": "/pbcache/endpoint",
+      "hb_cache_path_applog": "/pbcache/endpoint",
+      "hb_pb": "1.00",
+      "hb_pb_applogy": "1.00"
+    },
+    "ortb2": {
+      "ext": {
+        "warnings": {
+          "appnexus": [
+            {
+              "code": 11,
+              "message": "Module foobar (hook: foo) rejected request with code 123 at raw_bidder_response stage"
+            }
+          ]
+        },
+        "prebid": {
+          "modules": {
+            "errors": {
+              "foobar": {
+                "foo": [
+                  "Module foobar (hook: foo) rejected request with code 123 at raw_bidder_response stage"
+                ]
+              }
+            },
+            "trace": {
+              "stages": [
+                {
+                  "outcomes": [
+                    {
+                      "entity": "appnexus",
+                      "groups": [
+                        {
+                          "invocation_results": [
+                            {
+                              "action": "reject",
+                              "analytics_tags": {},
+                              "hook_id": {
+                                "hook_impl_code": "foo",
+                                "module_code": "foobar"
+                              },
+                              "message": "",
+                              "status": "success"
+                            }
+                          ]
+                        }
+                      ]
+                    },
+                    {
+                      "entity": "applogy",
+                      "groups": [
+                        {
+                          "invocation_results": [
+                            {
+                              "action": "no_action",
+                              "analytics_tags": {},
+                              "hook_id": {
+                                "hook_impl_code": "foo",
+                                "module_code": "foobar"
+                              },
+                              "message": "",
+                              "status": "success"
+                            }
+                          ]
+                        }
+                      ]
+                    }
+                  ],
+                  "stage": "raw_bidder_response"
+                }
+              ]
+            }
+          }
+        }
+      }
+    }
+  },
+  "expectedReturnCode": 200
+}
\ No newline at end of file
diff --git a/endpoints/openrtb2/sample-requests/hooks/amp_entrypoint_reject.json b/endpoints/openrtb2/sample-requests/hooks/amp_entrypoint_reject.json
new file mode 100644
index 00000000..6960cd7b
--- /dev/null
+++ b/endpoints/openrtb2/sample-requests/hooks/amp_entrypoint_reject.json
@@ -0,0 +1,85 @@
+{
+  "description": "Amp request rejected",
+  "query": "tag_id=101",
+  "config": {
+    "mockBidders": [
+      {"bidderName": "appnexus", "currency": "USD", "price": 2}
+    ]
+  },
+  "mockBidRequest": {
+    "id": "my-req-id",
+    "site": {
+      "page": "test.somepage.com"
+    },
+    "imp": [
+      {
+        "id": "my-imp-id",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 600
+            }
+          ]
+        },
+        "ext": {
+          "prebid": {
+            "bidder": {
+              "appnexus": {
+                "placementId": 12883451
+              }
+            }
+          }
+        }
+      }
+    ],
+    "ext": {
+      "prebid": {
+        "trace": "verbose",
+        "aliases": {
+          "unknown": "appnexus"
+        }
+      }
+    }
+  },
+  "expectedAmpResponse": {
+    "targeting": {},
+    "ortb2": {
+      "ext": {
+        "prebid": {
+          "modules": {
+            "trace": {
+              "stages": [
+                {
+                  "stage": "entrypoint",
+                  "outcomes": [
+                    {
+                      "entity": "http-request",
+                      "groups": [
+                        {
+                          "invocation_results": [
+                            {
+                              "analytics_tags": {},
+                              "hook_id": {
+                                "module_code": "foobar",
+                                "hook_impl_code": "foo"
+                              },
+                              "status": "success",
+                              "action": "reject",
+                              "message": ""
+                            }
+                          ]
+                        }
+                      ]
+                    }
+                  ]
+                }
+              ]
+            }
+          }
+        }
+      }
+    }
+  },
+  "expectedReturnCode": 200
+}
\ No newline at end of file
diff --git a/endpoints/openrtb2/sample-requests/hooks/amp_processed_auction_request_reject.json b/endpoints/openrtb2/sample-requests/hooks/amp_processed_auction_request_reject.json
new file mode 100644
index 00000000..3e78f02e
--- /dev/null
+++ b/endpoints/openrtb2/sample-requests/hooks/amp_processed_auction_request_reject.json
@@ -0,0 +1,85 @@
+{
+  "description": "Amp request rejected",
+  "query": "tag_id=101",
+  "config": {
+    "mockBidders": [
+      {"bidderName": "appnexus", "currency": "USD", "price": 2}
+    ]
+  },
+  "mockBidRequest": {
+    "id": "my-req-id",
+    "site": {
+      "page": "test.somepage.com"
+    },
+    "imp": [
+      {
+        "id": "my-imp-id",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 600
+            }
+          ]
+        },
+        "ext": {
+          "prebid": {
+            "bidder": {
+              "appnexus": {
+                "placementId": 12883451
+              }
+            }
+          }
+        }
+      }
+    ],
+    "ext": {
+      "prebid": {
+        "trace": "verbose",
+        "aliases": {
+          "unknown": "appnexus"
+        }
+      }
+    }
+  },
+  "expectedAmpResponse": {
+    "targeting": {},
+    "ortb2": {
+      "ext": {
+        "prebid": {
+          "modules": {
+            "trace": {
+              "stages": [
+                {
+                  "stage": "processed_auction_request",
+                  "outcomes": [
+                    {
+                      "entity": "auction-request",
+                      "groups": [
+                        {
+                          "invocation_results": [
+                            {
+                              "analytics_tags": {},
+                              "hook_id": {
+                                "module_code": "foobar",
+                                "hook_impl_code": "foo"
+                              },
+                              "status": "success",
+                              "action": "reject",
+                              "message": ""
+                            }
+                          ]
+                        }
+                      ]
+                    }
+                  ]
+                }
+              ]
+            }
+          }
+        }
+      }
+    }
+  },
+  "expectedReturnCode": 200
+}
\ No newline at end of file
diff --git a/endpoints/openrtb2/sample-requests/hooks/auction.json b/endpoints/openrtb2/sample-requests/hooks/auction.json
new file mode 100644
index 00000000..a2dc92c2
--- /dev/null
+++ b/endpoints/openrtb2/sample-requests/hooks/auction.json
@@ -0,0 +1,168 @@
+{
+  "description": "Auction request",
+  "config": {
+    "mockBidders": [
+      {"bidderName": "appnexus", "currency": "USD", "price": 0.00}
+    ]
+  },
+  "mockBidRequest": {
+    "id": "some-request-id",
+    "site": {
+      "page": "prebid.org"
+    },
+    "imp": [
+      {
+        "id": "some-impression-id",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 250
+            }
+          ]
+        },
+        "ext": {
+          "appnexus": {
+            "placementId": 12883451
+          }
+        }
+      }
+    ],
+    "tmax": 500,
+    "test": 1,
+    "ext": {
+      "prebid": {
+        "trace": "verbose"
+      }
+    }
+  },
+  "expectedBidResponse": {
+    "id": "some-request-id",
+    "bidid": "test bid id",
+    "cur":"USD",
+    "nbr":null,
+    "seatbid": [
+      {
+        "bid": [
+          {
+            "id": "appnexus-bid",
+            "impid": "some-impression-id",
+            "price": 0,
+            "ext": {"prebid":{"type":"banner"}}
+          }
+        ],
+        "seat": "appnexus"
+      }
+    ],
+    "ext": {
+      "prebid": {
+        "modules": {
+          "errors": {
+            "foobar": {
+              "foo": [
+                "error 1"
+              ],
+              "bar": [
+                "error 1"
+              ]
+            }
+          },
+          "warnings": {
+            "foobar": {
+              "bar": [
+                "warning 1"
+              ]
+            }
+          },
+          "trace": {
+            "stages": [
+              {
+                "stage": "entrypoint",
+                "outcomes": [
+                  {
+                    "entity": "http-request",
+                    "groups": [
+                      {
+                        "invocation_results": [
+                          {
+                            "analytics_tags": {},
+                            "hook_id": {
+                              "module_code": "foobar",
+                              "hook_impl_code": "foo"
+                            },
+                            "status": "success",
+                            "action": "update",
+                            "message": "",
+                            "debug_messages": [
+                              "Hook mutation successfully applied, affected key: header.foo, mutation type: update"
+                            ]
+                          },
+                          {
+                            "analytics_tags": {},
+                            "hook_id": {
+                              "module_code": "foobar",
+                              "hook_impl_code": "bar"
+                            },
+                            "status": "success",
+                            "action": "update",
+                            "message": "",
+                            "debug_messages": [
+                              "Hook mutation successfully applied, affected key: param.foo, mutation type: update"
+                            ]
+                          }
+                        ]
+                      },
+                      {
+                        "invocation_results": [
+                          {
+                            "analytics_tags": {},
+                            "hook_id": {
+                              "module_code": "foobar",
+                              "hook_impl_code": "baz"
+                            },
+                            "status": "success",
+                            "action": "update",
+                            "message": "",
+                            "debug_messages": [
+                              "Hook mutation successfully applied, affected key: body.tmax, mutation type: update",
+                              "Hook mutation successfully applied, affected key: body.regs.ext.us_privacy, mutation type: add"
+                            ]
+                          }
+                        ]
+                      }
+                    ]
+                  }
+                ]
+              },
+              {
+                "stage": "raw_auction_request",
+                "outcomes": [
+                  {
+                    "entity": "auction-request",
+                    "groups": [
+                      {
+                        "invocation_results": [
+                          {
+                            "analytics_tags": {},
+                            "hook_id": {
+                              "module_code": "vendor.module",
+                              "hook_impl_code": "foobar"
+                            },
+                            "status": "success",
+                            "action": "no_action",
+                            "message": ""
+                          }
+                        ]
+                      }
+                    ]
+                  }
+                ]
+              }
+            ]
+          }
+        }
+      }
+    }
+  },
+  "expectedReturnCode": 200
+}
diff --git a/endpoints/openrtb2/sample-requests/hooks/auction_bidder_reject.json b/endpoints/openrtb2/sample-requests/hooks/auction_bidder_reject.json
new file mode 100644
index 00000000..dd209763
--- /dev/null
+++ b/endpoints/openrtb2/sample-requests/hooks/auction_bidder_reject.json
@@ -0,0 +1,131 @@
+{
+  "description": "Auction request rejected at bidder-level stage",
+  "config": {
+    "mockBidders": [
+      {"bidderName": "appnexus", "currency": "USD", "price": 0.00},
+      {"bidderName": "applogy", "currency": "USD", "price": 0.00}
+    ]
+  },
+  "mockBidRequest": {
+    "id": "some-request-id",
+    "site": {
+      "page": "prebid.org"
+    },
+    "imp": [
+      {
+        "id": "some-impression-id",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 250
+            }
+          ]
+        },
+        "ext": {
+          "appnexus": {
+            "placementId": 12883451
+          },
+          "applogy": {
+            "placementId": 12883451
+          }
+        }
+      }
+    ],
+    "tmax": 50,
+    "test": 1,
+    "ext": {
+      "prebid": {
+        "trace": "verbose"
+      }
+    }
+  },
+  "expectedBidResponse": {
+    "id":"some-request-id",
+    "bidid":"test bid id",
+    "cur":"USD",
+    "nbr":null,
+    "seatbid": [
+      {
+        "bid": [
+          {
+            "id": "applogy-bid",
+            "impid": "some-impression-id",
+            "price": 0,
+            "ext": {"prebid":{"type":"banner"}}
+          }
+        ],
+        "seat": "applogy"
+      }
+    ],
+    "ext": {
+      "warnings": {
+        "appnexus": [
+          {
+            "code": 11,
+            "message": "Module foobar (hook: foo) rejected request with code 123 at bidder_request stage"
+          }
+        ]
+      },
+      "prebid": {
+        "modules": {
+          "errors": {
+            "foobar": {
+              "foo": [
+                "Module foobar (hook: foo) rejected request with code 123 at bidder_request stage"
+              ]
+            }
+          },
+          "trace": {
+            "stages": [
+              {
+                "outcomes": [
+                  {
+                    "entity": "appnexus",
+                    "groups": [
+                      {
+                        "invocation_results": [
+                          {
+                            "action": "reject",
+                            "analytics_tags": {},
+                            "hook_id": {
+                              "hook_impl_code": "foo",
+                              "module_code": "foobar"
+                            },
+                            "message": "",
+                            "status": "success"
+                          }
+                        ]
+                      }
+                    ]
+                  },
+                  {
+                    "entity": "applogy",
+                    "groups": [
+                      {
+                        "invocation_results": [
+                          {
+                            "action": "no_action",
+                            "analytics_tags": {},
+                            "hook_id": {
+                              "hook_impl_code": "foo",
+                              "module_code": "foobar"
+                            },
+                            "message": "",
+                            "status": "success"
+                          }
+                        ]
+                      }
+                    ]
+                  }
+                ],
+                "stage": "bidder_request"
+              }
+            ]
+          }
+        }
+      }
+    }
+  },
+  "expectedReturnCode": 200
+}
diff --git a/endpoints/openrtb2/sample-requests/hooks/auction_bidder_response_reject.json b/endpoints/openrtb2/sample-requests/hooks/auction_bidder_response_reject.json
new file mode 100644
index 00000000..ba5c75f0
--- /dev/null
+++ b/endpoints/openrtb2/sample-requests/hooks/auction_bidder_response_reject.json
@@ -0,0 +1,131 @@
+{
+  "description": "Auction request rejected at bidder-level stage",
+  "config": {
+    "mockBidders": [
+      {"bidderName": "appnexus", "currency": "USD", "price": 0.00},
+      {"bidderName": "applogy", "currency": "USD", "price": 0.00}
+    ]
+  },
+  "mockBidRequest": {
+    "id": "some-request-id",
+    "site": {
+      "page": "prebid.org"
+    },
+    "imp": [
+      {
+        "id": "some-impression-id",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 250
+            }
+          ]
+        },
+        "ext": {
+          "appnexus": {
+            "placementId": 12883451
+          },
+          "applogy": {
+            "placementId": 12883451
+          }
+        }
+      }
+    ],
+    "tmax": 50,
+    "test": 1,
+    "ext": {
+      "prebid": {
+        "trace": "verbose"
+      }
+    }
+  },
+  "expectedBidResponse": {
+    "id":"some-request-id",
+    "bidid":"test bid id",
+    "cur":"USD",
+    "nbr":null,
+    "seatbid": [
+      {
+        "bid": [
+          {
+            "id": "applogy-bid",
+            "impid": "some-impression-id",
+            "price": 0,
+            "ext": {"prebid":{"type":"banner"}}
+          }
+        ],
+        "seat": "applogy"
+      }
+    ],
+    "ext": {
+      "warnings": {
+        "appnexus": [
+          {
+            "code": 11,
+            "message": "Module foobar (hook: foo) rejected request with code 123 at raw_bidder_response stage"
+          }
+        ]
+      },
+      "prebid": {
+        "modules": {
+          "errors": {
+            "foobar": {
+              "foo": [
+                "Module foobar (hook: foo) rejected request with code 123 at raw_bidder_response stage"
+              ]
+            }
+          },
+          "trace": {
+            "stages": [
+              {
+                "outcomes": [
+                  {
+                    "entity": "appnexus",
+                    "groups": [
+                      {
+                        "invocation_results": [
+                          {
+                            "action": "reject",
+                            "analytics_tags": {},
+                            "hook_id": {
+                              "hook_impl_code": "foo",
+                              "module_code": "foobar"
+                            },
+                            "message": "",
+                            "status": "success"
+                          }
+                        ]
+                      }
+                    ]
+                  },
+                  {
+                    "entity": "applogy",
+                    "groups": [
+                      {
+                        "invocation_results": [
+                          {
+                            "action": "no_action",
+                            "analytics_tags": {},
+                            "hook_id": {
+                              "hook_impl_code": "foo",
+                              "module_code": "foobar"
+                            },
+                            "message": "",
+                            "status": "success"
+                          }
+                        ]
+                      }
+                    ]
+                  }
+                ],
+                "stage": "raw_bidder_response"
+              }
+            ]
+          }
+        }
+      }
+    }
+  },
+  "expectedReturnCode": 200
+}
diff --git a/endpoints/openrtb2/sample-requests/hooks/auction_entrypoint_reject.json b/endpoints/openrtb2/sample-requests/hooks/auction_entrypoint_reject.json
new file mode 100644
index 00000000..c67b8d4b
--- /dev/null
+++ b/endpoints/openrtb2/sample-requests/hooks/auction_entrypoint_reject.json
@@ -0,0 +1,77 @@
+{
+  "description": "Auction request rejected",
+  "config": {
+    "mockBidders": [
+      {"bidderName": "appnexus", "currency": "USD", "price": 0.00}
+    ]
+  },
+  "mockBidRequest": {
+    "id": "some-request-id",
+    "site": {
+      "page": "prebid.org"
+    },
+    "imp": [
+      {
+        "id": "some-impression-id",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 250
+            }
+          ]
+        },
+        "ext": {
+          "appnexus": {
+            "placementId": 12883451
+          }
+        }
+      }
+    ],
+    "tmax": 50,
+    "ext": {
+      "prebid": {
+        "trace": "verbose"
+      }
+    }
+  },
+  "expectedBidResponse": {
+    "id": "some-request-id",
+    "nbr": 123,
+    "ext": {
+      "prebid": {
+        "modules": {
+          "trace": {
+            "stages": [
+              {
+                "stage": "entrypoint",
+                "outcomes": [
+                  {
+                    "entity": "http-request",
+                    "groups": [
+                      {
+                        "invocation_results": [
+                          {
+                            "analytics_tags": {},
+                            "hook_id": {
+                              "module_code": "foobar",
+                              "hook_impl_code": "foo"
+                            },
+                            "status": "success",
+                            "action": "reject",
+                            "message": ""
+                          }
+                        ]
+                      }
+                    ]
+                  }
+                ]
+              }
+            ]
+          }
+        }
+      }
+    }
+  },
+  "expectedReturnCode": 200
+}
diff --git a/endpoints/openrtb2/sample-requests/hooks/auction_processed_auction_request_reject.json b/endpoints/openrtb2/sample-requests/hooks/auction_processed_auction_request_reject.json
new file mode 100644
index 00000000..eca136cf
--- /dev/null
+++ b/endpoints/openrtb2/sample-requests/hooks/auction_processed_auction_request_reject.json
@@ -0,0 +1,77 @@
+{
+  "description": "Auction request rejected",
+  "config": {
+    "mockBidders": [
+      {"bidderName": "appnexus", "currency": "USD", "price": 0.00}
+    ]
+  },
+  "mockBidRequest": {
+    "id": "some-request-id",
+    "site": {
+      "page": "prebid.org"
+    },
+    "imp": [
+      {
+        "id": "some-impression-id",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 250
+            }
+          ]
+        },
+        "ext": {
+          "appnexus": {
+            "placementId": 12883451
+          }
+        }
+      }
+    ],
+    "tmax": 50,
+    "ext": {
+      "prebid": {
+        "trace": "verbose"
+      }
+    }
+  },
+  "expectedBidResponse": {
+    "id": "some-request-id",
+    "nbr": 123,
+    "ext": {
+      "prebid": {
+        "modules": {
+          "trace": {
+            "stages": [
+              {
+                "stage": "processed_auction_request",
+                "outcomes": [
+                  {
+                    "entity": "auction-request",
+                    "groups": [
+                      {
+                        "invocation_results": [
+                          {
+                            "analytics_tags": {},
+                            "hook_id": {
+                              "module_code": "foobar",
+                              "hook_impl_code": "foo"
+                            },
+                            "status": "success",
+                            "action": "reject",
+                            "message": ""
+                          }
+                        ]
+                      }
+                    ]
+                  }
+                ]
+              }
+            ]
+          }
+        }
+      }
+    }
+  },
+  "expectedReturnCode": 200
+}
diff --git a/endpoints/openrtb2/sample-requests/hooks/auction_raw_auction_request_reject.json b/endpoints/openrtb2/sample-requests/hooks/auction_raw_auction_request_reject.json
new file mode 100644
index 00000000..dc192baf
--- /dev/null
+++ b/endpoints/openrtb2/sample-requests/hooks/auction_raw_auction_request_reject.json
@@ -0,0 +1,77 @@
+{
+  "description": "Auction request rejected",
+  "config": {
+    "mockBidders": [
+      {"bidderName": "appnexus", "currency": "USD", "price": 0.00}
+    ]
+  },
+  "mockBidRequest": {
+    "id": "some-request-id",
+    "site": {
+      "page": "prebid.org"
+    },
+    "imp": [
+      {
+        "id": "some-impression-id",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 250
+            }
+          ]
+        },
+        "ext": {
+          "appnexus": {
+            "placementId": 12883451
+          }
+        }
+      }
+    ],
+    "tmax": 50,
+    "ext": {
+      "prebid": {
+        "trace": "verbose"
+      }
+    }
+  },
+  "expectedBidResponse": {
+    "id": "some-request-id",
+    "nbr": 123,
+    "ext": {
+      "prebid": {
+        "modules": {
+          "trace": {
+            "stages": [
+              {
+                "stage": "raw_auction_request",
+                "outcomes": [
+                  {
+                    "entity": "auction-request",
+                    "groups": [
+                      {
+                        "invocation_results": [
+                          {
+                            "analytics_tags": {},
+                            "hook_id": {
+                              "module_code": "foobar",
+                              "hook_impl_code": "foo"
+                            },
+                            "status": "success",
+                            "action": "reject",
+                            "message": ""
+                          }
+                        ]
+                      }
+                    ]
+                  }
+                ]
+              }
+            ]
+          }
+        }
+      }
+    }
+  },
+  "expectedReturnCode": 200
+}
diff --git a/endpoints/openrtb2/sample-requests/valid-whole/hooks/auction_bidder_reject.json b/endpoints/openrtb2/sample-requests/valid-whole/hooks/auction_bidder_reject.json
deleted file mode 100644
index 29d333ca..00000000
--- a/endpoints/openrtb2/sample-requests/valid-whole/hooks/auction_bidder_reject.json
+++ /dev/null
@@ -1,78 +0,0 @@
-{
-  "description": "Simple request",
-  "config": {
-    "mockBidders": [
-      {"bidderName": "appnexus", "currency": "USD", "price": 0.00},
-      {"bidderName": "applogy", "currency": "USD", "price": 0.00}
-    ]
-  },
-  "mockBidRequest": {
-    "id": "some-request-id",
-    "site": {
-      "page": "prebid.org"
-    },
-    "imp": [
-      {
-        "id": "some-impression-id",
-        "banner": {
-          "format": [
-            {
-              "w": 300,
-              "h": 250
-            },
-            {
-              "w": 300,
-              "h": 600
-            }
-          ]
-        },
-        "ext": {
-          "appnexus": {
-            "placementId": 12883451
-          },
-          "applogy": {
-            "placementId": 12883451
-          }
-        }
-      }
-    ],
-    "tmax": 50,
-    "ext": {}
-  },
-  "expectedBidResponse": {
-    "id":"some-request-id",
-    "bidid":"test bid id",
-    "cur":"USD",
-    "nbr":null,
-    "seatbid": [
-      {
-        "bid": [
-          {
-            "id": "applogy-bid",
-            "impid": "some-impression-id",
-            "price": 0,
-            "ext": {"prebid":{"type":"banner"}}
-          }
-        ],
-        "seat": "applogy"
-      }
-    ],
-    "ext": {
-      "warnings": {
-        "appnexus": [
-          {
-            "code": 11,
-            "message": "Module foobar (hook: foo) rejected request with code 123 at bidder_request stage"
-          }
-        ],
-        "general": [
-          {
-            "code": 10002,
-            "message": "debug turned off for account"
-          }
-        ]
-      }
-    }
-  },
-  "expectedReturnCode": 200
-}
diff --git a/endpoints/openrtb2/sample-requests/valid-whole/hooks/auction_bidder_response_reject.json b/endpoints/openrtb2/sample-requests/valid-whole/hooks/auction_bidder_response_reject.json
deleted file mode 100644
index 4ea7171c..00000000
--- a/endpoints/openrtb2/sample-requests/valid-whole/hooks/auction_bidder_response_reject.json
+++ /dev/null
@@ -1,78 +0,0 @@
-{
-  "description": "Simple request",
-  "config": {
-    "mockBidders": [
-      {"bidderName": "appnexus", "currency": "USD", "price": 0.00},
-      {"bidderName": "applogy", "currency": "USD", "price": 0.00}
-    ]
-  },
-  "mockBidRequest": {
-    "id": "some-request-id",
-    "site": {
-      "page": "prebid.org"
-    },
-    "imp": [
-      {
-        "id": "some-impression-id",
-        "banner": {
-          "format": [
-            {
-              "w": 300,
-              "h": 250
-            },
-            {
-              "w": 300,
-              "h": 600
-            }
-          ]
-        },
-        "ext": {
-          "appnexus": {
-            "placementId": 12883451
-          },
-          "applogy": {
-            "placementId": 12883451
-          }
-        }
-      }
-    ],
-    "tmax": 50,
-    "ext": {}
-  },
-  "expectedBidResponse": {
-    "id":"some-request-id",
-    "bidid":"test bid id",
-    "cur":"USD",
-    "nbr":null,
-    "seatbid": [
-      {
-        "bid": [
-          {
-            "id": "applogy-bid",
-            "impid": "some-impression-id",
-            "price": 0,
-            "ext": {"prebid":{"type":"banner"}}
-          }
-        ],
-        "seat": "applogy"
-      }
-    ],
-    "ext": {
-      "warnings": {
-        "appnexus": [
-          {
-            "code": 11,
-            "message": "Module foobar (hook: foo) rejected request with code 123 at raw_bidder_response stage"
-          }
-        ],
-        "general": [
-          {
-            "code": 10002,
-            "message": "debug turned off for account"
-          }
-        ]
-      }
-    }
-  },
-  "expectedReturnCode": 200
-}
diff --git a/endpoints/openrtb2/sample-requests/valid-whole/hooks/auction_reject.json b/endpoints/openrtb2/sample-requests/valid-whole/hooks/auction_reject.json
deleted file mode 100644
index 9b77ce56..00000000
--- a/endpoints/openrtb2/sample-requests/valid-whole/hooks/auction_reject.json
+++ /dev/null
@@ -1,43 +0,0 @@
-{
-  "description": "Simple request",
-  "config": {
-    "mockBidders": [
-      {"bidderName": "appnexus", "currency": "USD", "price": 0.00}
-    ]
-  },
-  "mockBidRequest": {
-    "id": "some-request-id",
-    "site": {
-      "page": "prebid.org"
-    },
-    "imp": [
-      {
-        "id": "some-impression-id",
-        "banner": {
-          "format": [
-            {
-              "w": 300,
-              "h": 250
-            },
-            {
-              "w": 300,
-              "h": 600
-            }
-          ]
-        },
-        "ext": {
-          "appnexus": {
-            "placementId": 12883451
-          }
-        }
-      }
-    ],
-    "tmax": 50,
-    "ext": {}
-  },
-  "expectedBidResponse": {
-    "id": "some-request-id",
-    "nbr": 123
-  },
-  "expectedReturnCode": 200
-}
diff --git a/hooks/hookanalytics/analytics.go b/hooks/hookanalytics/analytics.go
index f55141fb..7907e57d 100644
--- a/hooks/hookanalytics/analytics.go
+++ b/hooks/hookanalytics/analytics.go
@@ -5,7 +5,7 @@
 package hookanalytics
 
 type Analytics struct {
-	Activities []Activity `json:"activities"`
+	Activities []Activity `json:"activities,omitempty"`
 }
 
 type Activity struct {
diff --git a/hooks/hookexecution/enricher.go b/hooks/hookexecution/enricher.go
new file mode 100644
index 00000000..cbefe201
--- /dev/null
+++ b/hooks/hookexecution/enricher.go
@@ -0,0 +1,204 @@
+package hookexecution
+
+import (
+	"encoding/json"
+
+	"github.com/buger/jsonparser"
+	"github.com/prebid/openrtb/v17/openrtb2"
+	"github.com/prebid/prebid-server/config"
+	"github.com/prebid/prebid-server/hooks/hookanalytics"
+	jsonpatch "gopkg.in/evanphx/json-patch.v4"
+)
+
+const (
+	// traceLevelBasic excludes debug_messages and analytic_tags from output
+	traceLevelBasic trace = "basic"
+	// traceLevelVerbose sets maximum level of output information
+	traceLevelVerbose trace = "verbose"
+)
+
+// Trace controls the level of detail in the output information returned from executing hooks.
+type trace string
+
+func (t trace) isBasicOrHigher() bool {
+	return t == traceLevelBasic || t.isVerbose()
+}
+
+func (t trace) isVerbose() bool {
+	return t == traceLevelVerbose
+}
+
+type extPrebid struct {
+	Prebid extModules `json:"prebid"`
+}
+
+type extModules struct {
+	Modules json.RawMessage `json:"modules"`
+}
+
+// EnrichExtBidResponse adds debug and trace information returned from executing hooks to the ext argument.
+// In response the outcome is visible under the key response.ext.prebid.modules.
+//
+// Debug information is added only if the debug mode is enabled by request and allowed by account (if provided).
+// The details of the trace output depends on the value in the bidRequest.ext.prebid.trace field.
+// Warnings returned if bidRequest contains unexpected types for debug fields controlling debug output.
+func EnrichExtBidResponse(
+	ext json.RawMessage,
+	stageOutcomes []StageOutcome,
+	bidRequest *openrtb2.BidRequest,
+	account *config.Account,
+) (json.RawMessage, []error, error) {
+	modules, warnings, err := GetModulesJSON(stageOutcomes, bidRequest, account)
+	if err != nil || modules == nil {
+		return ext, warnings, err
+	}
+
+	response, err := json.Marshal(extPrebid{Prebid: extModules{Modules: modules}})
+	if err != nil {
+		return ext, warnings, err
+	}
+
+	if ext != nil {
+		response, err = jsonpatch.MergePatch(ext, response)
+	}
+
+	return response, warnings, err
+}
+
+// GetModulesJSON returns debug and trace information produced from executing hooks.
+// Debug information is returned only if the debug mode is enabled by request and allowed by account (if provided).
+// The details of the trace output depends on the value in the bidRequest.ext.prebid.trace field.
+// Warnings returned if bidRequest contains unexpected types for debug fields controlling debug output.
+func GetModulesJSON(
+	stageOutcomes []StageOutcome,
+	bidRequest *openrtb2.BidRequest,
+	account *config.Account,
+) (json.RawMessage, []error, error) {
+	if len(stageOutcomes) == 0 {
+		return nil, nil, nil
+	}
+
+	trace, isDebugEnabled, warnings := getDebugContext(bidRequest, account)
+	modulesOutcome := getModulesOutcome(stageOutcomes, trace, isDebugEnabled)
+	if modulesOutcome == nil {
+		return nil, warnings, nil
+	}
+
+	data, err := json.Marshal(modulesOutcome)
+
+	return data, warnings, err
+}
+
+func getDebugContext(bidRequest *openrtb2.BidRequest, account *config.Account) (trace, bool, []error) {
+	var traceLevel string
+	var isDebugEnabled bool
+	var warnings []error
+	var err error
+
+	if bidRequest != nil {
+		traceLevel, err = jsonparser.GetString(bidRequest.Ext, "prebid", "trace")
+		if err != nil && err != jsonparser.KeyPathNotFoundError {
+			warnings = append(warnings, err)
+		}
+
+		isDebug, err := jsonparser.GetBoolean(bidRequest.Ext, "prebid", "debug")
+		if err != nil && err != jsonparser.KeyPathNotFoundError {
+			warnings = append(warnings, err)
+		}
+
+		isDebugEnabled = bidRequest.Test == 1 || isDebug
+		if account != nil {
+			isDebugEnabled = isDebugEnabled && account.DebugAllow
+		}
+	}
+
+	return trace(traceLevel), isDebugEnabled, warnings
+}
+
+func getModulesOutcome(stageOutcomes []StageOutcome, trace trace, isDebugEnabled bool) *ModulesOutcome {
+	var modulesOutcome ModulesOutcome
+	stages := make(map[string]Stage)
+	stageNames := make([]string, 0)
+
+	for _, stageOutcome := range stageOutcomes {
+		if len(stageOutcome.Groups) == 0 {
+			continue
+		}
+
+		prepareModulesOutcome(&modulesOutcome, stageOutcome.Groups, trace, isDebugEnabled)
+		if !trace.isBasicOrHigher() {
+			continue
+		}
+
+		stage, ok := stages[stageOutcome.Stage]
+		if !ok {
+			stageNames = append(stageNames, stageOutcome.Stage)
+			stage = Stage{
+				Stage:    stageOutcome.Stage,
+				Outcomes: []StageOutcome{},
+			}
+		}
+
+		stage.Outcomes = append(stage.Outcomes, stageOutcome)
+		if stageOutcome.ExecutionTimeMillis > stage.ExecutionTimeMillis {
+			stage.ExecutionTimeMillis = stageOutcome.ExecutionTimeMillis
+		}
+
+		stages[stageOutcome.Stage] = stage
+	}
+
+	if modulesOutcome.Errors == nil && modulesOutcome.Warnings == nil && len(stages) == 0 {
+		return nil
+	}
+
+	if len(stages) > 0 {
+		modulesOutcome.Trace = &TraceOutcome{}
+		modulesOutcome.Trace.Stages = make([]Stage, 0, len(stages))
+
+		for _, stage := range stageNames {
+			modulesOutcome.Trace.ExecutionTimeMillis += stages[stage].ExecutionTimeMillis
+			modulesOutcome.Trace.Stages = append(modulesOutcome.Trace.Stages, stages[stage])
+		}
+	}
+
+	return &modulesOutcome
+}
+
+func prepareModulesOutcome(modulesOutcome *ModulesOutcome, groups []GroupOutcome, trace trace, isDebugEnabled bool) {
+	for _, group := range groups {
+		for i, hookOutcome := range group.InvocationResults {
+			if !trace.isVerbose() {
+				group.InvocationResults[i].DebugMessages = nil
+				group.InvocationResults[i].AnalyticsTags = hookanalytics.Analytics{}
+			}
+
+			if isDebugEnabled {
+				modulesOutcome.Errors = fillMessages(modulesOutcome.Errors, hookOutcome.Errors, hookOutcome.HookID)
+				modulesOutcome.Warnings = fillMessages(modulesOutcome.Warnings, hookOutcome.Warnings, hookOutcome.HookID)
+			}
+		}
+	}
+}
+
+func fillMessages(messages Messages, values []string, hookID HookID) Messages {
+	if len(values) == 0 {
+		return messages
+	}
+
+	if messages == nil {
+		return Messages{hookID.ModuleCode: {hookID.HookImplCode: values}}
+	}
+
+	if _, ok := messages[hookID.ModuleCode]; !ok {
+		messages[hookID.ModuleCode] = map[string][]string{hookID.HookImplCode: values}
+		return messages
+	}
+
+	if prevValues, ok := messages[hookID.ModuleCode][hookID.HookImplCode]; ok {
+		values = append(prevValues, values...)
+	}
+
+	messages[hookID.ModuleCode][hookID.HookImplCode] = values
+
+	return messages
+}
diff --git a/hooks/hookexecution/outcome.go b/hooks/hookexecution/outcome.go
index d02bed9a..3eeb7bce 100644
--- a/hooks/hookexecution/outcome.go
+++ b/hooks/hookexecution/outcome.go
@@ -25,6 +25,37 @@ const (
 	ActionNone   Action = "no_action" // the hook does not want to take any action
 )
 
+// Messages in format: {"module": {"hook": ["msg1", "msg2"]}}
+type Messages map[string]map[string][]string
+
+// ModulesOutcome represents result of hooks execution
+// ready to be added to BidResponse.
+//
+// Errors and Warnings hold the error and warning
+// messages returned from executing individual hooks.
+type ModulesOutcome struct {
+	Errors   Messages      `json:"errors,omitempty"`
+	Warnings Messages      `json:"warnings,omitempty"`
+	Trace    *TraceOutcome `json:"trace,omitempty"`
+}
+
+// TraceOutcome holds the result of executing hooks at all stages.
+type TraceOutcome struct {
+	// ExecutionTime is the sum of ExecutionTime of all stages.
+	ExecutionTime
+	Stages []Stage `json:"stages"`
+}
+
+// Stage holds the result of executing hooks at specific stage.
+// May contain multiple StageOutcome results for stages executed
+// multiple times (ex. for each bidder-request/response stages).
+type Stage struct {
+	// ExecutionTime is set to the longest ExecutionTime of its children.
+	ExecutionTime
+	Stage    string         `json:"stage"`
+	Outcomes []StageOutcome `json:"outcomes"`
+}
+
 // StageOutcome represents the result of executing specific stage.
 type StageOutcome struct {
 	// ExecutionTime is the sum of ExecutionTime of all its groups
@@ -51,7 +82,7 @@ type HookOutcome struct {
 	Status        Status                  `json:"status"`
 	Action        Action                  `json:"action"`
 	Message       string                  `json:"message"` // arbitrary string value returned from hook execution
-	DebugMessages []string                `json:"debug_messages"`
+	DebugMessages []string                `json:"debug_messages,omitempty"`
 	Errors        []string                `json:"-"`
 	Warnings      []string                `json:"-"`
 }
@@ -63,5 +94,5 @@ type HookID struct {
 }
 
 type ExecutionTime struct {
-	ExecutionTimeMillis time.Duration `json:"execution_time_millis"`
+	ExecutionTimeMillis time.Duration `json:"execution_time_millis,omitempty"`
 }
diff --git a/openrtb_ext/request.go b/openrtb_ext/request.go
index 47b66134..22f6b127 100644
--- a/openrtb_ext/request.go
+++ b/openrtb_ext/request.go
@@ -62,6 +62,13 @@ type ExtRequestPrebid struct {
 
 	//AlternateBidderCodes is populated with host's AlternateBidderCodes config if not defined in request
 	AlternateBidderCodes *ExtAlternateBidderCodes `json:"alternatebiddercodes,omitempty"`
+
+	// Trace controls the level of detail in the output information returned from executing hooks.
+	// There are two options:
+	// - verbose: sets maximum level of output information
+	// - basic: excludes debugmessages and analytic_tags from output
+	// any other value or an empty string disables trace output at all.
+	Trace string `json:"trace,omitempty"`
 }
 
 // Experiment defines if experimental features are available for the request
diff --git a/openrtb_ext/response.go b/openrtb_ext/response.go
index b20e741c..68097504 100644
--- a/openrtb_ext/response.go
+++ b/openrtb_ext/response.go
@@ -41,6 +41,7 @@ type ExtResponseSyncData struct {
 type ExtResponsePrebid struct {
 	AuctionTimestamp int64           `json:"auctiontimestamp,omitempty"`
 	Passthrough      json.RawMessage `json:"passthrough,omitempty"`
+	Modules          json.RawMessage `json:"modules,omitempty"`
 }
 
 // ExtUserSync defines the contract for bidresponse.ext.usersync.{bidder}.syncs[i]
diff --git a/static/bidder-info/outbrain.yaml b/static/bidder-info/outbrain.yaml
index 69a12ca6..09b73c21 100644
--- a/static/bidder-info/outbrain.yaml
+++ b/static/bidder-info/outbrain.yaml
@@ -7,10 +7,12 @@ capabilities:
     mediaTypes:
       - banner
       - native
+      - video
   site:
     mediaTypes:
       - banner
       - native
+      - video
 userSync:
   redirect:
     url: "https://prebidtest.zemanta.com/usersync/prebidtest?gdpr={{.GDPR}}&gdpr_consent={{.GDPRConsent}}&us_privacy={{.USPrivacy}}&cb={{.RedirectURL}}"


Test patch:
diff --git a/adapters/outbrain/outbraintest/exemplary/video.json b/adapters/outbrain/outbraintest/exemplary/video.json
new file mode 100644
index 00000000..550f56a6
--- /dev/null
+++ b/adapters/outbrain/outbraintest/exemplary/video.json
@@ -0,0 +1,131 @@
+{
+  "mockBidRequest": {
+    "id": "test-request-id",
+    "imp": [
+      {
+        "id": "test-imp-id",
+        "video": {
+          "w": 640,
+          "h": 480,
+          "protocols": [1,2,3,4,5,6,7,8],
+          "playbackmethod": [1],
+          "mimes": ["video/mp4"],
+          "skip": 1,
+          "api": [2],
+          "maxbitrate": 3000
+        },
+        "ext": {
+          "bidder": {
+            "publisher": {
+              "id": "publisher-id"
+            }
+          }
+        }
+      }
+    ],
+    "site": {
+      "page": "http://example.com"
+    },
+    "device": {
+      "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36",
+      "h": 500,
+      "w": 1000
+    }
+  },
+  "httpCalls": [
+    {
+      "expectedRequest": {
+        "uri": "http://example.com/bid",
+        "body": {
+          "id": "test-request-id",
+          "imp": [
+            {
+              "id": "test-imp-id",
+              "video": {
+                "w": 640,
+                "h": 480,
+                "protocols": [1,2,3,4,5,6,7,8],
+                "playbackmethod": [1],
+                "mimes": ["video/mp4"],
+                "skip": 1,
+                "api": [2],
+                "maxbitrate": 3000
+              },
+              "ext": {
+                "bidder": {
+                  "publisher": {
+                    "id": "publisher-id"
+                  }
+                }
+              }
+            }
+          ],
+          "site": {
+            "page": "http://example.com",
+            "publisher": {
+              "id": "publisher-id"
+            }
+          },
+          "device": {
+            "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36",
+            "h": 500,
+            "w": 1000
+          }
+        }
+      },
+      "mockResponse": {
+        "status": 200,
+        "body": {
+          "id": "5095a742-1c27-402b-ab6f-66b1bd53383b",
+          "seatbid": [
+            {
+              "bid": [
+                {
+                  "id": "test-request-id",
+                  "impid": "test-imp-id",
+                  "price": 2.549447,
+                  "adm": "<vast ad>",
+                  "adomain": [
+                    "example.com"
+                  ],
+                  "cid": "test-cid",
+                  "crid": "test-crid",
+                  "cat": [
+                    "IAB13-4"
+                  ]
+                }
+              ],
+              "seat": "acc-1876"
+            }
+          ],
+          "bidid": "43ccadf2-8b2e-11eb-b294-de4c49e99ff6",
+          "cur": "USD"
+        }
+      }
+    }
+  ],
+  "expectedBidResponses": [
+    {
+      "currency": "USD",
+      "bids": [
+        {
+          "bid": {
+            "id": "test-request-id",
+            "impid": "test-imp-id",
+            "price": 2.549447,
+            "adm": "<vast ad>",
+            "adomain": [
+              "example.com"
+            ],
+            "cid": "test-cid",
+            "crid": "test-crid",
+            "cat": [
+              "IAB13-4"
+            ]
+          },
+          "type": "video"
+        }
+      ]
+    }
+  ]
+}
diff --git a/amp/parse_test.go b/amp/parse_test.go
index d2a9c5ee..6bff5b75 100644
--- a/amp/parse_test.go
+++ b/amp/parse_test.go
@@ -30,7 +30,7 @@ func TestParseParams(t *testing.T) {
 			description: "All Fields",
 			// targeting data is encoded string that looks like this: {"gam-key1":"val1","gam-key2":"val2"}
 			query: "tag_id=anyTagID&account=anyAccount&curl=anyCurl&consent_string=anyConsent&debug=1&__amp_source_origin=anyOrigin" +
-				"&slot=anySlot&timeout=42&h=1&w=2&oh=3&ow=4&ms=10x11,12x13&targeting=%7B%22gam-key1%22%3A%22val1%22%2C%22gam-key2%22%3A%22val2%22%7D",
+				"&slot=anySlot&timeout=42&h=1&w=2&oh=3&ow=4&ms=10x11,12x13&targeting=%7B%22gam-key1%22%3A%22val1%22%2C%22gam-key2%22%3A%22val2%22%7D&trace=basic",
 			expectedParams: Params{
 				Account:         "anyAccount",
 				CanonicalURL:    "anyCurl",
@@ -50,6 +50,7 @@ func TestParseParams(t *testing.T) {
 					},
 				},
 				Targeting: `{"gam-key1":"val1","gam-key2":"val2"}`,
+				Trace:     "basic",
 			},
 		},
 		{
diff --git a/endpoints/openrtb2/amp_auction_test.go b/endpoints/openrtb2/amp_auction_test.go
index 187a41b0..58c13963 100644
--- a/endpoints/openrtb2/amp_auction_test.go
+++ b/endpoints/openrtb2/amp_auction_test.go
@@ -3,6 +3,7 @@ package openrtb2
 import (
 	"context"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"net/http"
 	"net/http/httptest"
@@ -11,6 +12,7 @@ import (
 	"reflect"
 	"strconv"
 	"testing"
+	"time"
 
 	"github.com/julienschmidt/httprouter"
 	"github.com/prebid/openrtb/v17/openrtb2"
@@ -20,7 +22,9 @@ import (
 	"github.com/prebid/prebid-server/config"
 	"github.com/prebid/prebid-server/exchange"
 	"github.com/prebid/prebid-server/hooks"
+	"github.com/prebid/prebid-server/hooks/hookexecution"
 	"github.com/prebid/prebid-server/hooks/hookstage"
+	"github.com/prebid/prebid-server/metrics"
 	metricsConfig "github.com/prebid/prebid-server/metrics/config"
 	"github.com/prebid/prebid-server/openrtb_ext"
 	"github.com/prebid/prebid-server/stored_requests/backends/empty_fetcher"
@@ -116,9 +120,7 @@ func TestGoodAmpRequests(t *testing.T) {
 			// Assertions
 			if assert.Equal(t, test.ExpectedReturnCode, recorder.Code, "Expected status %d. Got %d. Amp test file: %s", http.StatusOK, recorder.Code, filename) {
 				if test.ExpectedReturnCode == http.StatusOK {
-					var ampResponse AmpResponse
-					assert.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &ampResponse), "Error unmarshalling ampResponse: %v", err)
-					assert.Equal(t, test.ExpectedAmpResponse, ampResponse, "Not the expected response. Test file: %s", filename)
+					assert.JSONEq(t, string(test.ExpectedAmpResponse), string(recorder.Body.Bytes()), "Not the expected response. Test file: %s", filename)
 				} else {
 					assert.Equal(t, test.ExpectedErrorMessage, recorder.Body.String(), filename)
 				}
@@ -346,8 +348,8 @@ func TestGDPRConsent(t *testing.T) {
 			return
 		}
 		assert.Equal(t, test.expectedUserExt, ue, test.description)
-		assert.Equal(t, expectedErrorsFromHoldAuction, response.Errors, test.description+":errors")
-		assert.Empty(t, response.Warnings, test.description+":warnings")
+		assert.Equal(t, expectedErrorsFromHoldAuction, response.ORTB2.Ext.Errors, test.description+":errors")
+		assert.Empty(t, response.ORTB2.Ext.Warnings, test.description+":warnings")
 
 		// Invoke Endpoint With Legacy Param
 		requestLegacy := httptest.NewRequest("GET", fmt.Sprintf("/openrtb2/auction/amp?tag_id=1&consent_type=2&gdpr_consent=%s", test.consent), nil)
@@ -377,8 +379,8 @@ func TestGDPRConsent(t *testing.T) {
 			return
 		}
 		assert.Equal(t, test.expectedUserExt, ueLegacy, test.description+":legacy")
-		assert.Equal(t, expectedErrorsFromHoldAuction, responseLegacy.Errors, test.description+":legacy:errors")
-		assert.Empty(t, responseLegacy.Warnings, test.description+":legacy:warnings")
+		assert.Equal(t, expectedErrorsFromHoldAuction, responseLegacy.ORTB2.Ext.Errors, test.description+":legacy:errors")
+		assert.Empty(t, responseLegacy.ORTB2.Ext.Warnings, test.description+":legacy:warnings")
 	}
 }
 
@@ -470,6 +472,39 @@ func TestOverrideWithParams(t *testing.T) {
 				errorMsgs: nil,
 			},
 		},
+		{
+			desc: "amp.Params with Trace field - expect ext.prebid.trace to be added",
+			given: testInput{
+				ampParams:  amp.Params{Trace: "verbose"},
+				bidRequest: &openrtb2.BidRequest{Imp: []openrtb2.Imp{{Banner: &openrtb2.Banner{Format: []openrtb2.Format{}}}}},
+			},
+			expected: testOutput{
+				bidRequest: &openrtb2.BidRequest{
+					Imp:  []openrtb2.Imp{{Banner: &openrtb2.Banner{Format: []openrtb2.Format{}}}},
+					Site: &openrtb2.Site{Ext: json.RawMessage(`{"amp":1}`)},
+					Ext:  json.RawMessage(`{"prebid":{"trace":"verbose"}}`),
+				},
+				errorMsgs: nil,
+			},
+		},
+		{
+			desc: "amp.Params with Trace field - expect ext.prebid.trace to be merged with existing ext fields",
+			given: testInput{
+				ampParams: amp.Params{Trace: "verbose"},
+				bidRequest: &openrtb2.BidRequest{
+					Imp: []openrtb2.Imp{{Banner: &openrtb2.Banner{Format: []openrtb2.Format{}}}},
+					Ext: json.RawMessage(`{"prebid":{"debug":true}}`),
+				},
+			},
+			expected: testOutput{
+				bidRequest: &openrtb2.BidRequest{
+					Imp:  []openrtb2.Imp{{Banner: &openrtb2.Banner{Format: []openrtb2.Format{}}}},
+					Site: &openrtb2.Site{Ext: json.RawMessage(`{"amp":1}`)},
+					Ext:  json.RawMessage(`{"prebid":{"debug":true,"trace":"verbose"}}`),
+				},
+				errorMsgs: nil,
+			},
+		},
 		{
 			desc: "bid request with malformed User.Ext - amp.Params with AdditionalConsent - expect error",
 			given: testInput{
@@ -732,8 +767,8 @@ func TestCCPAConsent(t *testing.T) {
 			return
 		}
 		assert.Equal(t, test.expectedRegExt, re, test.description)
-		assert.Equal(t, expectedErrorsFromHoldAuction, response.Errors)
-		assert.Empty(t, response.Warnings)
+		assert.Equal(t, expectedErrorsFromHoldAuction, response.ORTB2.Ext.Errors)
+		assert.Empty(t, response.ORTB2.Ext.Warnings)
 	}
 }
 
@@ -842,15 +877,15 @@ func TestConsentWarnings(t *testing.T) {
 			assert.NotNil(t, result, "lastRequest")
 			assert.Nil(t, result.User, "lastRequest.User")
 			assert.Nil(t, result.Regs, "lastRequest.Regs")
-			assert.Equal(t, expectedErrorsFromHoldAuction, response.Errors)
+			assert.Equal(t, expectedErrorsFromHoldAuction, response.ORTB2.Ext.Errors)
 			if testCase.invalidConsentURL {
-				assert.Equal(t, testCase.expectedWarnings, response.Warnings)
+				assert.Equal(t, testCase.expectedWarnings, response.ORTB2.Ext.Warnings)
 			} else {
-				assert.Empty(t, response.Warnings)
+				assert.Empty(t, response.ORTB2.Ext.Warnings)
 			}
 
 		} else {
-			assert.Equal(t, testCase.expectedWarnings, response.Warnings)
+			assert.Equal(t, testCase.expectedWarnings, response.ORTB2.Ext.Warnings)
 		}
 	}
 }
@@ -943,8 +978,8 @@ func TestNewAndLegacyConsentBothProvided(t *testing.T) {
 			return
 		}
 		assert.Equal(t, test.expectedUserExt, ue, test.description)
-		assert.Equal(t, expectedErrorsFromHoldAuction, response.Errors)
-		assert.Empty(t, response.Warnings)
+		assert.Equal(t, expectedErrorsFromHoldAuction, response.ORTB2.Ext.Errors)
+		assert.Empty(t, response.ORTB2.Ext.Warnings)
 	}
 }
 
@@ -1067,7 +1102,7 @@ func TestAmpDebug(t *testing.T) {
 			t.Errorf("Bad targeting data. Expected 3 keys, got %d.", len(response.Targeting))
 		}
 
-		if response.Debug == nil {
+		if response.ORTB2.Ext.Debug == nil {
 			t.Errorf("Debug requested but not present")
 		}
 	}
@@ -1143,7 +1178,7 @@ func TestQueryParamOverrides(t *testing.T) {
 	}
 
 	var resolvedRequest openrtb2.BidRequest
-	err := json.Unmarshal(response.Debug.ResolvedRequest, &resolvedRequest)
+	err := json.Unmarshal(response.ORTB2.Ext.Debug.ResolvedRequest, &resolvedRequest)
 	assert.NoError(t, err, "resolved request should have a correct format")
 	if resolvedRequest.TMax != timeout {
 		t.Errorf("Expected TMax to equal timeout (%d), got: %d", timeout, resolvedRequest.TMax)
@@ -1292,7 +1327,7 @@ func (s formatOverrideSpec) execute(t *testing.T) {
 		t.Fatalf("Error unmarshalling response: %s", err.Error())
 	}
 	var resolvedRequest openrtb2.BidRequest
-	err := json.Unmarshal(response.Debug.ResolvedRequest, &resolvedRequest)
+	err := json.Unmarshal(response.ORTB2.Ext.Debug.ResolvedRequest, &resolvedRequest)
 	assert.NoError(t, err, "resolved request should have the correct format")
 	formats := resolvedRequest.Imp[0].Banner.Format
 	if len(formats) != len(s.expect) {
@@ -1934,84 +1969,82 @@ func TestSetTargeting(t *testing.T) {
 	}
 }
 
-func TestValidAmpResponseWhenRequestStagesRejected(t *testing.T) {
+func TestValidAmpResponseWhenRequestRejected(t *testing.T) {
 	const nbr int = 123
-	const file string = "sample-requests/amp/valid-supplementary/aliased-buyeruids.json"
-
-	var test testCase
-	fileData, err := os.ReadFile(file)
-	assert.NoError(t, err, "Failed to read test file.")
-	if err := json.Unmarshal(fileData, &test); err != nil {
-		t.Fatal("Failed to unmarshal test file.")
-	}
 
 	testCases := []struct {
-		description         string
-		file                string
-		planBuilder         hooks.ExecutionPlanBuilder
-		expectedAmpResponse AmpResponse
+		description string
+		file        string
+		planBuilder hooks.ExecutionPlanBuilder
 	}{
 		{
-			description:         "Assert correct BidResponse when request rejected at entrypoint stage",
-			file:                file,
-			planBuilder:         mockPlanBuilder{entrypointPlan: makeRejectPlan[hookstage.Entrypoint](mockRejectionHook{nbr})},
-			expectedAmpResponse: AmpResponse{Targeting: map[string]string{}},
+			description: "Assert correct AmpResponse when request rejected at entrypoint stage",
+			file:        "sample-requests/hooks/amp_entrypoint_reject.json",
+			planBuilder: mockPlanBuilder{entrypointPlan: makePlan[hookstage.Entrypoint](mockRejectionHook{nbr})},
 		},
 		{
-			description:         "Assert correct BidResponse when request rejected at raw-auction stage",
-			file:                file,
-			planBuilder:         mockPlanBuilder{rawAuctionPlan: makeRejectPlan[hookstage.RawAuctionRequest](mockRejectionHook{nbr})},
-			expectedAmpResponse: test.ExpectedAmpResponse,
+			// raw_auction stage not executed for AMP endpoint, so we expect full response
+			description: "Assert correct AmpResponse when request rejected at raw_auction stage",
+			file:        "sample-requests/amp/valid-supplementary/aliased-buyeruids.json",
+			planBuilder: mockPlanBuilder{rawAuctionPlan: makePlan[hookstage.RawAuctionRequest](mockRejectionHook{nbr})},
 		},
 		{
-			description:         "Assert correct AmpResponse when request rejected at processed-auction stage",
-			file:                file,
-			planBuilder:         mockPlanBuilder{processedAuctionPlan: makeRejectPlan[hookstage.ProcessedAuctionRequest](mockRejectionHook{nbr})},
-			expectedAmpResponse: AmpResponse{Targeting: map[string]string{}},
+			description: "Assert correct AmpResponse when request rejected at processed_auction stage",
+			file:        "sample-requests/hooks/amp_processed_auction_request_reject.json",
+			planBuilder: mockPlanBuilder{processedAuctionPlan: makePlan[hookstage.ProcessedAuctionRequest](mockRejectionHook{nbr})},
 		},
 		{
-			// bidder-request stage rejects only bidder, so we expect bidder rejection warning added
-			description: "Assert correct BidResponse when request rejected at bidder-request stage",
-			file:        file,
-			planBuilder: mockPlanBuilder{bidderRequestPlan: makeRejectPlan[hookstage.BidderRequest](mockRejectionHook{nbr})},
-			expectedAmpResponse: AmpResponse{Targeting: map[string]string{}, Warnings: map[openrtb_ext.BidderName][]openrtb_ext.ExtBidderMessage{
-				"appnexus": {
-					{
-						Code:    11,
-						Message: "Module foobar (hook: foo) rejected request with code 123 at bidder_request stage",
-					},
-				},
-				"general": {
-					{
-						Code:    10002,
-						Message: "debug turned off for account",
-					},
-				},
-			}},
+			// bidder_request stage rejects only bidder, so we expect bidder rejection warning added
+			description: "Assert correct AmpResponse when request rejected at bidder-request stage",
+			file:        "sample-requests/hooks/amp_bidder_reject.json",
+			planBuilder: mockPlanBuilder{bidderRequestPlan: makePlan[hookstage.BidderRequest](mockRejectionHook{nbr})},
+		},
+		{
+			// raw_bidder_response stage rejects only bidder, so we expect bidder rejection warning added
+			description: "Assert correct AmpResponse when request rejected at raw_bidder_response stage",
+			file:        "sample-requests/hooks/amp_bidder_response_reject.json",
+			planBuilder: mockPlanBuilder{rawBidderResponsePlan: makePlan[hookstage.RawBidderResponse](mockRejectionHook{nbr})},
 		},
 		{
-			description: "Assert correct AmpResponse when request rejected at raw-bidder-response stage",
-			file:        file,
-			planBuilder: mockPlanBuilder{rawBidderResponsePlan: makeRejectPlan[hookstage.RawBidderResponse](mockRejectionHook{nbr})},
-			expectedAmpResponse: AmpResponse{Targeting: map[string]string{}, Warnings: map[openrtb_ext.BidderName][]openrtb_ext.ExtBidderMessage{
-				"appnexus": {
+			// no debug information should be added for raw_auction stage because it's not executed for amp endpoint
+			description: "Assert correct AmpResponse with debug information from modules added to ext.prebid.modules",
+			file:        "sample-requests/hooks/amp.json",
+			planBuilder: mockPlanBuilder{
+				entrypointPlan: hooks.Plan[hookstage.Entrypoint]{
 					{
-						Code:    11,
-						Message: "Module foobar (hook: foo) rejected request with code 123 at raw_bidder_response stage",
+						Timeout: 5 * time.Millisecond,
+						Hooks: []hooks.HookWrapper[hookstage.Entrypoint]{
+							entryPointHookUpdateWithErrors,
+							entryPointHookUpdateWithErrorsAndWarnings,
+						},
+					},
+					{
+						Timeout: 5 * time.Millisecond,
+						Hooks: []hooks.HookWrapper[hookstage.Entrypoint]{
+							entryPointHookUpdate,
+						},
 					},
 				},
-				"general": {
+				rawAuctionPlan: hooks.Plan[hookstage.RawAuctionRequest]{
 					{
-						Code:    10002,
-						Message: "debug turned off for account",
+						Timeout: 5 * time.Millisecond,
+						Hooks: []hooks.HookWrapper[hookstage.RawAuctionRequest]{
+							rawAuctionHookNone,
+						},
 					},
 				},
-			}},
+			},
 		},
 	}
 
 	for _, tc := range testCases {
 		t.Run(tc.description, func(t *testing.T) {
+			fileData, err := os.ReadFile(tc.file)
+			assert.NoError(t, err, "Failed to read test file.")
+
+			test := testCase{}
+			assert.NoError(t, json.Unmarshal(fileData, &test), "Failed to parse test file.")
+
 			request := httptest.NewRequest("GET", fmt.Sprintf("/openrtb2/auction/amp?%s", test.Query), nil)
 			recorder := httptest.NewRecorder()
 			query := request.URL.Query()
@@ -2021,15 +2054,29 @@ func TestValidAmpResponseWhenRequestStagesRejected(t *testing.T) {
 			test.planBuilder = tc.planBuilder
 			test.endpointType = AMP_ENDPOINT
 
-			ampEndpointHandler, _, mockBidServers, mockCurrencyRatesServer, err := buildTestEndpoint(test, &config.Configuration{MaxRequestSize: maxSize})
+			cfg := &config.Configuration{MaxRequestSize: maxSize, AccountDefaults: config.Account{DebugAllow: true}}
+			ampEndpointHandler, _, mockBidServers, mockCurrencyRatesServer, err := buildTestEndpoint(test, cfg)
 			assert.NoError(t, err, "Failed to build test endpoint.")
 
 			ampEndpointHandler(recorder, request, nil)
 			assert.Equal(t, recorder.Code, http.StatusOK, "Endpoint should return 200 OK.")
 
-			var actualResp AmpResponse
-			assert.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &actualResp), "Unable to unmarshal actual BidResponse.")
-			assert.Equal(t, tc.expectedAmpResponse, actualResp, "Invalid AMP Response.")
+			var actualAmpResp AmpResponse
+			var expectedAmpResp AmpResponse
+			assert.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &actualAmpResp), "Unable to unmarshal actual AmpResponse.")
+			assert.NoError(t, json.Unmarshal(test.ExpectedAmpResponse, &expectedAmpResp), "Unable to unmarshal expected AmpResponse.")
+
+			// validate modules data separately, because it has dynamic data
+			if expectedAmpResp.ORTB2.Ext.Prebid == nil {
+				assert.Nil(t, actualAmpResp.ORTB2.Ext.Prebid, "AmpResponse.ortb2.ext.prebid expected to be nil.")
+			} else {
+				hookexecution.AssertEqualModulesData(t, expectedAmpResp.ORTB2.Ext.Prebid.Modules, actualAmpResp.ORTB2.Ext.Prebid.Modules)
+			}
+
+			// reset modules to validate amp responses
+			actualAmpResp.ORTB2.Ext.Prebid = nil
+			expectedAmpResp.ORTB2.Ext.Prebid = nil
+			assert.Equal(t, expectedAmpResp, actualAmpResp, "Invalid AMP Response.")
 
 			// Close servers regardless if the test case was run or not
 			for _, mockBidServer := range mockBidServers {
@@ -2039,3 +2086,111 @@ func TestValidAmpResponseWhenRequestStagesRejected(t *testing.T) {
 		})
 	}
 }
+
+func TestSendAmpResponse_LogsErrors(t *testing.T) {
+	testCases := []struct {
+		description    string
+		expectedErrors []error
+		expectedStatus int
+		writer         http.ResponseWriter
+		request        *openrtb2.BidRequest
+		response       *openrtb2.BidResponse
+		hookExecutor   hookexecution.HookStageExecutor
+	}{
+		{
+			description: "Error logged when bid.ext unmarshal fails",
+			expectedErrors: []error{
+				errors.New("Critical error while unpacking AMP targets: unexpected end of JSON input"),
+			},
+			expectedStatus: http.StatusInternalServerError,
+			writer:         httptest.NewRecorder(),
+			request:        &openrtb2.BidRequest{ID: "some-id", Test: 1},
+			response: &openrtb2.BidResponse{ID: "some-id", SeatBid: []openrtb2.SeatBid{
+				{Bid: []openrtb2.Bid{{Ext: json.RawMessage(`"hb_cache_id`)}}},
+			}},
+			hookExecutor: &hookexecution.EmptyHookExecutor{},
+		},
+		{
+			description: "Error logged when test mode activated but no debug present in response",
+			expectedErrors: []error{
+				errors.New("test set on request but debug not present in response"),
+			},
+			expectedStatus: 0,
+			writer:         httptest.NewRecorder(),
+			request:        &openrtb2.BidRequest{ID: "some-id", Test: 1},
+			response:       &openrtb2.BidResponse{ID: "some-id", Ext: json.RawMessage("{}")},
+			hookExecutor:   &hookexecution.EmptyHookExecutor{},
+		},
+		{
+			description: "Error logged when response encoding fails",
+			expectedErrors: []error{
+				errors.New("/openrtb2/amp Failed to send response: failed writing response"),
+			},
+			expectedStatus: 0,
+			writer:         errorResponseWriter{},
+			request:        &openrtb2.BidRequest{ID: "some-id", Test: 1},
+			response:       &openrtb2.BidResponse{ID: "some-id", Ext: json.RawMessage(`{"debug": {}}`)},
+			hookExecutor:   &hookexecution.EmptyHookExecutor{},
+		},
+		{
+			description: "Error logged if hook enrichment returns warnings",
+			expectedErrors: []error{
+				errors.New("Value is not a string: 1"),
+				errors.New("Value is not a boolean: active"),
+			},
+			expectedStatus: 0,
+			writer:         httptest.NewRecorder(),
+			request:        &openrtb2.BidRequest{ID: "some-id", Ext: json.RawMessage(`{"prebid": {"debug": "active", "trace": 1}}`)},
+			response:       &openrtb2.BidResponse{ID: "some-id", Ext: json.RawMessage("{}")},
+			hookExecutor: &mockStageExecutor{
+				outcomes: []hookexecution.StageOutcome{
+					{
+						Entity: "bid-request",
+						Stage:  hooks.StageBidderRequest.String(),
+						Groups: []hookexecution.GroupOutcome{
+							{
+								InvocationResults: []hookexecution.HookOutcome{
+									{
+										HookID: hookexecution.HookID{
+											ModuleCode:   "foobar",
+											HookImplCode: "foo",
+										},
+										Status:   hookexecution.StatusSuccess,
+										Action:   hookexecution.ActionNone,
+										Warnings: []string{"warning message"},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.description, func(t *testing.T) {
+			labels := metrics.Labels{}
+			ao := analytics.AmpObject{}
+			account := &config.Account{DebugAllow: true}
+			reqWrapper := openrtb_ext.RequestWrapper{BidRequest: test.request}
+
+			labels, ao = sendAmpResponse(test.writer, test.hookExecutor, test.response, &reqWrapper, account, labels, ao, nil)
+
+			assert.Equal(t, ao.Errors, test.expectedErrors, "Invalid errors.")
+			assert.Equal(t, test.expectedStatus, ao.Status, "Invalid HTTP response status.")
+		})
+	}
+}
+
+type errorResponseWriter struct{}
+
+func (e errorResponseWriter) Header() http.Header {
+	return http.Header{}
+}
+
+func (e errorResponseWriter) Write(bytes []byte) (int, error) {
+	return 0, errors.New("failed writing response")
+}
+
+func (e errorResponseWriter) WriteHeader(statusCode int) {}
diff --git a/endpoints/openrtb2/auction_test.go b/endpoints/openrtb2/auction_test.go
index 72314c91..b32f1349 100644
--- a/endpoints/openrtb2/auction_test.go
+++ b/endpoints/openrtb2/auction_test.go
@@ -20,6 +20,7 @@ import (
 	"github.com/prebid/openrtb/v17/native1"
 	nativeRequests "github.com/prebid/openrtb/v17/native1/request"
 	"github.com/prebid/openrtb/v17/openrtb2"
+	"github.com/prebid/prebid-server/analytics"
 	"github.com/prebid/prebid-server/hooks"
 	"github.com/prebid/prebid-server/hooks/hookexecution"
 	"github.com/prebid/prebid-server/hooks/hookstage"
@@ -4656,9 +4657,34 @@ func TestValidateStoredResp(t *testing.T) {
 	}
 }
 
-func TestValidResponseWhenRequestRejected(t *testing.T) {
+func TestValidResponseAfterExecutingStages(t *testing.T) {
 	const nbr int = 123
-	const file string = "sample-requests/valid-whole/hooks/auction_reject.json"
+
+	hooksPlanBuilder := mockPlanBuilder{
+		entrypointPlan: hooks.Plan[hookstage.Entrypoint]{
+			{
+				Timeout: 5 * time.Millisecond,
+				Hooks: []hooks.HookWrapper[hookstage.Entrypoint]{
+					entryPointHookUpdateWithErrors,
+					entryPointHookUpdateWithErrorsAndWarnings,
+				},
+			},
+			{
+				Timeout: 5 * time.Millisecond,
+				Hooks: []hooks.HookWrapper[hookstage.Entrypoint]{
+					entryPointHookUpdate,
+				},
+			},
+		},
+		rawAuctionPlan: hooks.Plan[hookstage.RawAuctionRequest]{
+			{
+				Timeout: 5 * time.Millisecond,
+				Hooks: []hooks.HookWrapper[hookstage.RawAuctionRequest]{
+					rawAuctionHookNone,
+				},
+			},
+		},
+	}
 
 	testCases := []struct {
 		description string
@@ -4667,29 +4693,34 @@ func TestValidResponseWhenRequestRejected(t *testing.T) {
 	}{
 		{
 			description: "Assert correct BidResponse when request rejected at entrypoint stage",
-			file:        file,
-			planBuilder: mockPlanBuilder{entrypointPlan: makeRejectPlan[hookstage.Entrypoint](mockRejectionHook{nbr})},
+			file:        "sample-requests/hooks/auction_entrypoint_reject.json",
+			planBuilder: mockPlanBuilder{entrypointPlan: makePlan[hookstage.Entrypoint](mockRejectionHook{nbr})},
 		},
 		{
 			description: "Assert correct BidResponse when request rejected at raw-auction stage",
-			file:        file,
-			planBuilder: mockPlanBuilder{rawAuctionPlan: makeRejectPlan[hookstage.RawAuctionRequest](mockRejectionHook{nbr})},
+			file:        "sample-requests/hooks/auction_raw_auction_request_reject.json",
+			planBuilder: mockPlanBuilder{rawAuctionPlan: makePlan[hookstage.RawAuctionRequest](mockRejectionHook{nbr})},
 		},
 		{
 			description: "Assert correct BidResponse when request rejected at processed-auction stage",
-			file:        file,
-			planBuilder: mockPlanBuilder{processedAuctionPlan: makeRejectPlan[hookstage.ProcessedAuctionRequest](mockRejectionHook{nbr})},
+			file:        "sample-requests/hooks/auction_processed_auction_request_reject.json",
+			planBuilder: mockPlanBuilder{processedAuctionPlan: makePlan[hookstage.ProcessedAuctionRequest](mockRejectionHook{nbr})},
 		},
 		{
 			// bidder-request stage doesn't reject whole request, so we do not expect NBR code in response
 			description: "Assert correct BidResponse when request rejected at bidder-request stage",
-			file:        "sample-requests/valid-whole/hooks/auction_bidder_reject.json",
-			planBuilder: mockPlanBuilder{bidderRequestPlan: makeRejectPlan[hookstage.BidderRequest](mockRejectionHook{nbr})},
+			file:        "sample-requests/hooks/auction_bidder_reject.json",
+			planBuilder: mockPlanBuilder{bidderRequestPlan: makePlan[hookstage.BidderRequest](mockRejectionHook{nbr})},
 		},
 		{
 			description: "Assert correct BidResponse when request rejected at raw-bidder-response stage",
-			file:        "sample-requests/valid-whole/hooks/auction_bidder_response_reject.json",
-			planBuilder: mockPlanBuilder{rawBidderResponsePlan: makeRejectPlan[hookstage.RawBidderResponse](mockRejectionHook{nbr})},
+			file:        "sample-requests/hooks/auction_bidder_response_reject.json",
+			planBuilder: mockPlanBuilder{rawBidderResponsePlan: makePlan[hookstage.RawBidderResponse](mockRejectionHook{nbr})},
+		},
+		{
+			description: "Assert correct BidResponse with debug information from modules added to ext.prebid.modules",
+			file:        "sample-requests/hooks/auction.json",
+			planBuilder: hooksPlanBuilder,
 		},
 	}
 
@@ -4703,7 +4734,8 @@ func TestValidResponseWhenRequestRejected(t *testing.T) {
 			test.planBuilder = tc.planBuilder
 			test.endpointType = OPENRTB_ENDPOINT
 
-			auctionEndpointHandler, _, mockBidServers, mockCurrencyRatesServer, err := buildTestEndpoint(test, &config.Configuration{MaxRequestSize: maxSize})
+			cfg := &config.Configuration{MaxRequestSize: maxSize, AccountDefaults: config.Account{DebugAllow: true}}
+			auctionEndpointHandler, _, mockBidServers, mockCurrencyRatesServer, err := buildTestEndpoint(test, cfg)
 			assert.NoError(t, err, "Failed to build test endpoint.")
 
 			recorder := httptest.NewRecorder()
@@ -4725,7 +4757,13 @@ func TestValidResponseWhenRequestRejected(t *testing.T) {
 
 			assertBidResponseEqual(t, tc.file, expectedResp, actualResp)
 			assert.Equal(t, expectedResp.NBR, actualResp.NBR, "Invalid NBR.")
-			assert.Equal(t, expectedExt.Warnings, actualExt.Warnings, "Wrong bidResponse.ext.")
+			assert.Equal(t, expectedExt.Warnings, actualExt.Warnings, "Wrong bidResponse.ext.warnings.")
+
+			if expectedExt.Prebid != nil {
+				hookexecution.AssertEqualModulesData(t, expectedExt.Prebid.Modules, actualExt.Prebid.Modules)
+			} else {
+				assert.Nil(t, actualExt.Prebid, "Invalid BidResponse.ext.prebid")
+			}
 
 			// Close servers regardless if the test case was run or not
 			for _, mockBidServer := range mockBidServers {
@@ -4736,6 +4774,78 @@ func TestValidResponseWhenRequestRejected(t *testing.T) {
 	}
 }
 
+func TestSendAuctionResponse_LogsErrors(t *testing.T) {
+	hookExecutor := &mockStageExecutor{
+		outcomes: []hookexecution.StageOutcome{
+			{
+				Entity: "bid-request",
+				Stage:  hooks.StageBidderRequest.String(),
+				Groups: []hookexecution.GroupOutcome{
+					{
+						InvocationResults: []hookexecution.HookOutcome{
+							{
+								HookID: hookexecution.HookID{
+									ModuleCode:   "foobar",
+									HookImplCode: "foo",
+								},
+								Status:   hookexecution.StatusSuccess,
+								Action:   hookexecution.ActionNone,
+								Warnings: []string{"warning message"},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	testCases := []struct {
+		description    string
+		expectedErrors []error
+		expectedStatus int
+		request        *openrtb2.BidRequest
+		response       *openrtb2.BidResponse
+		hookExecutor   hookexecution.HookStageExecutor
+	}{
+		{
+			description: "Error logged if hook enrichment fails",
+			expectedErrors: []error{
+				errors.New("Failed to enrich Bid Response with hook debug information: Invalid JSON Document"),
+				errors.New("/openrtb2/auction Failed to send response: json: error calling MarshalJSON for type json.RawMessage: invalid character '.' looking for beginning of value"),
+			},
+			expectedStatus: 0,
+			request:        &openrtb2.BidRequest{ID: "some-id", Test: 1},
+			response:       &openrtb2.BidResponse{ID: "some-id", Ext: json.RawMessage("...")},
+			hookExecutor:   hookExecutor,
+		},
+		{
+			description: "Error logged if hook enrichment returns warnings",
+			expectedErrors: []error{
+				errors.New("Value is not a string: 1"),
+				errors.New("Value is not a boolean: active"),
+			},
+			expectedStatus: 0,
+			request:        &openrtb2.BidRequest{ID: "some-id", Test: 1, Ext: json.RawMessage(`{"prebid": {"debug": "active", "trace": 1}}`)},
+			response:       &openrtb2.BidResponse{ID: "some-id", Ext: json.RawMessage("{}")},
+			hookExecutor:   hookExecutor,
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.description, func(t *testing.T) {
+			writer := httptest.NewRecorder()
+			labels := metrics.Labels{}
+			ao := analytics.AuctionObject{}
+			account := &config.Account{DebugAllow: true}
+
+			labels, ao = sendAuctionResponse(writer, test.hookExecutor, test.response, test.request, account, labels, ao)
+
+			assert.Equal(t, ao.Errors, test.expectedErrors, "Invalid errors.")
+			assert.Equal(t, test.expectedStatus, ao.Status, "Invalid HTTP response status.")
+		})
+	}
+}
+
 type mockStoredResponseFetcher struct {
 	data map[string]json.RawMessage
 }
@@ -4772,3 +4882,13 @@ func getIntegrationFromRequest(req *openrtb_ext.RequestWrapper) (string, error)
 	reqPrebid := reqExt.GetPrebid()
 	return reqPrebid.Integration, nil
 }
+
+type mockStageExecutor struct {
+	hookexecution.EmptyHookExecutor
+
+	outcomes []hookexecution.StageOutcome
+}
+
+func (e mockStageExecutor) GetOutcomes() []hookexecution.StageOutcome {
+	return e.outcomes
+}
diff --git a/endpoints/openrtb2/test_utils.go b/endpoints/openrtb2/test_utils.go
index 854ca108..cf803a34 100644
--- a/endpoints/openrtb2/test_utils.go
+++ b/endpoints/openrtb2/test_utils.go
@@ -29,6 +29,7 @@ import (
 	"github.com/prebid/prebid-server/experiment/adscert"
 	"github.com/prebid/prebid-server/gdpr"
 	"github.com/prebid/prebid-server/hooks"
+	"github.com/prebid/prebid-server/hooks/hookexecution"
 	"github.com/prebid/prebid-server/hooks/hookstage"
 	"github.com/prebid/prebid-server/metrics"
 	metricsConfig "github.com/prebid/prebid-server/metrics/config"
@@ -38,6 +39,7 @@ import (
 	"github.com/prebid/prebid-server/stored_requests/backends/empty_fetcher"
 	"github.com/prebid/prebid-server/util/iputil"
 	"github.com/prebid/prebid-server/util/uuidutil"
+	jsonpatch "gopkg.in/evanphx/json-patch.v4"
 )
 
 // In this file we define:
@@ -76,7 +78,7 @@ type testCase struct {
 	// "/openrtb2/amp" endpoint JSON test info
 	storedRequest       map[string]json.RawMessage `json:"mockAmpStoredRequest"`
 	StoredResponse      map[string]json.RawMessage `json:"mockAmpStoredResponse"`
-	ExpectedAmpResponse AmpResponse                `json:"expectedAmpResponse"`
+	ExpectedAmpResponse json.RawMessage            `json:"expectedAmpResponse"`
 }
 
 type testConfigValues struct {
@@ -1474,7 +1476,7 @@ func (m mockPlanBuilder) PlanForAuctionResponseStage(_ string, _ *config.Account
 	return m.auctionResponsePlan
 }
 
-func makeRejectPlan[H any](hook H) hooks.Plan[H] {
+func makePlan[H any](hook H) hooks.Plan[H] {
 	return hooks.Plan[H]{
 		{
 			Timeout: 5 * time.Millisecond,
@@ -1544,3 +1546,115 @@ func (m mockRejectionHook) HandleRawBidderResponseHook(
 
 	return result, nil
 }
+
+var entryPointHookUpdateWithErrors = hooks.HookWrapper[hookstage.Entrypoint]{
+	Module: "foobar",
+	Code:   "foo",
+	Hook: mockUpdateHook{
+		entrypointHandler: func(
+			_ hookstage.ModuleInvocationContext,
+			payload hookstage.EntrypointPayload,
+		) (hookstage.HookResult[hookstage.EntrypointPayload], error) {
+			ch := &hookstage.ChangeSet[hookstage.EntrypointPayload]{}
+			ch.AddMutation(func(payload hookstage.EntrypointPayload) (hookstage.EntrypointPayload, error) {
+				payload.Request.Header.Add("foo", "bar")
+				return payload, nil
+			}, hookstage.MutationUpdate, "header", "foo")
+
+			return hookstage.HookResult[hookstage.EntrypointPayload]{
+				ChangeSet: ch,
+				Errors:    []string{"error 1"},
+			}, nil
+		},
+	},
+}
+
+var entryPointHookUpdateWithErrorsAndWarnings = hooks.HookWrapper[hookstage.Entrypoint]{
+	Module: "foobar",
+	Code:   "bar",
+	Hook: mockUpdateHook{
+		entrypointHandler: func(
+			_ hookstage.ModuleInvocationContext,
+			payload hookstage.EntrypointPayload,
+		) (hookstage.HookResult[hookstage.EntrypointPayload], error) {
+			ch := &hookstage.ChangeSet[hookstage.EntrypointPayload]{}
+			ch.AddMutation(func(payload hookstage.EntrypointPayload) (hookstage.EntrypointPayload, error) {
+				params := payload.Request.URL.Query()
+				params.Add("foo", "baz")
+				payload.Request.URL.RawQuery = params.Encode()
+				return payload, nil
+			}, hookstage.MutationUpdate, "param", "foo")
+
+			return hookstage.HookResult[hookstage.EntrypointPayload]{
+				ChangeSet: ch,
+				Errors:    []string{"error 1"},
+				Warnings:  []string{"warning 1"},
+			}, nil
+		},
+	},
+}
+
+var entryPointHookUpdate = hooks.HookWrapper[hookstage.Entrypoint]{
+	Module: "foobar",
+	Code:   "baz",
+	Hook: mockUpdateHook{
+		entrypointHandler: func(
+			ctx hookstage.ModuleInvocationContext,
+			payload hookstage.EntrypointPayload,
+		) (hookstage.HookResult[hookstage.EntrypointPayload], error) {
+			result := hookstage.HookResult[hookstage.EntrypointPayload]{}
+			if ctx.Endpoint != hookexecution.EndpointAuction {
+				result.Warnings = []string{fmt.Sprintf("Endpoint %s is not supported by hook.", ctx.Endpoint)}
+				return result, nil
+			}
+
+			ch := &hookstage.ChangeSet[hookstage.EntrypointPayload]{}
+			ch.AddMutation(func(payload hookstage.EntrypointPayload) (hookstage.EntrypointPayload, error) {
+				body, err := jsonpatch.MergePatch(payload.Body, []byte(`{"tmax":50}`))
+				if err == nil {
+					payload.Body = body
+				}
+				return payload, err
+			}, hookstage.MutationUpdate, "body", "tmax")
+			ch.AddMutation(func(payload hookstage.EntrypointPayload) (hookstage.EntrypointPayload, error) {
+				body, err := jsonpatch.MergePatch(payload.Body, []byte(`{"regs": {"ext": {"gdpr": 1, "us_privacy": "1NYN"}}}`))
+				if err == nil {
+					payload.Body = body
+				}
+				return payload, err
+			}, hookstage.MutationAdd, "body", "regs", "ext", "us_privacy")
+			result.ChangeSet = ch
+
+			return result, nil
+		},
+	},
+}
+
+var rawAuctionHookNone = hooks.HookWrapper[hookstage.RawAuctionRequest]{
+	Module: "vendor.module",
+	Code:   "foobar",
+	Hook:   mockUpdateHook{},
+}
+
+type mockUpdateHook struct {
+	entrypointHandler func(
+		hookstage.ModuleInvocationContext,
+		hookstage.EntrypointPayload,
+	) (hookstage.HookResult[hookstage.EntrypointPayload], error)
+}
+
+func (m mockUpdateHook) HandleEntrypointHook(
+	_ context.Context,
+	miCtx hookstage.ModuleInvocationContext,
+	payload hookstage.EntrypointPayload,
+) (hookstage.HookResult[hookstage.EntrypointPayload], error) {
+	return m.entrypointHandler(miCtx, payload)
+}
+
+func (m mockUpdateHook) HandleRawAuctionHook(
+	_ context.Context,
+	_ hookstage.ModuleInvocationContext,
+	_ hookstage.RawAuctionRequestPayload,
+) (hookstage.HookResult[hookstage.RawAuctionRequestPayload], error) {
+	return hookstage.HookResult[hookstage.RawAuctionRequestPayload]{}, nil
+}
diff --git a/hooks/hookexecution/enricher_test.go b/hooks/hookexecution/enricher_test.go
new file mode 100644
index 00000000..ebe86c2c
--- /dev/null
+++ b/hooks/hookexecution/enricher_test.go
@@ -0,0 +1,302 @@
+package hookexecution
+
+import (
+	"encoding/json"
+	"errors"
+	"os"
+	"testing"
+
+	"github.com/prebid/openrtb/v17/openrtb2"
+	"github.com/prebid/prebid-server/config"
+	"github.com/prebid/prebid-server/hooks/hookanalytics"
+	"github.com/prebid/prebid-server/openrtb_ext"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// StageOutcomeTest is used for test purpose instead of the original structure,
+// so we can unmarshal hidden fields such as StageOutcomeTest.Stage and HookOutcomeTest.Errors/Warnings
+type StageOutcomeTest struct {
+	ExecutionTime
+	Entity entity             `json:"entity"`
+	Groups []GroupOutcomeTest `json:"groups"`
+	Stage  string             `json:"stage"`
+}
+
+type GroupOutcomeTest struct {
+	ExecutionTime
+	InvocationResults []HookOutcomeTest `json:"invocation_results"`
+}
+
+type HookOutcomeTest struct {
+	ExecutionTime
+	AnalyticsTags hookanalytics.Analytics `json:"analytics_tags"`
+	HookID        HookID                  `json:"hook_id"`
+	Status        Status                  `json:"status"`
+	Action        Action                  `json:"action"`
+	Message       string                  `json:"message"`
+	DebugMessages []string                `json:"debug_messages"`
+	Errors        []string                `json:"errors"`
+	Warnings      []string                `json:"warnings"`
+}
+
+func TestEnrichBidResponse(t *testing.T) {
+	testCases := []struct {
+		description             string
+		expectedWarnings        []error
+		expectedBidResponseFile string
+		stageOutcomesFile       string
+		bidResponse             *openrtb2.BidResponse
+		bidRequest              *openrtb2.BidRequest
+		account                 *config.Account
+	}{
+		{
+			description:             "BidResponse enriched with verbose trace and debug info when bidRequest.test=1 and trace=verbose",
+			expectedWarnings:        nil,
+			expectedBidResponseFile: "test/complete-stage-outcomes/expected-verbose-debug-response.json",
+			stageOutcomesFile:       "test/complete-stage-outcomes/stage-outcomes.json",
+			bidResponse:             &openrtb2.BidResponse{Ext: []byte(`{"prebid": {"foo": "bar"}}`)},
+			bidRequest:              &openrtb2.BidRequest{Test: 1, Ext: []byte(`{"prebid": {"trace": "verbose"}}`)},
+			account:                 &config.Account{DebugAllow: true},
+		},
+		{
+			description:             "BidResponse enriched with basic trace and debug info when bidRequest.ext.prebid.debug=true and trace=basic",
+			expectedWarnings:        nil,
+			expectedBidResponseFile: "test/complete-stage-outcomes/expected-basic-debug-response.json",
+			stageOutcomesFile:       "test/complete-stage-outcomes/stage-outcomes.json",
+			bidResponse:             &openrtb2.BidResponse{Ext: []byte(`{"prebid": {"foo": "bar"}}`)},
+			bidRequest:              &openrtb2.BidRequest{Ext: []byte(`{"prebid": {"debug": true, "trace": "basic"}}`)},
+			account:                 &config.Account{DebugAllow: true},
+		},
+		{
+			description:             "BidResponse enriched with debug info when bidRequest.ext.prebid.debug=true",
+			expectedWarnings:        nil,
+			expectedBidResponseFile: "test/complete-stage-outcomes/expected-debug-response.json",
+			stageOutcomesFile:       "test/complete-stage-outcomes/stage-outcomes.json",
+			bidResponse:             &openrtb2.BidResponse{Ext: []byte(`{"prebid": {"foo": "bar"}}`)},
+			bidRequest:              &openrtb2.BidRequest{Ext: []byte(`{"prebid": {"debug": true, "trace": ""}}`)},
+			account:                 &config.Account{DebugAllow: true},
+		},
+		{
+			description:             "BidResponse not enriched when bidRequest.ext.prebid.debug=false",
+			expectedWarnings:        nil,
+			expectedBidResponseFile: "test/complete-stage-outcomes/expected-empty-response.json",
+			stageOutcomesFile:       "test/complete-stage-outcomes/stage-outcomes.json",
+			bidResponse:             &openrtb2.BidResponse{Ext: []byte(`{"prebid": {"foo": "bar"}}`)},
+			bidRequest:              &openrtb2.BidRequest{},
+			account:                 &config.Account{DebugAllow: true},
+		},
+		{
+			description:             "BidResponse enriched only with verbose trace when bidRequest.ext.prebid.trace=verbose and account.DebugAllow=false",
+			expectedWarnings:        nil,
+			expectedBidResponseFile: "test/complete-stage-outcomes/expected-verbose-response.json",
+			stageOutcomesFile:       "test/complete-stage-outcomes/stage-outcomes.json",
+			bidResponse:             &openrtb2.BidResponse{Ext: []byte(`{"prebid": {"foo": "bar"}}`)},
+			bidRequest:              &openrtb2.BidRequest{Test: 1, Ext: []byte(`{"prebid": {"debug": true, "trace": "verbose"}}`)},
+			account:                 &config.Account{DebugAllow: false},
+		},
+		{
+			description:             "BidResponse enriched with debug info if bidResponse.Ext is nil",
+			expectedWarnings:        nil,
+			expectedBidResponseFile: "test/complete-stage-outcomes/expected-pure-debug-response.json",
+			stageOutcomesFile:       "test/complete-stage-outcomes/stage-outcomes.json",
+			bidResponse:             &openrtb2.BidResponse{},
+			bidRequest:              &openrtb2.BidRequest{Test: 1},
+			account:                 &config.Account{DebugAllow: true},
+		},
+		{
+			description:             "BidResponse not enriched with modules if stage outcome groups empty",
+			expectedWarnings:        nil,
+			expectedBidResponseFile: "test/empty-stage-outcomes/empty.json",
+			stageOutcomesFile:       "test/empty-stage-outcomes/empty-stage-outcomes-v1.json",
+			bidResponse:             &openrtb2.BidResponse{},
+			bidRequest:              &openrtb2.BidRequest{Test: 1},
+			account:                 &config.Account{DebugAllow: true},
+		},
+		{
+			description:             "BidResponse not enriched with modules if stage outcomes empty",
+			expectedWarnings:        nil,
+			expectedBidResponseFile: "test/empty-stage-outcomes/empty.json",
+			stageOutcomesFile:       "test/empty-stage-outcomes/empty-stage-outcomes-v2.json",
+			bidResponse:             &openrtb2.BidResponse{},
+			bidRequest:              &openrtb2.BidRequest{Test: 1},
+			account:                 &config.Account{DebugAllow: true},
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.description, func(t *testing.T) {
+			expectedResponse := readFile(t, test.expectedBidResponseFile)
+			stageOutcomes := getStageOutcomes(t, test.stageOutcomesFile)
+
+			ext, warns, err := EnrichExtBidResponse(test.bidResponse.Ext, stageOutcomes, test.bidRequest, test.account)
+			require.NoError(t, err, "Failed to enrich BidResponse with hook debug information: %s", err)
+			assert.Equal(t, test.expectedWarnings, warns, "Unexpected warnings")
+
+			test.bidResponse.Ext = ext
+			if test.bidResponse.Ext == nil {
+				assert.Empty(t, expectedResponse)
+			} else {
+				assert.JSONEq(t, string(expectedResponse), string(test.bidResponse.Ext))
+			}
+		})
+	}
+}
+
+func TestGetModulesJSON(t *testing.T) {
+	testCases := []struct {
+		description             string
+		expectedWarnings        []error
+		expectedBidResponseFile string
+		stageOutcomesFile       string
+		bidRequest              *openrtb2.BidRequest
+		account                 *config.Account
+	}{
+		{
+			description:             "Modules Outcome contains verbose trace and debug info when bidRequest.test=1 and trace=verbose",
+			expectedWarnings:        nil,
+			expectedBidResponseFile: "test/complete-stage-outcomes/expected-verbose-debug-response.json",
+			stageOutcomesFile:       "test/complete-stage-outcomes/stage-outcomes.json",
+			bidRequest:              &openrtb2.BidRequest{Test: 1, Ext: []byte(`{"prebid": {"trace": "verbose"}}`)},
+			account:                 &config.Account{DebugAllow: true},
+		},
+		{
+			description:             "Modules Outcome contains verbose trace and debug info when bidRequest.test=1 and trace=verbose and account is not defined",
+			expectedWarnings:        nil,
+			expectedBidResponseFile: "test/complete-stage-outcomes/expected-verbose-debug-response.json",
+			stageOutcomesFile:       "test/complete-stage-outcomes/stage-outcomes.json",
+			bidRequest:              &openrtb2.BidRequest{Test: 1, Ext: []byte(`{"prebid": {"trace": "verbose"}}`)},
+			account:                 nil,
+		},
+		{
+			description:             "Modules Outcome contains basic trace and debug info when bidRequest.ext.prebid.debug=true and trace=basic",
+			expectedWarnings:        nil,
+			expectedBidResponseFile: "test/complete-stage-outcomes/expected-basic-debug-response.json",
+			stageOutcomesFile:       "test/complete-stage-outcomes/stage-outcomes.json",
+			bidRequest:              &openrtb2.BidRequest{Ext: []byte(`{"prebid": {"debug": true, "trace": "basic"}}`)},
+			account:                 &config.Account{DebugAllow: true},
+		},
+		{
+			description:             "Modules Outcome contains debug info when bidRequest.ext.prebid.debug=true",
+			expectedWarnings:        nil,
+			expectedBidResponseFile: "test/complete-stage-outcomes/expected-debug-response.json",
+			stageOutcomesFile:       "test/complete-stage-outcomes/stage-outcomes.json",
+			bidRequest:              &openrtb2.BidRequest{Ext: []byte(`{"prebid": {"debug": true, "trace": ""}}`)},
+			account:                 &config.Account{DebugAllow: true},
+		},
+		{
+			description:             "Modules Outcome empty when bidRequest.ext.prebid.debug=false",
+			expectedWarnings:        nil,
+			expectedBidResponseFile: "test/empty-stage-outcomes/empty.json",
+			stageOutcomesFile:       "test/complete-stage-outcomes/stage-outcomes.json",
+			bidRequest:              &openrtb2.BidRequest{},
+			account:                 &config.Account{DebugAllow: true},
+		},
+		{
+			description:             "Modules Outcome empty when bidRequest is nil",
+			expectedWarnings:        nil,
+			expectedBidResponseFile: "test/empty-stage-outcomes/empty.json",
+			stageOutcomesFile:       "test/complete-stage-outcomes/stage-outcomes.json",
+			bidRequest:              nil,
+			account:                 &config.Account{DebugAllow: true},
+		},
+		{
+			description:             "Modules Outcome contains only verbose trace when bidRequest.ext.prebid.trace=verbose and account.DebugAllow=false",
+			expectedWarnings:        nil,
+			expectedBidResponseFile: "test/complete-stage-outcomes/expected-verbose-response.json",
+			stageOutcomesFile:       "test/complete-stage-outcomes/stage-outcomes.json",
+			bidRequest:              &openrtb2.BidRequest{Test: 1, Ext: []byte(`{"prebid": {"debug": true, "trace": "verbose"}}`)},
+			account:                 &config.Account{DebugAllow: false},
+		},
+		{
+			description:             "Modules Outcome contains debug info if bidResponse.Ext is nil",
+			expectedWarnings:        nil,
+			expectedBidResponseFile: "test/complete-stage-outcomes/expected-pure-debug-response.json",
+			stageOutcomesFile:       "test/complete-stage-outcomes/stage-outcomes.json",
+			bidRequest:              &openrtb2.BidRequest{Test: 1},
+			account:                 &config.Account{DebugAllow: true},
+		},
+		{
+			description:             "Modules Outcome empty if stage outcome groups empty",
+			expectedWarnings:        nil,
+			expectedBidResponseFile: "test/empty-stage-outcomes/empty.json",
+			stageOutcomesFile:       "test/empty-stage-outcomes/empty-stage-outcomes-v1.json",
+			bidRequest:              &openrtb2.BidRequest{Test: 1},
+			account:                 &config.Account{DebugAllow: true},
+		},
+		{
+			description:             "Modules Outcome empty if stage outcomes empty",
+			expectedWarnings:        nil,
+			expectedBidResponseFile: "test/empty-stage-outcomes/empty.json",
+			stageOutcomesFile:       "test/empty-stage-outcomes/empty-stage-outcomes-v2.json",
+			bidRequest:              &openrtb2.BidRequest{Test: 1},
+			account:                 &config.Account{DebugAllow: true},
+		},
+		{
+			description: "Warnings returned if debug info invalid",
+			expectedWarnings: []error{
+				errors.New("Value is not a string: 1"),
+				errors.New("Value is not a boolean: active"),
+			},
+			expectedBidResponseFile: "test/complete-stage-outcomes/expected-pure-debug-response.json",
+			stageOutcomesFile:       "test/complete-stage-outcomes/stage-outcomes.json",
+			bidRequest:              &openrtb2.BidRequest{Test: 1, Ext: json.RawMessage(`{"prebid": {"debug": "active", "trace": 1}}`)},
+			account:                 &config.Account{DebugAllow: true},
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.description, func(t *testing.T) {
+			expectedResponse := readFile(t, test.expectedBidResponseFile)
+			stageOutcomes := getStageOutcomes(t, test.stageOutcomesFile)
+
+			modules, warns, err := GetModulesJSON(stageOutcomes, test.bidRequest, test.account)
+			require.NoError(t, err, "Failed to get modules outcome as json: %s", err)
+			assert.Equal(t, test.expectedWarnings, warns, "Unexpected warnings")
+
+			if modules == nil {
+				assert.Empty(t, expectedResponse)
+			} else {
+				var expectedExtBidResponse openrtb_ext.ExtBidResponse
+				err := json.Unmarshal(expectedResponse, &expectedExtBidResponse)
+				assert.NoError(t, err, "Failed to unmarshal prebid response extension")
+				assert.JSONEq(t, string(expectedExtBidResponse.Prebid.Modules), string(modules))
+			}
+		})
+	}
+}
+
+func getStageOutcomes(t *testing.T, file string) []StageOutcome {
+	var stageOutcomes []StageOutcome
+	var stageOutcomesTest []StageOutcomeTest
+
+	data := readFile(t, file)
+	err := json.Unmarshal(data, &stageOutcomesTest)
+	require.NoError(t, err, "Failed to unmarshal stage outcomes: %s", err)
+
+	for _, stageT := range stageOutcomesTest {
+		stage := StageOutcome{
+			ExecutionTime: stageT.ExecutionTime,
+			Entity:        stageT.Entity,
+			Stage:         stageT.Stage,
+		}
+
+		for _, groupT := range stageT.Groups {
+			group := GroupOutcome{ExecutionTime: groupT.ExecutionTime}
+			for _, hookT := range groupT.InvocationResults {
+				group.InvocationResults = append(group.InvocationResults, HookOutcome(hookT))
+			}
+
+			stage.Groups = append(stage.Groups, group)
+		}
+		stageOutcomes = append(stageOutcomes, stage)
+	}
+
+	return stageOutcomes
+}
+
+func readFile(t *testing.T, filename string) []byte {
+	data, err := os.ReadFile(filename)
+	require.NoError(t, err, "Failed to read file %s: %v", filename, err)
+	return data
+}
diff --git a/hooks/hookexecution/outcome_test.go b/hooks/hookexecution/outcome_test.go
deleted file mode 100644
index de81d7b0..00000000
--- a/hooks/hookexecution/outcome_test.go
+++ /dev/null
@@ -1,51 +0,0 @@
-package hookexecution
-
-import (
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-)
-
-func assertEqualStageOutcomes(t *testing.T, expected StageOutcome, actual StageOutcome) {
-	t.Helper()
-
-	assert.Equal(t, len(actual.Groups), len(expected.Groups), "Stage outcomes contain different number of groups")
-
-	// calculate expected timings from actual outcome
-	for i, group := range actual.Groups {
-		expected.ExecutionTimeMillis += group.ExecutionTimeMillis
-		for _, hook := range group.InvocationResults {
-			if hook.ExecutionTimeMillis > expected.Groups[i].ExecutionTimeMillis {
-				expected.Groups[i].ExecutionTimeMillis = hook.ExecutionTimeMillis
-			}
-		}
-	}
-
-	assert.Equal(t, expected.ExecutionTimeMillis, actual.ExecutionTimeMillis, "Incorrect stage execution time")
-	assert.Equal(t, expected.Stage, actual.Stage, "Incorrect stage name")
-	assert.Equal(t, expected.Entity, actual.Entity, "Incorrect stage entity name")
-
-	for i, expGroup := range expected.Groups {
-		gotGroup := actual.Groups[i]
-		assert.Equal(t, len(expGroup.InvocationResults), len(gotGroup.InvocationResults), "Group outcomes #%d contain different number of invocation results", i)
-		assert.Equal(t, expGroup.ExecutionTimeMillis, gotGroup.ExecutionTimeMillis, "Incorrect group #%d execution time", i)
-
-		for _, expHook := range expGroup.InvocationResults {
-			gotHook := findCorrespondingHookResult(expHook.HookID, gotGroup)
-			assert.NotNil(t, gotHook, "Expected to get hook, got nil: group #%d, hookID %v", i, expHook.HookID)
-
-			gotHook.ExecutionTimeMillis = 0 // reset hook execution time, we cannot predict it
-			assert.Equal(t, expHook, *gotHook, "Incorrect hook outcome: group #%d, hookID %v", i, expHook.HookID)
-		}
-	}
-}
-
-func findCorrespondingHookResult(hookID HookID, group GroupOutcome) *HookOutcome {
-	for _, hook := range group.InvocationResults {
-		if hook.HookID.ModuleCode == hookID.ModuleCode &&
-			hook.HookID.HookImplCode == hookID.HookImplCode {
-			return &hook
-		}
-	}
-	return nil
-}
diff --git a/hooks/hookexecution/test/complete-stage-outcomes/expected-basic-debug-response.json b/hooks/hookexecution/test/complete-stage-outcomes/expected-basic-debug-response.json
new file mode 100644
index 00000000..1f52d187
--- /dev/null
+++ b/hooks/hookexecution/test/complete-stage-outcomes/expected-basic-debug-response.json
@@ -0,0 +1,151 @@
+{
+  "prebid": {
+    "foo": "bar",
+    "modules": {
+      "errors": {
+        "foobar": {
+          "foo": [
+            "error 1"
+          ],
+          "bar": [
+            "error 2",
+            "error 4"
+          ]
+        },
+        "foobaz": {
+          "baz": [
+            "error 3"
+          ]
+        }
+      },
+      "warnings": {
+        "foobaz": {
+          "baz": [
+            "warning 1"
+          ]
+        }
+      },
+      "trace": {
+        "execution_time_millis": 600,
+        "stages": [
+          {
+            "stage": "entrypoint",
+            "execution_time_millis": 300,
+            "outcomes": [
+              {
+                "entity": "http-request",
+                "execution_time_millis": 300,
+                "groups": [
+                  {
+                    "execution_time_millis": 200,
+                    "invocation_results": [
+                      {
+                        "hook_id": {
+                          "module_code": "foobar",
+                          "hook_impl_code": "foo"
+                        },
+                        "status": "success",
+                        "action": "update",
+                        "execution_time_millis": 200,
+                        "analytics_tags": {},
+                        "message": ""
+                      },
+                      {
+                        "hook_id": {
+                          "module_code": "foobar",
+                          "hook_impl_code": "bar"
+                        },
+                        "status": "success",
+                        "action": "update",
+                        "execution_time_millis": 50,
+                        "analytics_tags": {},
+                        "message": ""
+                      }
+                    ]
+                  },
+                  {
+                    "execution_time_millis": 100,
+                    "invocation_results": [
+                      {
+                        "hook_id": {
+                          "module_code": "foobaz",
+                          "hook_impl_code": "baz"
+                        },
+                        "status": "success",
+                        "action": "update",
+                        "execution_time_millis": 100,
+                        "analytics_tags": {},
+                        "message": ""
+                      },
+                      {
+                        "hook_id": {
+                          "module_code": "foobar",
+                          "hook_impl_code": "bar"
+                        },
+                        "status": "success",
+                        "action": "update",
+                        "execution_time_millis": 10,
+                        "analytics_tags": {},
+                        "message": ""
+                      }
+                    ]
+                  }
+                ]
+              }
+            ]
+          },
+          {
+            "stage": "bidder_request",
+            "execution_time_millis": 300,
+            "outcomes": [
+              {
+                "entity": "adf",
+                "execution_time_millis": 200,
+                "groups": [
+                  {
+                    "execution_time_millis": 200,
+                    "invocation_results": [
+                      {
+                        "hook_id": {
+                          "module_code": "foobar",
+                          "hook_impl_code": "foo"
+                        },
+                        "status": "success",
+                        "action": "update",
+                        "execution_time_millis": 200,
+                        "analytics_tags": {},
+                        "message": ""
+                      }
+                    ]
+                  }
+                ]
+              },
+              {
+                "entity": "adot",
+                "execution_time_millis": 300,
+                "groups": [
+                  {
+                    "execution_time_millis": 200,
+                    "invocation_results": [
+                      {
+                        "hook_id": {
+                          "module_code": "foobar",
+                          "hook_impl_code": "bar"
+                        },
+                        "status": "success",
+                        "action": "no_action",
+                        "execution_time_millis": 200,
+                        "analytics_tags": {},
+                        "message": ""
+                      }
+                    ]
+                  }
+                ]
+              }
+            ]
+          }
+        ]
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git a/hooks/hookexecution/test/complete-stage-outcomes/expected-debug-response.json b/hooks/hookexecution/test/complete-stage-outcomes/expected-debug-response.json
new file mode 100644
index 00000000..2b119fc3
--- /dev/null
+++ b/hooks/hookexecution/test/complete-stage-outcomes/expected-debug-response.json
@@ -0,0 +1,30 @@
+{
+  "prebid": {
+    "foo": "bar",
+    "modules": {
+      "errors": {
+        "foobar": {
+          "foo": [
+            "error 1"
+          ],
+          "bar": [
+            "error 2",
+            "error 4"
+          ]
+        },
+        "foobaz": {
+          "baz": [
+            "error 3"
+          ]
+        }
+      },
+      "warnings": {
+        "foobaz": {
+          "baz": [
+            "warning 1"
+          ]
+        }
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git a/hooks/hookexecution/test/complete-stage-outcomes/expected-empty-response.json b/hooks/hookexecution/test/complete-stage-outcomes/expected-empty-response.json
new file mode 100644
index 00000000..a23b7652
--- /dev/null
+++ b/hooks/hookexecution/test/complete-stage-outcomes/expected-empty-response.json
@@ -0,0 +1,5 @@
+{
+  "prebid": {
+    "foo": "bar"
+  }
+}
\ No newline at end of file
diff --git a/hooks/hookexecution/test/complete-stage-outcomes/expected-pure-debug-response.json b/hooks/hookexecution/test/complete-stage-outcomes/expected-pure-debug-response.json
new file mode 100644
index 00000000..7336aff0
--- /dev/null
+++ b/hooks/hookexecution/test/complete-stage-outcomes/expected-pure-debug-response.json
@@ -0,0 +1,29 @@
+{
+  "prebid": {
+    "modules": {
+      "errors": {
+        "foobar": {
+          "foo": [
+            "error 1"
+          ],
+          "bar": [
+            "error 2",
+            "error 4"
+          ]
+        },
+        "foobaz": {
+          "baz": [
+            "error 3"
+          ]
+        }
+      },
+      "warnings": {
+        "foobaz": {
+          "baz": [
+            "warning 1"
+          ]
+        }
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git a/hooks/hookexecution/test/complete-stage-outcomes/expected-verbose-debug-response.json b/hooks/hookexecution/test/complete-stage-outcomes/expected-verbose-debug-response.json
new file mode 100644
index 00000000..9825d1fd
--- /dev/null
+++ b/hooks/hookexecution/test/complete-stage-outcomes/expected-verbose-debug-response.json
@@ -0,0 +1,186 @@
+{
+  "prebid": {
+    "foo": "bar",
+    "modules": {
+      "errors": {
+        "foobar": {
+          "foo": [
+            "error 1"
+          ],
+          "bar": [
+            "error 2",
+            "error 4"
+          ]
+        },
+        "foobaz": {
+          "baz": [
+            "error 3"
+          ]
+        }
+      },
+      "warnings": {
+        "foobaz": {
+          "baz": [
+            "warning 1"
+          ]
+        }
+      },
+      "trace": {
+        "execution_time_millis": 600,
+        "stages": [
+          {
+            "stage": "entrypoint",
+            "execution_time_millis": 300,
+            "outcomes": [
+              {
+                "entity": "http-request",
+                "execution_time_millis": 300,
+                "groups": [
+                  {
+                    "execution_time_millis": 200,
+                    "invocation_results": [
+                      {
+                        "hook_id": {
+                          "module_code": "foobar",
+                          "hook_impl_code": "foo"
+                        },
+                        "status": "success",
+                        "action": "update",
+                        "execution_time_millis": 200,
+                        "analytics_tags": {
+                          "activities": [
+                            {
+                              "name": "device-id",
+                              "status": "success",
+                              "results": [
+                                {
+                                  "status": "success-allow",
+                                  "values": {
+                                    "foo": "bar"
+                                  },
+                                  "appliedto": {
+                                    "impids": [
+                                      "impId1"
+                                    ],
+                                    "request": true
+                                  }
+                                }
+                              ]
+                            },
+                            {
+                              "name": "define-blocks",
+                              "status": "error"
+                            }
+                          ]
+                        },
+                        "debug_messages": [
+                          "debug message 1"
+                        ],
+                        "message": ""
+                      },
+                      {
+                        "hook_id": {
+                          "module_code": "foobar",
+                          "hook_impl_code": "bar"
+                        },
+                        "status": "success",
+                        "action": "update",
+                        "execution_time_millis": 50,
+                        "analytics_tags": {},
+                        "message": ""
+                      }
+                    ]
+                  },
+                  {
+                    "execution_time_millis": 100,
+                    "invocation_results": [
+                      {
+                        "hook_id": {
+                          "module_code": "foobaz",
+                          "hook_impl_code": "baz"
+                        },
+                        "status": "success",
+                        "action": "update",
+                        "execution_time_millis": 100,
+                        "analytics_tags": {},
+                        "debug_messages": [
+                          "debug message 1",
+                          "debug message 2"
+                        ],
+                        "message": ""
+                      },
+                      {
+                        "hook_id": {
+                          "module_code": "foobar",
+                          "hook_impl_code": "bar"
+                        },
+                        "status": "success",
+                        "action": "update",
+                        "execution_time_millis": 10,
+                        "analytics_tags": {},
+                        "message": ""
+                      }
+                    ]
+                  }
+                ]
+              }
+            ]
+          },
+          {
+            "stage": "bidder_request",
+            "execution_time_millis": 300,
+            "outcomes": [
+              {
+                "entity": "adf",
+                "execution_time_millis": 200,
+                "groups": [
+                  {
+                    "execution_time_millis": 200,
+                    "invocation_results": [
+                      {
+                        "hook_id": {
+                          "module_code": "foobar",
+                          "hook_impl_code": "foo"
+                        },
+                        "status": "success",
+                        "action": "update",
+                        "execution_time_millis": 200,
+                        "analytics_tags": {},
+                        "debug_messages": [
+                          "debug message 1"
+                        ],
+                        "message": ""
+                      }
+                    ]
+                  }
+                ]
+              },
+              {
+                "entity": "adot",
+                "execution_time_millis": 300,
+                "groups": [
+                  {
+                    "execution_time_millis": 200,
+                    "invocation_results": [
+                      {
+                        "hook_id": {
+                          "module_code": "foobar",
+                          "hook_impl_code": "bar"
+                        },
+                        "status": "success",
+                        "action": "no_action",
+                        "execution_time_millis": 200,
+                        "analytics_tags": {},
+                        "message": ""
+                      }
+                    ]
+                  }
+                ]
+              }
+            ]
+          }
+        ]
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git a/hooks/hookexecution/test/complete-stage-outcomes/expected-verbose-response.json b/hooks/hookexecution/test/complete-stage-outcomes/expected-verbose-response.json
new file mode 100644
index 00000000..8561d352
--- /dev/null
+++ b/hooks/hookexecution/test/complete-stage-outcomes/expected-verbose-response.json
@@ -0,0 +1,163 @@
+{
+  "prebid": {
+    "foo": "bar",
+    "modules": {
+      "trace": {
+        "execution_time_millis": 600,
+        "stages": [
+          {
+            "stage": "entrypoint",
+            "execution_time_millis": 300,
+            "outcomes": [
+              {
+                "entity": "http-request",
+                "execution_time_millis": 300,
+                "groups": [
+                  {
+                    "execution_time_millis": 200,
+                    "invocation_results": [
+                      {
+                        "hook_id": {
+                          "module_code": "foobar",
+                          "hook_impl_code": "foo"
+                        },
+                        "status": "success",
+                        "action": "update",
+                        "execution_time_millis": 200,
+                        "analytics_tags": {
+                          "activities": [
+                            {
+                              "name": "device-id",
+                              "status": "success",
+                              "results": [
+                                {
+                                  "status": "success-allow",
+                                  "values": {
+                                    "foo": "bar"
+                                  },
+                                  "appliedto": {
+                                    "impids": [
+                                      "impId1"
+                                    ],
+                                    "request": true
+                                  }
+                                }
+                              ]
+                            },
+                            {
+                              "name": "define-blocks",
+                              "status": "error"
+                            }
+                          ]
+                        },
+                        "debug_messages": [
+                          "debug message 1"
+                        ],
+                        "message": ""
+                      },
+                      {
+                        "hook_id": {
+                          "module_code": "foobar",
+                          "hook_impl_code": "bar"
+                        },
+                        "status": "success",
+                        "action": "update",
+                        "execution_time_millis": 50,
+                        "analytics_tags": {},
+                        "message": ""
+                      }
+                    ]
+                  },
+                  {
+                    "execution_time_millis": 100,
+                    "invocation_results": [
+                      {
+                        "hook_id": {
+                          "module_code": "foobaz",
+                          "hook_impl_code": "baz"
+                        },
+                        "status": "success",
+                        "action": "update",
+                        "execution_time_millis": 100,
+                        "analytics_tags": {},
+                        "debug_messages": [
+                          "debug message 1",
+                          "debug message 2"
+                        ],
+                        "message": ""
+                      },
+                      {
+                        "hook_id": {
+                          "module_code": "foobar",
+                          "hook_impl_code": "bar"
+                        },
+                        "status": "success",
+                        "action": "update",
+                        "execution_time_millis": 10,
+                        "analytics_tags": {},
+                        "message": ""
+                      }
+                    ]
+                  }
+                ]
+              }
+            ]
+          },
+          {
+            "stage": "bidder_request",
+            "execution_time_millis": 300,
+            "outcomes": [
+              {
+                "entity": "adf",
+                "execution_time_millis": 200,
+                "groups": [
+                  {
+                    "execution_time_millis": 200,
+                    "invocation_results": [
+                      {
+                        "hook_id": {
+                          "module_code": "foobar",
+                          "hook_impl_code": "foo"
+                        },
+                        "status": "success",
+                        "action": "update",
+                        "execution_time_millis": 200,
+                        "analytics_tags": {},
+                        "debug_messages": [
+                          "debug message 1"
+                        ],
+                        "message": ""
+                      }
+                    ]
+                  }
+                ]
+              },
+              {
+                "entity": "adot",
+                "execution_time_millis": 300,
+                "groups": [
+                  {
+                    "execution_time_millis": 200,
+                    "invocation_results": [
+                      {
+                        "hook_id": {
+                          "module_code": "foobar",
+                          "hook_impl_code": "bar"
+                        },
+                        "status": "success",
+                        "action": "no_action",
+                        "execution_time_millis": 200,
+                        "analytics_tags": {},
+                        "message": ""
+                      }
+                    ]
+                  }
+                ]
+              }
+            ]
+          }
+        ]
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git a/hooks/hookexecution/test/complete-stage-outcomes/stage-outcomes.json b/hooks/hookexecution/test/complete-stage-outcomes/stage-outcomes.json
new file mode 100644
index 00000000..62a6f822
--- /dev/null
+++ b/hooks/hookexecution/test/complete-stage-outcomes/stage-outcomes.json
@@ -0,0 +1,159 @@
+[
+  {
+    "entity": "http-request",
+    "stage": "entrypoint",
+    "execution_time_millis": 300,
+    "groups": [
+      {
+        "execution_time_millis": 200,
+        "invocation_results": [
+          {
+            "hook_id": {
+              "module_code": "foobar",
+              "hook_impl_code": "foo"
+            },
+            "status": "success",
+            "action": "update",
+            "execution_time_millis": 200,
+            "analytics_tags": {
+              "activities": [
+                {
+                  "name": "device-id",
+                  "status": "success",
+                  "results": [
+                    {
+                      "status": "success-allow",
+                      "values": {
+                        "foo": "bar"
+                      },
+                      "appliedto": {
+                        "impids": [
+                          "impId1"
+                        ],
+                        "request": true
+                      }
+                    }
+                  ]
+                },
+                {
+                  "name": "define-blocks",
+                  "status": "error"
+                }
+              ]
+            },
+            "debug_messages": [
+              "debug message 1"
+            ],
+            "errors": [
+              "error 1"
+            ],
+            "message": ""
+          },
+          {
+            "hook_id": {
+              "module_code": "foobar",
+              "hook_impl_code": "bar"
+            },
+            "status": "success",
+            "action": "update",
+            "execution_time_millis": 50,
+            "analytics_tags": {},
+            "errors": [
+              "error 2"
+            ],
+            "message": ""
+          }
+        ]
+      },
+      {
+        "execution_time_millis": 100,
+        "invocation_results": [
+          {
+            "hook_id": {
+              "module_code": "foobaz",
+              "hook_impl_code": "baz"
+            },
+            "status": "success",
+            "action": "update",
+            "execution_time_millis": 100,
+            "analytics_tags": {},
+            "debug_messages": [
+              "debug message 1",
+              "debug message 2"
+            ],
+            "errors": [
+              "error 3"
+            ],
+            "warnings": [
+              "warning 1"
+            ],
+            "message": ""
+          },
+          {
+            "hook_id": {
+              "module_code": "foobar",
+              "hook_impl_code": "bar"
+            },
+            "status": "success",
+            "action": "update",
+            "execution_time_millis": 10,
+            "analytics_tags": {},
+            "errors": [
+              "error 4"
+            ],
+            "message": ""
+          }
+        ]
+      }
+    ]
+  },
+  {
+    "entity": "adf",
+    "stage": "bidder_request",
+    "execution_time_millis": 200,
+    "groups": [
+      {
+        "execution_time_millis": 200,
+        "invocation_results": [
+          {
+            "hook_id": {
+              "module_code": "foobar",
+              "hook_impl_code": "foo"
+            },
+            "status": "success",
+            "action": "update",
+            "execution_time_millis": 200,
+            "analytics_tags": {},
+            "debug_messages": [
+              "debug message 1"
+            ],
+            "message": ""
+          }
+        ]
+      }
+    ]
+  },
+  {
+    "entity": "adot",
+    "stage": "bidder_request",
+    "execution_time_millis": 300,
+    "groups": [
+      {
+        "execution_time_millis": 200,
+        "invocation_results": [
+          {
+            "hook_id": {
+              "module_code": "foobar",
+              "hook_impl_code": "bar"
+            },
+            "status": "success",
+            "action": "no_action",
+            "execution_time_millis": 200,
+            "analytics_tags": {},
+            "message": ""
+          }
+        ]
+      }
+    ]
+  }
+]
diff --git a/hooks/hookexecution/test/empty-stage-outcomes/empty-stage-outcomes-v1.json b/hooks/hookexecution/test/empty-stage-outcomes/empty-stage-outcomes-v1.json
new file mode 100644
index 00000000..165c4d9f
--- /dev/null
+++ b/hooks/hookexecution/test/empty-stage-outcomes/empty-stage-outcomes-v1.json
@@ -0,0 +1,8 @@
+[
+  {
+    "entity": "http-request",
+    "stage": "entrypoint",
+    "execution_time_millis": 0,
+    "groups": []
+  }
+]
\ No newline at end of file
diff --git a/hooks/hookexecution/test/empty-stage-outcomes/empty-stage-outcomes-v2.json b/hooks/hookexecution/test/empty-stage-outcomes/empty-stage-outcomes-v2.json
new file mode 100644
index 00000000..0637a088
--- /dev/null
+++ b/hooks/hookexecution/test/empty-stage-outcomes/empty-stage-outcomes-v2.json
@@ -0,0 +1,1 @@
+[]
\ No newline at end of file
diff --git a/hooks/hookexecution/test/empty-stage-outcomes/empty.json b/hooks/hookexecution/test/empty-stage-outcomes/empty.json
new file mode 100644
index 00000000..e69de29b
diff --git a/hooks/hookexecution/test_utils.go b/hooks/hookexecution/test_utils.go
new file mode 100644
index 00000000..bd94d577
--- /dev/null
+++ b/hooks/hookexecution/test_utils.go
@@ -0,0 +1,105 @@
+package hookexecution
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+// AssertEqualModulesData is the test helper function which asserts
+// that expected modules data fully corresponds the actual modules data.
+// Dynamic data for execution time is calculated from actual modules data.
+func AssertEqualModulesData(t *testing.T, expectedData, actualData json.RawMessage) {
+	t.Helper()
+
+	var expectedModulesOutcome ModulesOutcome
+	var actualModulesOutcome ModulesOutcome
+
+	assert.NoError(t, json.Unmarshal(expectedData, &expectedModulesOutcome), "Failed to unmarshal expected modules data.")
+	assert.NoError(t, json.Unmarshal(actualData, &actualModulesOutcome), "Failed to unmarshal actual modules data.")
+	assert.Equal(t, expectedModulesOutcome.Errors, actualModulesOutcome.Errors, "Invalid error messages.")
+	assert.Equal(t, expectedModulesOutcome.Warnings, actualModulesOutcome.Warnings, "Invalid warning messages.")
+
+	assertEqualTraces(t, expectedModulesOutcome.Trace, actualModulesOutcome.Trace)
+}
+
+func assertEqualTraces(t *testing.T, expectedTrace *TraceOutcome, actualTrace *TraceOutcome) {
+	if expectedTrace == nil {
+		assert.Nil(t, actualTrace, "Nil trace not expected.")
+	}
+
+	// calculate expected timings from actual modules outcome
+	for i, actualStage := range actualTrace.Stages {
+		expectedStage := expectedTrace.Stages[i]
+		expectedTrace.ExecutionTimeMillis += actualStage.ExecutionTimeMillis
+
+		for _, actualOutcome := range actualStage.Outcomes {
+			if expectedStage.ExecutionTimeMillis < actualOutcome.ExecutionTimeMillis {
+				expectedStage.ExecutionTimeMillis = actualOutcome.ExecutionTimeMillis
+			}
+
+			expectedOutcome := findCorrespondingStageOutcome(expectedStage, actualOutcome)
+			assert.NotNil(t, expectedOutcome, "Not found corresponding stage outcome, actual:`", actualOutcome)
+			assertEqualStageOutcomes(t, *expectedOutcome, actualOutcome)
+		}
+
+		assert.Equal(t, expectedStage.Stage, actualStage.Stage, "Invalid stage name.")
+		assert.Equal(t, expectedStage.ExecutionTimeMillis, actualStage.ExecutionTimeMillis, "Invalid stage execution time.")
+	}
+
+	assert.Equal(t, expectedTrace.ExecutionTimeMillis, actualTrace.ExecutionTimeMillis, "Invalid trace execution time.")
+}
+
+func assertEqualStageOutcomes(t *testing.T, expected StageOutcome, actual StageOutcome) {
+	t.Helper()
+
+	assert.Equal(t, len(actual.Groups), len(expected.Groups), "Stage outcomes contain different number of groups")
+
+	// calculate expected timings from actual outcome
+	for i, group := range actual.Groups {
+		expected.ExecutionTimeMillis += group.ExecutionTimeMillis
+		for _, hook := range group.InvocationResults {
+			if hook.ExecutionTimeMillis > expected.Groups[i].ExecutionTimeMillis {
+				expected.Groups[i].ExecutionTimeMillis = hook.ExecutionTimeMillis
+			}
+		}
+	}
+
+	assert.Equal(t, expected.ExecutionTimeMillis, actual.ExecutionTimeMillis, "Incorrect stage execution time")
+	assert.Equal(t, expected.Stage, actual.Stage, "Incorrect stage name")
+	assert.Equal(t, expected.Entity, actual.Entity, "Incorrect stage entity name")
+
+	for i, expGroup := range expected.Groups {
+		gotGroup := actual.Groups[i]
+		assert.Equal(t, len(expGroup.InvocationResults), len(gotGroup.InvocationResults), "Group outcomes #%d contain different number of invocation results", i)
+		assert.Equal(t, expGroup.ExecutionTimeMillis, gotGroup.ExecutionTimeMillis, "Incorrect group #%d execution time", i)
+
+		for _, expHook := range expGroup.InvocationResults {
+			gotHook := findCorrespondingHookResult(expHook.HookID, gotGroup)
+			assert.NotNil(t, gotHook, "Expected to get hook, got nil: group #%d, hookID %v", i, expHook.HookID)
+
+			gotHook.ExecutionTimeMillis = 0 // reset hook execution time, we cannot predict it
+			assert.Equal(t, expHook, *gotHook, "Incorrect hook outcome: group #%d, hookID %v", i, expHook.HookID)
+		}
+	}
+}
+
+func findCorrespondingStageOutcome(stage Stage, outcome StageOutcome) *StageOutcome {
+	for _, out := range stage.Outcomes {
+		if out.Entity == outcome.Entity {
+			return &out
+		}
+	}
+	return nil
+}
+
+func findCorrespondingHookResult(hookID HookID, group GroupOutcome) *HookOutcome {
+	for _, hook := range group.InvocationResults {
+		if hook.HookID.ModuleCode == hookID.ModuleCode &&
+			hook.HookID.HookImplCode == hookID.HookImplCode {
+			return &hook
+		}
+	}
+	return nil
+}