Repository: prebid/prebid-server
Pull request #2719 (2023-04-24 20:58:37)

New Adapter: FRVR Ad Network
Docs
- https://github.com/prebid/prebid.github.io/pull/4515


Patch:
diff --git a/adapters/frvradn/frvradn.go b/adapters/frvradn/frvradn.go
new file mode 100644
index 00000000..3954343e
--- /dev/null
+++ b/adapters/frvradn/frvradn.go
@@ -0,0 +1,151 @@
+package frvradn
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"github.com/prebid/openrtb/v19/openrtb2"
+	"github.com/prebid/prebid-server/adapters"
+	"github.com/prebid/prebid-server/config"
+	"github.com/prebid/prebid-server/errortypes"
+	"github.com/prebid/prebid-server/openrtb_ext"
+	"github.com/prebid/prebid-server/util/httputil"
+	"strings"
+)
+
+type adapter struct {
+	uri string
+}
+
+func Builder(bidderName openrtb_ext.BidderName, config config.Adapter, server config.Server) (adapters.Bidder, error) {
+	if config.Endpoint == "" {
+		return nil, errors.New("missing endpoint adapter parameter")
+	}
+
+	bidder := &adapter{
+		uri: config.Endpoint,
+	}
+	return bidder, nil
+}
+
+func (a *adapter) MakeRequests(request *openrtb2.BidRequest, requestInfo *adapters.ExtraRequestInfo) ([]*adapters.RequestData, []error) {
+	var requests []*adapters.RequestData
+	var errs []error
+
+	requestCopy := *request
+	for _, imp := range request.Imp {
+		frvrAdnExt, err := getImpressionExt(&imp)
+		if err != nil {
+			errs = append(errs, err)
+			continue
+		}
+
+		if imp.BidFloor > 0 && imp.BidFloorCur != "" && strings.ToUpper(imp.BidFloorCur) != "USD" {
+			convertedValue, err := requestInfo.ConvertCurrency(imp.BidFloor, imp.BidFloorCur, "USD")
+			if err != nil {
+				errs = append(errs, err)
+				continue
+			}
+			imp.BidFloorCur = "USD"
+			imp.BidFloor = convertedValue
+		}
+
+		ext, err := json.Marshal(frvrAdnExt)
+		if err != nil {
+			errs = append(errs, err)
+			continue
+		}
+		imp.Ext = ext
+
+		requestCopy.Imp = []openrtb2.Imp{imp}
+
+		requestJSON, err := json.Marshal(requestCopy)
+		if err != nil {
+			errs = append(errs, err)
+			continue
+		}
+
+		requestData := &adapters.RequestData{
+			Method: "POST",
+			Uri:    a.uri,
+			Body:   requestJSON,
+		}
+		requests = append(requests, requestData)
+	}
+	return requests, errs
+}
+
+func (a *adapter) MakeBids(request *openrtb2.BidRequest, requestData *adapters.RequestData, responseData *adapters.ResponseData) (*adapters.BidderResponse, []error) {
+	if httputil.IsResponseStatusCodeNoContent(responseData) {
+		return nil, nil
+	}
+
+	if err := httputil.CheckResponseStatusCodeForErrors(responseData); err != nil {
+		return nil, []error{err}
+	}
+
+	if len(responseData.Body) == 0 {
+		return nil, nil
+	}
+
+	var response openrtb2.BidResponse
+	if err := json.Unmarshal(responseData.Body, &response); err != nil {
+		return nil, []error{err}
+	}
+
+	bidResponse := adapters.NewBidderResponseWithBidsCapacity(len(request.Imp))
+	bidResponse.Currency = response.Cur
+	var errs []error
+	for _, seatBid := range response.SeatBid {
+		for i := range seatBid.Bid {
+			bidType, err := getBidMediaType(&seatBid.Bid[i])
+			if err != nil {
+				errs = append(errs, err)
+				continue
+			}
+
+			b := &adapters.TypedBid{
+				Bid:     &seatBid.Bid[i],
+				BidType: bidType,
+			}
+			bidResponse.Bids = append(bidResponse.Bids, b)
+		}
+	}
+	return bidResponse, errs
+}
+
+func getImpressionExt(imp *openrtb2.Imp) (*openrtb_ext.ImpExtFRVRAdn, error) {
+	var extImpBidder adapters.ExtImpBidder
+	if err := json.Unmarshal(imp.Ext, &extImpBidder); err != nil {
+		return nil, &errortypes.BadInput{
+			Message: "missing ext",
+		}
+	}
+	var frvrAdnExt openrtb_ext.ImpExtFRVRAdn
+	if err := json.Unmarshal(extImpBidder.Bidder, &frvrAdnExt); err != nil {
+		return nil, &errortypes.BadInput{
+			Message: "missing ext.bidder",
+		}
+	}
+
+	if len(frvrAdnExt.PublisherID) == 0 || len(frvrAdnExt.AdUnitID) == 0 {
+		return nil, &errortypes.BadInput{
+			Message: "publisher_id and ad_unit_id are required",
+		}
+	}
+	return &frvrAdnExt, nil
+}
+
+func getBidMediaType(bid *openrtb2.Bid) (openrtb_ext.BidType, error) {
+	var extBid openrtb_ext.ExtBid
+	err := json.Unmarshal(bid.Ext, &extBid)
+	if err != nil {
+		return "", fmt.Errorf("unable to deserialize imp %v bid.ext", bid.ImpID)
+	}
+
+	if extBid.Prebid == nil {
+		return "", fmt.Errorf("imp %v with unknown media type", bid.ImpID)
+	}
+
+	return extBid.Prebid.Type, nil
+}
diff --git a/adapters/richaudience/richaudience.go b/adapters/richaudience/richaudience.go
index 71356bf2..16fbe229 100644
--- a/adapters/richaudience/richaudience.go
+++ b/adapters/richaudience/richaudience.go
@@ -52,10 +52,16 @@ func (a *adapter) MakeRequests(request *openrtb2.BidRequest, requestInfo *adapte
 		}
 
 		if request.App != nil {
+			appCopy := *request.App
+			request.App = &appCopy
+
 			request.App.Keywords = "tagid=" + imp.TagID
 		}
 
 		if request.Site != nil {
+			siteCopy := *request.Site
+			request.Site = &siteCopy
+
 			request.Site.Keywords = "tagid=" + imp.TagID
 		}
 
@@ -65,6 +71,13 @@ func (a *adapter) MakeRequests(request *openrtb2.BidRequest, requestInfo *adapte
 			}
 
 			if raiExt.Test {
+				if request.Device != nil {
+					deviceCopy := *request.Device
+					request.Device = &deviceCopy
+				} else {
+					request.Device = &openrtb2.Device{}
+				}
+
 				request.Device.IP = "11.222.33.44"
 				request.Test = int8(1)
 			}
diff --git a/endpoints/openrtb2/auction.go b/endpoints/openrtb2/auction.go
index efc74613..2811a9e7 100644
--- a/endpoints/openrtb2/auction.go
+++ b/endpoints/openrtb2/auction.go
@@ -1670,11 +1670,9 @@ func (deps *endpointDeps) validateUser(req *openrtb_ext.RequestWrapper, aliases
 	// Check Universal User ID
 	eids := userExt.GetEid()
 	if eids != nil {
-		if len(*eids) == 0 {
-			return append(errL, errors.New("request.user.ext.eids must contain at least one element or be undefined"))
-		}
-		uniqueSources := make(map[string]struct{}, len(*eids))
-		for eidIndex, eid := range *eids {
+		eidsValue := *eids
+		uniqueSources := make(map[string]struct{}, len(eidsValue))
+		for eidIndex, eid := range eidsValue {
 			if eid.Source == "" {
 				return append(errL, fmt.Errorf("request.user.ext.eids[%d] missing required field: \"source\"", eidIndex))
 			}
@@ -1710,6 +1708,7 @@ func validateRegs(req *openrtb_ext.RequestWrapper, gpp gpplib.GppContainer) []er
 		for _, id := range req.BidRequest.Regs.GPPSID {
 			if id == int8(constants.SectionTCFEU2) {
 				gdpr = 1
+				break
 			}
 		}
 		if gdpr != *req.BidRequest.Regs.GDPR {
diff --git a/endpoints/openrtb2/sample-requests/invalid-whole/user-ext-eids-empty.json b/endpoints/openrtb2/sample-requests/invalid-whole/user-ext-eids-empty.json
deleted file mode 100644
index 0baf9895..00000000
--- a/endpoints/openrtb2/sample-requests/invalid-whole/user-ext-eids-empty.json
+++ /dev/null
@@ -1,39 +0,0 @@
-{
-  "description": "Bid request with empty eids array in user.ext",
-  "mockBidRequest": {
-    "id": "anyRequestID",
-    "site": {
-      "page": "prebid.org",
-      "publisher": {
-        "id": "anyPublisher"
-      }
-    },
-    "imp": [{
-      "id": "anyImpID",
-      "ext": {
-        "appnexus": {
-          "placementId": 42
-        }
-      },
-      "banner": {
-        "format": [{
-            "w": 300,
-            "h": 250
-          },
-          {
-            "w": 300,
-            "h": 300
-          }
-        ]
-      }
-    }],
-    "tmax": 1000,
-    "user": {
-      "ext": {
-        "eids": []
-      }
-    }
-  },
-  "expectedReturnCode": 400,
-  "expectedErrorMessage": "Invalid request: request.user.ext.eids must contain at least one element or be undefined\n"
-}
\ No newline at end of file
diff --git a/endpoints/openrtb2/sample-requests/valid-whole/exemplary/user-ext-eids-empty.json b/endpoints/openrtb2/sample-requests/valid-whole/exemplary/user-ext-eids-empty.json
new file mode 100644
index 00000000..9f047613
--- /dev/null
+++ b/endpoints/openrtb2/sample-requests/valid-whole/exemplary/user-ext-eids-empty.json
@@ -0,0 +1,56 @@
+{
+    "description": "Bid request with empty eids array in user.ext",
+    "mockBidRequest": {
+        "id": "some-request-id",
+        "site": {
+            "page": "prebid.org"
+        },
+        "imp": [
+            {
+                "id": "some-impression-id",
+                "ext": {
+                    "appnexus": {
+                        "placementId": 123456
+                    }
+                },
+                "banner": {
+                    "format": [
+                        {
+                            "w": 300,
+                            "h": 250
+                        },
+                        {
+                            "w": 300,
+                            "h": 600
+                        }
+                    ]
+                }
+            }
+        ],
+        "tmax": 1000,
+        "user": {
+            "ext": {
+                "eids": []
+            }
+        }
+    },
+    "expectedBidResponse": {
+        "id": "some-request-id",
+        "seatbid": [
+            {
+                "bid": [
+                    {
+                        "id": "appnexus-bid",
+                        "impid": "some-impression-id",
+                        "price": 0
+                    }
+                ],
+                "seat": "appnexus"
+            }
+        ],
+        "bidid": "test bid id",
+        "cur": "USD",
+        "nbr": 0
+    },
+    "expectedReturnCode": 200
+}
\ No newline at end of file
diff --git a/endpoints/openrtb2/sample-requests/valid-whole/supplementary/empty-gppsid-conflict.json b/endpoints/openrtb2/sample-requests/valid-whole/supplementary/empty-gppsid-conflict.json
new file mode 100644
index 00000000..9f7c9231
--- /dev/null
+++ b/endpoints/openrtb2/sample-requests/valid-whole/supplementary/empty-gppsid-conflict.json
@@ -0,0 +1,56 @@
+{
+  "description": "Well formed amp request with  conflicting (2.6 gdpr consent vs. GPP) gdpr signals",
+  "mockBidRequest": {
+    "id": "b9c97a4b-cbc4-483d-b2c4-58a19ed5cfc5",
+    "site": {
+      "page": "prebid.org",
+      "publisher": {
+        "id": "a3de7af2-a86a-4043-a77b-c7e86744155e"
+      }
+    },
+    "source": {
+      "tid": "b9c97a4b-cbc4-483d-b2c4-58a19ed5cfc5"
+    },
+    "tmax": 1000,
+    "imp": [
+      {
+        "id": "/19968336/header-bid-tag-0",
+        "ext": {
+          "appnexus": {
+            "placementId": 12883451
+          }
+        },
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 250
+            },
+            {
+              "w": 300,
+              "h": 300
+            }
+          ]
+        }
+      }
+    ],
+    "regs": {
+      "gpp": "DBACNYA~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA~1NYN",
+      "gpp_sid": [ ],
+      "gdpr": 1,
+      "ext": {
+        "us_privacy": "1YYY"
+      }
+    },
+    "user": {
+      "consent": "CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA",
+      "ext": {}
+    }
+  },
+  "expectedBidResponse": {
+      "id":"b9c97a4b-cbc4-483d-b2c4-58a19ed5cfc5",
+      "bidid":"test bid id",
+      "nbr":0
+  },
+  "expectedReturnCode": 200
+}
diff --git a/exchange/adapter_builders.go b/exchange/adapter_builders.go
index 784ae8ae..7a2ffd54 100755
--- a/exchange/adapter_builders.go
+++ b/exchange/adapter_builders.go
@@ -77,6 +77,7 @@ import (
 	"github.com/prebid/prebid-server/adapters/eplanning"
 	"github.com/prebid/prebid-server/adapters/epom"
 	"github.com/prebid/prebid-server/adapters/freewheelssp"
+	"github.com/prebid/prebid-server/adapters/frvradn"
 	"github.com/prebid/prebid-server/adapters/gamma"
 	"github.com/prebid/prebid-server/adapters/gamoshi"
 	"github.com/prebid/prebid-server/adapters/globalsun"
@@ -254,6 +255,7 @@ func newAdapterBuilders() map[openrtb_ext.BidderName]adapters.Builder {
 		openrtb_ext.BidderEvtech:            limelightDigital.Builder,
 		openrtb_ext.BidderFreewheelSSP:      freewheelssp.Builder,
 		openrtb_ext.BidderFreewheelSSPOld:   freewheelssp.Builder,
+		openrtb_ext.BidderFRVRAdNetwork:     frvradn.Builder,
 		openrtb_ext.BidderGamma:             gamma.Builder,
 		openrtb_ext.BidderGamoshi:           gamoshi.Builder,
 		openrtb_ext.BidderGlobalsun:         globalsun.Builder,
diff --git a/exchange/bidder.go b/exchange/bidder.go
index 58dc1789..8e6f133f 100644
--- a/exchange/bidder.go
+++ b/exchange/bidder.go
@@ -520,6 +520,7 @@ func (bidder *bidderAdapter) doRequestImpl(ctx context.Context, req *adapters.Re
 		ctx = bidder.addClientTrace(ctx)
 	}
 	bidder.me.RecordOverheadTime(metrics.PreBidder, time.Since(pbsRequestStartTime))
+	httpCallStart := time.Now()
 	httpResp, err := ctxhttp.Do(ctx, bidder.Client, httpReq)
 	if err != nil {
 		if err == context.DeadlineExceeded {
@@ -560,6 +561,7 @@ func (bidder *bidderAdapter) doRequestImpl(ctx context.Context, req *adapters.Re
 		}
 	}
 
+	bidder.me.RecordBidderServerResponseTime(time.Since(httpCallStart))
 	return &httpCallInfo{
 		request: req,
 		response: &adapters.ResponseData{
diff --git a/metrics/config/metrics.go b/metrics/config/metrics.go
index 95e2614d..0cf0533d 100644
--- a/metrics/config/metrics.go
+++ b/metrics/config/metrics.go
@@ -149,6 +149,12 @@ func (me *MultiMetricsEngine) RecordTLSHandshakeTime(tlsHandshakeTime time.Durat
 	}
 }
 
+func (me *MultiMetricsEngine) RecordBidderServerResponseTime(bidderServerResponseTime time.Duration) {
+	for _, thisME := range *me {
+		thisME.RecordBidderServerResponseTime(bidderServerResponseTime)
+	}
+}
+
 // RecordAdapterBidReceived across all engines
 func (me *MultiMetricsEngine) RecordAdapterBidReceived(labels metrics.AdapterLabels, bidType openrtb_ext.BidType, hasAdm bool) {
 	for _, thisME := range *me {
@@ -425,6 +431,10 @@ func (me *NilMetricsEngine) RecordDNSTime(dnsLookupTime time.Duration) {
 func (me *NilMetricsEngine) RecordTLSHandshakeTime(tlsHandshakeTime time.Duration) {
 }
 
+// RecordBidderServerResponseTime as a noop
+func (me *NilMetricsEngine) RecordBidderServerResponseTime(bidderServerResponseTime time.Duration) {
+}
+
 // RecordAdapterBidReceived as a noop
 func (me *NilMetricsEngine) RecordAdapterBidReceived(labels metrics.AdapterLabels, bidType openrtb_ext.BidType, hasAdm bool) {
 }
diff --git a/metrics/go_metrics.go b/metrics/go_metrics.go
index 44f2ecd7..64726e30 100644
--- a/metrics/go_metrics.go
+++ b/metrics/go_metrics.go
@@ -31,6 +31,7 @@ type Metrics struct {
 	AccountCacheMeter              map[CacheResult]metrics.Meter
 	DNSLookupTimer                 metrics.Timer
 	TLSHandshakeTimer              metrics.Timer
+	BidderServerResponseTimer      metrics.Timer
 	StoredResponsesMeter           metrics.Meter
 
 	// Metrics for OpenRTB requests specifically
@@ -219,7 +220,8 @@ func NewBlankMetrics(registry metrics.Registry, exchanges []openrtb_ext.BidderNa
 		exchanges: exchanges,
 		modules:   getModuleNames(moduleStageNames),
 
-		OverheadTimer: makeBlankOverheadTimerMetrics(),
+		OverheadTimer:             makeBlankOverheadTimerMetrics(),
+		BidderServerResponseTimer: blankTimer,
 	}
 
 	for _, a := range exchanges {
@@ -305,6 +307,7 @@ func NewMetrics(registry metrics.Registry, exchanges []openrtb_ext.BidderName, d
 	newMetrics.PrebidCacheRequestTimerError = metrics.GetOrRegisterTimer("prebid_cache_request_time.err", registry)
 	newMetrics.StoredResponsesMeter = metrics.GetOrRegisterMeter("stored_responses", registry)
 	newMetrics.OverheadTimer = makeOverheadTimerMetrics(registry)
+	newMetrics.BidderServerResponseTimer = metrics.GetOrRegisterTimer("bidder_server_response_time_seconds", registry)
 
 	for _, dt := range StoredDataTypes() {
 		for _, ft := range StoredDataFetchTypes() {
@@ -811,6 +814,10 @@ func (me *Metrics) RecordTLSHandshakeTime(tlsHandshakeTime time.Duration) {
 	me.TLSHandshakeTimer.Update(tlsHandshakeTime)
 }
 
+func (me *Metrics) RecordBidderServerResponseTime(bidderServerResponseTime time.Duration) {
+	me.BidderServerResponseTimer.Update(bidderServerResponseTime)
+}
+
 // RecordAdapterBidReceived implements a part of the MetricsEngine interface.
 // This tracks how many bids from each Bidder use `adm` vs. `nurl.
 func (me *Metrics) RecordAdapterBidReceived(labels AdapterLabels, bidType openrtb_ext.BidType, hasAdm bool) {
diff --git a/metrics/metrics.go b/metrics/metrics.go
index 186e1ffa..214e7c1d 100644
--- a/metrics/metrics.go
+++ b/metrics/metrics.go
@@ -430,6 +430,7 @@ type MetricsEngine interface {
 	RecordAdapterConnections(adapterName openrtb_ext.BidderName, connWasReused bool, connWaitTime time.Duration)
 	RecordDNSTime(dnsLookupTime time.Duration)
 	RecordTLSHandshakeTime(tlsHandshakeTime time.Duration)
+	RecordBidderServerResponseTime(bidderServerResponseTime time.Duration)
 	RecordAdapterPanic(labels AdapterLabels)
 	RecordAdapterBidReceived(labels AdapterLabels, bidType openrtb_ext.BidType, hasAdm bool)
 	RecordAdapterPrice(labels AdapterLabels, cpm float64)
diff --git a/metrics/metrics_mock.go b/metrics/metrics_mock.go
index cbca8570..0ce238c5 100644
--- a/metrics/metrics_mock.go
+++ b/metrics/metrics_mock.go
@@ -71,6 +71,11 @@ func (me *MetricsEngineMock) RecordTLSHandshakeTime(tlsHandshakeTime time.Durati
 	me.Called(tlsHandshakeTime)
 }
 
+// RecordBidderServerResponseTime mock
+func (me *MetricsEngineMock) RecordBidderServerResponseTime(bidderServerResponseTime time.Duration) {
+	me.Called(bidderServerResponseTime)
+}
+
 // RecordAdapterBidReceived mock
 func (me *MetricsEngineMock) RecordAdapterBidReceived(labels AdapterLabels, bidType openrtb_ext.BidType, hasAdm bool) {
 	me.Called(labels, bidType, hasAdm)
diff --git a/metrics/prometheus/prometheus.go b/metrics/prometheus/prometheus.go
index ee11dccf..eb387789 100644
--- a/metrics/prometheus/prometheus.go
+++ b/metrics/prometheus/prometheus.go
@@ -55,6 +55,7 @@ type Metrics struct {
 	storedResponsesErrors        *prometheus.CounterVec
 	adsCertRequests              *prometheus.CounterVec
 	adsCertSignTimer             prometheus.Histogram
+	bidderServerResponseTimer    prometheus.Histogram
 
 	// Adapter Metrics
 	adapterBids                           *prometheus.CounterVec
@@ -440,6 +441,11 @@ func NewMetrics(cfg config.PrometheusMetrics, disabledMetrics config.DisabledMet
 		[]string{adapterLabel},
 		standardTimeBuckets)
 
+	metrics.bidderServerResponseTimer = newHistogram(cfg, reg,
+		"bidder_server_response_time_seconds",
+		"Duration needed to send HTTP request and receive response back from bidder server.",
+		standardTimeBuckets)
+
 	metrics.syncerRequests = newCounter(cfg, reg,
 		"syncer_requests",
 		"Count of cookie sync requests where a syncer is a candidate to be synced labeled by syncer key and status.",
@@ -893,6 +899,10 @@ func (m *Metrics) RecordTLSHandshakeTime(tlsHandshakeTime time.Duration) {
 	m.tlsHandhakeTimer.Observe(tlsHandshakeTime.Seconds())
 }
 
+func (m *Metrics) RecordBidderServerResponseTime(bidderServerResponseTime time.Duration) {
+	m.bidderServerResponseTimer.Observe(bidderServerResponseTime.Seconds())
+}
+
 func (m *Metrics) RecordAdapterPanic(labels metrics.AdapterLabels) {
 	m.adapterPanics.With(prometheus.Labels{
 		adapterLabel: string(labels.Adapter),
diff --git a/openrtb_ext/bidders.go b/openrtb_ext/bidders.go
index 90f80d4f..a5a1ecff 100644
--- a/openrtb_ext/bidders.go
+++ b/openrtb_ext/bidders.go
@@ -164,6 +164,7 @@ const (
 	BidderEvtech            BidderName = "evtech"
 	BidderFreewheelSSP      BidderName = "freewheelssp"
 	BidderFreewheelSSPOld   BidderName = "freewheel-ssp"
+	BidderFRVRAdNetwork     BidderName = "frvradn"
 	BidderGamma             BidderName = "gamma"
 	BidderGamoshi           BidderName = "gamoshi"
 	BidderGlobalsun         BidderName = "globalsun"
@@ -350,6 +351,7 @@ func CoreBidderNames() []BidderName {
 		BidderEvtech,
 		BidderFreewheelSSP,
 		BidderFreewheelSSPOld,
+		BidderFRVRAdNetwork,
 		BidderGamma,
 		BidderGamoshi,
 		BidderGlobalsun,
diff --git a/openrtb_ext/imp_frvradn.go b/openrtb_ext/imp_frvradn.go
new file mode 100644
index 00000000..ac8f86b2
--- /dev/null
+++ b/openrtb_ext/imp_frvradn.go
@@ -0,0 +1,6 @@
+package openrtb_ext
+
+type ImpExtFRVRAdn struct {
+	PublisherID string `json:"publisher_id"`
+	AdUnitID    string `json:"ad_unit_id"`
+}
diff --git a/ortb/clone.go b/ortb/clone.go
new file mode 100644
index 00000000..c0e5a4dd
--- /dev/null
+++ b/ortb/clone.go
@@ -0,0 +1,267 @@
+package ortb
+
+import (
+	"github.com/prebid/openrtb/v19/openrtb2"
+	"github.com/prebid/prebid-server/util/ptrutil"
+	"github.com/prebid/prebid-server/util/sliceutil"
+)
+
+func CloneApp(s *openrtb2.App) *openrtb2.App {
+	if s == nil {
+		return nil
+	}
+
+	// Shallow Copy (Value Fields)
+	c := *s
+
+	// Deep Copy (Pointers)
+	c.Cat = sliceutil.Clone(s.Cat)
+	c.SectionCat = sliceutil.Clone(s.SectionCat)
+	c.PageCat = sliceutil.Clone(s.PageCat)
+	c.Publisher = ClonePublisher(s.Publisher)
+	c.Content = CloneContent(s.Content)
+	c.KwArray = sliceutil.Clone(s.KwArray)
+	c.Ext = sliceutil.Clone(s.Ext)
+
+	return &c
+}
+
+func ClonePublisher(s *openrtb2.Publisher) *openrtb2.Publisher {
+	if s == nil {
+		return nil
+	}
+
+	// Shallow Copy (Value Fields)
+	c := *s
+
+	// Deep Copy (Pointers)
+	c.Cat = sliceutil.Clone(s.Cat)
+	c.Ext = sliceutil.Clone(s.Ext)
+
+	return &c
+}
+
+func CloneContent(s *openrtb2.Content) *openrtb2.Content {
+	if s == nil {
+		return nil
+	}
+
+	// Shallow Copy (Value Fields)
+	c := *s
+
+	// Deep Copy (Pointers)
+	c.Producer = CloneProducer(s.Producer)
+	c.Cat = sliceutil.Clone(s.Cat)
+	c.ProdQ = ptrutil.Clone(s.ProdQ)
+	c.VideoQuality = ptrutil.Clone(s.VideoQuality)
+	c.KwArray = sliceutil.Clone(s.KwArray)
+	c.Data = CloneDataSlice(s.Data)
+	c.Network = CloneNetwork(s.Network)
+	c.Channel = CloneChannel(s.Channel)
+	c.Ext = sliceutil.Clone(s.Ext)
+
+	return &c
+}
+
+func CloneProducer(s *openrtb2.Producer) *openrtb2.Producer {
+	if s == nil {
+		return nil
+	}
+
+	// Shallow Copy (Value Fields)
+	c := *s
+
+	// Deep Copy (Pointers)
+	c.Cat = sliceutil.Clone(s.Cat)
+	c.Ext = sliceutil.Clone(s.Ext)
+
+	return &c
+}
+
+func CloneDataSlice(s []openrtb2.Data) []openrtb2.Data {
+	if s == nil {
+		return nil
+	}
+
+	c := make([]openrtb2.Data, len(s))
+	for i, d := range s {
+		c[i] = CloneData(d)
+	}
+
+	return c
+}
+
+func CloneData(s openrtb2.Data) openrtb2.Data {
+	// Shallow Copy (Value Fields) Occurred By Passing Argument By Value
+
+	// Deep Copy (Pointers)
+	s.Segment = CloneSegmentSlice(s.Segment)
+	s.Ext = sliceutil.Clone(s.Ext)
+
+	return s
+}
+
+func CloneSegmentSlice(s []openrtb2.Segment) []openrtb2.Segment {
+	if s == nil {
+		return nil
+	}
+
+	c := make([]openrtb2.Segment, len(s))
+	for i, d := range s {
+		c[i] = CloneSegment(d)
+	}
+
+	return c
+}
+
+func CloneSegment(s openrtb2.Segment) openrtb2.Segment {
+	// Shallow Copy (Value Fields) Occurred By Passing Argument By Value
+
+	// Deep Copy (Pointers)
+	s.Ext = sliceutil.Clone(s.Ext)
+
+	return s
+}
+
+func CloneNetwork(s *openrtb2.Network) *openrtb2.Network {
+	if s == nil {
+		return nil
+	}
+
+	// Shallow Copy (Value Fields)
+	c := *s
+
+	// Deep Copy (Pointers)
+	c.Ext = sliceutil.Clone(s.Ext)
+
+	return &c
+}
+
+func CloneChannel(s *openrtb2.Channel) *openrtb2.Channel {
+	if s == nil {
+		return nil
+	}
+
+	// Shallow Copy (Value Fields)
+	c := *s
+
+	// Deep Copy (Pointers)
+	c.Ext = sliceutil.Clone(s.Ext)
+
+	return &c
+}
+
+func CloneSite(s *openrtb2.Site) *openrtb2.Site {
+	if s == nil {
+		return nil
+	}
+
+	// Shallow Copy (Value Fields)
+	c := *s
+
+	// Deep Copy (Pointers)
+	c.Cat = sliceutil.Clone(s.Cat)
+	c.SectionCat = sliceutil.Clone(s.SectionCat)
+	c.PageCat = sliceutil.Clone(s.PageCat)
+	c.Publisher = ClonePublisher(s.Publisher)
+	c.Content = CloneContent(s.Content)
+	c.KwArray = sliceutil.Clone(s.KwArray)
+	c.Ext = sliceutil.Clone(s.Ext)
+
+	return &c
+}
+
+func CloneUser(s *openrtb2.User) *openrtb2.User {
+	if s == nil {
+		return nil
+	}
+
+	// Shallow Copy (Value Fields)
+	c := *s
+
+	// Deep Copy (Pointers)
+	c.KwArray = sliceutil.Clone(s.KwArray)
+	c.Geo = CloneGeo(s.Geo)
+	c.Data = CloneDataSlice(s.Data)
+	c.EIDs = CloneEIDSlice(s.EIDs)
+	c.Ext = sliceutil.Clone(s.Ext)
+
+	return &c
+}
+
+func CloneGeo(s *openrtb2.Geo) *openrtb2.Geo {
+	if s == nil {
+		return nil
+	}
+
+	// Shallow Copy (Value Fields)
+	c := *s
+
+	// Deep Copy (Pointers)
+	c.Ext = sliceutil.Clone(s.Ext)
+
+	return &c
+}
+
+func CloneEIDSlice(s []openrtb2.EID) []openrtb2.EID {
+	if s == nil {
+		return nil
+	}
+
+	c := make([]openrtb2.EID, len(s))
+	for i, d := range s {
+		c[i] = CloneEID(d)
+	}
+
+	return c
+}
+
+func CloneEID(s openrtb2.EID) openrtb2.EID {
+	// Shallow Copy (Value Fields) Occurred By Passing Argument By Value
+
+	// Deep Copy (Pointers)
+	s.UIDs = CloneUIDSlice(s.UIDs)
+	s.Ext = sliceutil.Clone(s.Ext)
+
+	return s
+}
+
+func CloneUIDSlice(s []openrtb2.UID) []openrtb2.UID {
+	if s == nil {
+		return nil
+	}
+
+	c := make([]openrtb2.UID, len(s))
+	for i, d := range s {
+		c[i] = CloneUID(d)
+	}
+
+	return c
+}
+
+func CloneUID(s openrtb2.UID) openrtb2.UID {
+	// Shallow Copy (Value Fields) Occurred By Passing Argument By Value
+
+	// Deep Copy (Pointers)
+	s.Ext = sliceutil.Clone(s.Ext)
+
+	return s
+}
+
+func CloneDOOH(s *openrtb2.DOOH) *openrtb2.DOOH {
+	if s == nil {
+		return nil
+	}
+
+	// Shallow Copy (Value Fields)
+	c := *s
+
+	// Deep Copy (Pointers)
+	c.VenueType = sliceutil.Clone(s.VenueType)
+	c.VenueTypeTax = ptrutil.Clone(s.VenueTypeTax)
+	c.Publisher = ClonePublisher(s.Publisher)
+	c.Content = CloneContent(s.Content)
+	c.Ext = sliceutil.Clone(s.Ext)
+
+	return &c
+}
diff --git a/static/bidder-info/adsinteractive.yaml b/static/bidder-info/adsinteractive.yaml
index e70c15b6..0fb63903 100644
--- a/static/bidder-info/adsinteractive.yaml
+++ b/static/bidder-info/adsinteractive.yaml
@@ -11,6 +11,6 @@ capabilities:
       - banner
 userSync:
   redirect:
-    url: "http://pb.adsinteractive.com/getuid?{{.RedirectURL}}"
+    url: "http://bid.adsinteractive.com/getuid?{{.RedirectURL}}"
     userMacro: "$AUID"
- 
\ No newline at end of file
+ 
diff --git a/static/bidder-info/adview.yaml b/static/bidder-info/adview.yaml
index 53158cf4..2871653f 100644
--- a/static/bidder-info/adview.yaml
+++ b/static/bidder-info/adview.yaml
@@ -2,6 +2,8 @@ endpoint: "https://bid.adview.com/agent/thirdAdxService/{{.AccountID}}"
 maintainer:
   email: "partner@adview.com"
 gvlVendorID: 1022
+endpointCompression: gzip
+modifyingVastXmlAllowed: true
 capabilities:
   app:
     mediaTypes:
diff --git a/static/bidder-info/frvradn.yaml b/static/bidder-info/frvradn.yaml
new file mode 100644
index 00000000..b5b2e7f2
--- /dev/null
+++ b/static/bidder-info/frvradn.yaml
@@ -0,0 +1,18 @@
+endpoint: "https://fran.frvr.com/api/v1/openrtb"
+maintainer:
+  email: "info@frvr.com"
+gvlVendorID: 1107
+modifyingVastXmlAllowed: false
+capabilities:
+  app:
+    mediaTypes:
+      - banner
+      - video
+  site:
+    mediaTypes:
+      - banner
+      - video
+userSync:
+  iframe:
+    url: "https://fran.frvr.com/api/v1/sync?gdpr={{.GDPR}}&gdpr_consent={{.GDPRConsent}}&us_privacy={{.USPrivacy}}&redirect_uri={{.RedirectURL}}"
+    userMacro: "{{UID}}"
\ No newline at end of file
diff --git a/static/bidder-params/frvradn.json b/static/bidder-params/frvradn.json
new file mode 100644
index 00000000..09edaba1
--- /dev/null
+++ b/static/bidder-params/frvradn.json
@@ -0,0 +1,17 @@
+{
+  "$schema": "http://json-schema.org/draft-04/schema#",
+  "title": "FRVR Ad Network Adapter Params",
+  "description": "A schema which validates params accepted by the FRVR Ad Network adapter",
+  "type": "object",
+  "properties": {
+    "publisher_id": {
+      "type": ["string"],
+      "description": "Publisher ID"
+    },
+    "ad_unit_id": {
+      "type": ["string"],
+      "description": "Ad Unit ID"
+    }
+  },
+  "required": ["publisher_id", "ad_unit_id"]
+}
diff --git a/util/ptrutil/ptrutil.go b/util/ptrutil/ptrutil.go
index 999ac34a..90888f3e 100644
--- a/util/ptrutil/ptrutil.go
+++ b/util/ptrutil/ptrutil.go
@@ -3,3 +3,12 @@ package ptrutil
 func ToPtr[T any](v T) *T {
 	return &v
 }
+
+func Clone[T any](v *T) *T {
+	if v == nil {
+		return nil
+	}
+
+	clone := *v
+	return &clone
+}
diff --git a/util/sliceutil/clone.go b/util/sliceutil/clone.go
new file mode 100644
index 00000000..2077a933
--- /dev/null
+++ b/util/sliceutil/clone.go
@@ -0,0 +1,12 @@
+package sliceutil
+
+func Clone[T any](s []T) []T {
+	if s == nil {
+		return nil
+	}
+
+	c := make([]T, len(s))
+	copy(c, s)
+
+	return c
+}


Test patch:
diff --git a/adapters/frvradn/frvradn_test.go b/adapters/frvradn/frvradn_test.go
new file mode 100644
index 00000000..f50f319b
--- /dev/null
+++ b/adapters/frvradn/frvradn_test.go
@@ -0,0 +1,28 @@
+package frvradn
+
+import (
+	"github.com/stretchr/testify/assert"
+	"testing"
+
+	"github.com/prebid/prebid-server/adapters/adapterstest"
+	"github.com/prebid/prebid-server/config"
+	"github.com/prebid/prebid-server/openrtb_ext"
+)
+
+func TestJsonSamples(t *testing.T) {
+	bidder, buildErr := Builder(openrtb_ext.BidderFRVRAdNetwork, config.Adapter{
+		Endpoint: "https://fran.frvr.com/api/v1/openrtb",
+	}, config.Server{ExternalUrl: "https://host.com", GvlID: 1, DataCenter: "2"})
+
+	if buildErr != nil {
+		t.Fatalf("Builder returned unexpected error %v", buildErr)
+	}
+
+	adapterstest.RunJSONBidderTest(t, "frvradntest", bidder)
+}
+
+func TestInvalidEndpoint(t *testing.T) {
+	_, buildErr := Builder(openrtb_ext.BidderFRVRAdNetwork, config.Adapter{Endpoint: ""}, config.Server{})
+
+	assert.Error(t, buildErr)
+}
diff --git a/adapters/frvradn/frvradntest/exemplary/banner.json b/adapters/frvradn/frvradntest/exemplary/banner.json
new file mode 100644
index 00000000..1c9866aa
--- /dev/null
+++ b/adapters/frvradn/frvradntest/exemplary/banner.json
@@ -0,0 +1,105 @@
+{
+  "mockBidRequest": {
+    "id": "test-request-id",
+    "imp": [
+      {
+        "id": "test-banner-id",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 600
+            }
+          ]
+        },
+        "ext": {
+          "bidder": {
+            "ad_unit_id": "4fd8fadb-b218-41cd-a99f-8ae932b8c529",
+            "publisher_id": "7de498f7-f955-4696-a337-348b026c5e42"
+          }
+        }
+      }
+    ]
+  },
+  "httpCalls": [
+    {
+      "expectedRequest": {
+        "uri": "https://fran.frvr.com/api/v1/openrtb",
+        "body": {
+          "id": "test-request-id",
+          "imp": [
+            {
+              "id": "test-banner-id",
+              "banner": {
+                "format": [
+                  {
+                    "w": 300,
+                    "h": 600
+                  }
+                ]
+              },
+              "ext": {
+                "ad_unit_id": "4fd8fadb-b218-41cd-a99f-8ae932b8c529",
+                "publisher_id": "7de498f7-f955-4696-a337-348b026c5e42"
+              }
+            }
+          ]
+        }
+      },
+      "mockResponse": {
+        "status": 200,
+        "body": {
+          "id": "test-request-id",
+          "seatbid": [
+            {
+              "seat": "test-seat",
+              "bid": [
+                {
+                  "id": "d4916d88-c0f4-4340-87c0-bc9054696d3a",
+                  "impid": "test-banner-id",
+                  "price": 0.55,
+                  "adm": "a creative",
+                  "adid": "123123",
+                  "adomain": ["advertiser.com"],
+                  "crid": "9999",
+                  "w": 300,
+                  "h": 600,
+                  "ext": {
+                    "prebid": {
+                      "type": "banner"
+                    }
+                  }
+                }
+              ]
+            }
+          ]
+        }
+      }
+    }
+  ],
+  "expectedBidResponses": [
+    {
+      "bids": [
+        {
+          "bid": {
+            "id": "d4916d88-c0f4-4340-87c0-bc9054696d3a",
+            "impid": "test-banner-id",
+            "price": 0.55,
+            "adm": "a creative",
+            "adid": "123123",
+            "adomain": ["advertiser.com"],
+            "crid": "9999",
+            "w": 300,
+            "h": 600,
+            "ext": {
+              "prebid": {
+                "type": "banner"
+              }
+            }
+          },
+          "type": "banner"
+        }
+      ]
+    }
+  ]
+}
diff --git a/adapters/frvradn/frvradntest/exemplary/native.json b/adapters/frvradn/frvradntest/exemplary/native.json
new file mode 100644
index 00000000..9b350547
--- /dev/null
+++ b/adapters/frvradn/frvradntest/exemplary/native.json
@@ -0,0 +1,95 @@
+{
+  "mockBidRequest": {
+    "id": "test-request-id",
+    "imp": [
+      {
+        "id": "test-native-id",
+        "native": {
+          "request": "test-native-request"
+        },
+        "ext": {
+          "bidder": {
+            "ad_unit_id": "38cab549-569e-4362-a90f-f7c1d09d3e4c",
+            "publisher_id": "90f55919-525e-4e2f-99cb-fd4a59fb75a0"
+          }
+        }
+      }
+    ]
+  },
+  "httpCalls": [
+    {
+      "expectedRequest": {
+        "uri": "https://fran.frvr.com/api/v1/openrtb",
+        "body": {
+          "id": "test-request-id",
+          "imp": [
+            {
+              "id": "test-native-id",
+              "native": {
+                "request": "test-native-request"
+              },
+              "ext": {
+                "ad_unit_id": "38cab549-569e-4362-a90f-f7c1d09d3e4c",
+                "publisher_id": "90f55919-525e-4e2f-99cb-fd4a59fb75a0"
+              }
+            }
+          ]
+        }
+      },
+      "mockResponse": {
+        "status": 200,
+        "body": {
+          "id": "test-request-id",
+          "seatbid": [
+            {
+              "seat": "test-seat",
+              "bid": [
+                {
+                  "id": "9244e776-e0c7-4d76-8c20-88971909114b",
+                  "impid": "test-native-id",
+                  "price": 2.50,
+                  "adm": "a creative",
+                  "adid": "9999",
+                  "adomain": ["advertiser.com"],
+                  "crid": "9999",
+                  "w": 300,
+                  "h": 600,
+                  "ext": {
+                    "prebid": {
+                      "type": "native"
+                    }
+                  }
+                }
+              ]
+            }
+          ]
+        }
+      }
+    }
+  ],
+  "expectedBidResponses": [
+    {
+      "bids": [
+        {
+          "bid": {
+            "id": "9244e776-e0c7-4d76-8c20-88971909114b",
+            "impid": "test-native-id",
+            "price": 2.50,
+            "adm": "a creative",
+            "adid": "9999",
+            "adomain": ["advertiser.com"],
+            "crid": "9999",
+            "w": 300,
+            "h": 600,
+            "ext": {
+              "prebid": {
+                "type": "native"
+              }
+            }
+          },
+          "type": "native"
+        }
+      ]
+    }
+  ]
+}
diff --git a/adapters/frvradn/frvradntest/exemplary/video.json b/adapters/frvradn/frvradntest/exemplary/video.json
new file mode 100644
index 00000000..9ff1bb31
--- /dev/null
+++ b/adapters/frvradn/frvradntest/exemplary/video.json
@@ -0,0 +1,101 @@
+{
+  "mockBidRequest": {
+    "id": "test-request-id",
+    "imp": [
+      {
+        "id": "test-video-id",
+        "video": {
+          "mimes": ["video/mp4"],
+          "w": 300,
+          "h": 600
+        },
+        "ext": {
+          "bidder": {
+            "ad_unit_id": "5da02f4f-96de-4a89-824a-6e8e1dfc495c",
+            "publisher_id": "9d66baf8-a80f-4378-9bd2-06ec09eaa86c"
+          }
+        }
+      }
+    ]
+  },
+  "httpCalls": [
+    {
+      "expectedRequest": {
+        "uri": "https://fran.frvr.com/api/v1/openrtb",
+        "body": {
+          "id": "test-request-id",
+          "imp": [
+            {
+              "id": "test-video-id",
+              "video": {
+                "mimes": ["video/mp4"],
+                "w": 300,
+                "h": 600
+              },
+              "ext": {
+                "ad_unit_id": "5da02f4f-96de-4a89-824a-6e8e1dfc495c",
+                "publisher_id": "9d66baf8-a80f-4378-9bd2-06ec09eaa86c"
+              }
+            }
+          ]
+        }
+      },
+      "mockResponse": {
+        "status": 200,
+        "body": {
+          "id": "test-request-id",
+          "seatbid": [
+            {
+              "seat": "test-seat",
+              "bid": [
+                {
+                  "id": "42d72858-e071-40b2-ab74-27848e4d3336",
+                  "impid": "test-video-id",
+                  "price": 6.50,
+                  "nurl": "notification",
+                  "adm": "a creative",
+                  "adid": "9999",
+                  "adomain": ["advertiser.com"],
+                  "crid": "9999",
+                  "w": 300,
+                  "h": 600,
+                  "ext": {
+                    "prebid": {
+                      "type": "video"
+                    }
+                  }
+                }
+              ]
+            }
+          ]
+        }
+      }
+    }
+  ],
+  "expectedBidResponses": [
+    {
+      "bids": [
+        {
+          "bid": {
+            "id": "42d72858-e071-40b2-ab74-27848e4d3336",
+            "impid": "test-video-id",
+            "price": 6.50,
+            "nurl": "notification",
+            "adm": "a creative",
+            "adid": "9999",
+            "adomain": ["advertiser.com"],
+            "crid": "9999",
+            "w": 300,
+            "h": 600,
+            "ext": {
+              "prebid": {
+                "type": "video"
+              }
+            }
+          },
+          "type": "video"
+        }
+      ]
+    }
+  ]
+}
diff --git a/adapters/frvradn/frvradntest/supplemental/204.json b/adapters/frvradn/frvradntest/supplemental/204.json
new file mode 100644
index 00000000..48fe8a03
--- /dev/null
+++ b/adapters/frvradn/frvradntest/supplemental/204.json
@@ -0,0 +1,55 @@
+{
+  "mockBidRequest": {
+    "id": "test-request-id",
+    "imp": [
+      {
+        "id": "test-banner-id",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 600
+            }
+          ]
+        },
+        "ext": {
+          "bidder": {
+            "ad_unit_id": "aae06006-2c41-499c-a432-7b74bc8d9b28",
+            "publisher_id": "0af78b6e-45ad-4bad-8332-4b5e6a87b222"
+          }
+        }
+      }
+    ]
+  },
+  "httpCalls": [
+    {
+      "expectedRequest": {
+        "uri": "https://fran.frvr.com/api/v1/openrtb",
+        "body": {
+          "id": "test-request-id",
+          "imp": [
+            {
+              "id": "test-banner-id",
+              "banner": {
+                "format": [
+                  {
+                    "w": 300,
+                    "h": 600
+                  }
+                ]
+              },
+              "ext": {
+                "ad_unit_id": "aae06006-2c41-499c-a432-7b74bc8d9b28",
+                "publisher_id": "0af78b6e-45ad-4bad-8332-4b5e6a87b222"
+              }
+            }
+          ]
+        }
+      },
+      "mockResponse": {
+        "status": 204
+      }
+    }
+  ],
+  "expectedBidResponses": []
+}
diff --git a/adapters/frvradn/frvradntest/supplemental/400.json b/adapters/frvradn/frvradntest/supplemental/400.json
new file mode 100644
index 00000000..a7e5f40d
--- /dev/null
+++ b/adapters/frvradn/frvradntest/supplemental/400.json
@@ -0,0 +1,60 @@
+{
+  "mockBidRequest": {
+    "id": "test-request-id",
+    "imp": [
+      {
+        "id": "test-banner-id",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 600
+            }
+          ]
+        },
+        "ext": {
+          "bidder": {
+            "ad_unit_id": "7fc326d4-243d-41ef-bbb4-4c76c4ad69cd",
+            "publisher_id": "4552e5bd-5ece-4f7d-a256-9069a33260a6"
+          }
+        }
+      }
+    ]
+  },
+  "httpCalls": [
+    {
+      "expectedRequest": {
+        "uri": "https://fran.frvr.com/api/v1/openrtb",
+        "body": {
+          "id": "test-request-id",
+          "imp": [
+            {
+              "id": "test-banner-id",
+              "banner": {
+                "format": [
+                  {
+                    "w": 300,
+                    "h": 600
+                  }
+                ]
+              },
+              "ext": {
+                "ad_unit_id": "7fc326d4-243d-41ef-bbb4-4c76c4ad69cd",
+                "publisher_id": "4552e5bd-5ece-4f7d-a256-9069a33260a6"
+              }
+            }
+          ]
+        }
+      },
+      "mockResponse": {
+        "status": 400
+      }
+    }
+  ],
+  "expectedMakeBidsErrors": [
+    {
+      "value": "Unexpected status code: 400. Run with request.debug = 1 for more info",
+      "comparison": "literal"
+    }
+  ]
+}
diff --git a/adapters/frvradn/frvradntest/supplemental/503.json b/adapters/frvradn/frvradntest/supplemental/503.json
new file mode 100644
index 00000000..11b9ce2d
--- /dev/null
+++ b/adapters/frvradn/frvradntest/supplemental/503.json
@@ -0,0 +1,60 @@
+{
+  "mockBidRequest": {
+    "id": "test-request-id",
+    "imp": [
+      {
+        "id": "test-banner-id",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 600
+            }
+          ]
+        },
+        "ext": {
+          "bidder": {
+            "ad_unit_id": "7fc326d4-243d-41ef-bbb4-4c76c4ad69cd",
+            "publisher_id": "4552e5bd-5ece-4f7d-a256-9069a33260a6"
+          }
+        }
+      }
+    ]
+  },
+  "httpCalls": [
+    {
+      "expectedRequest": {
+        "uri": "https://fran.frvr.com/api/v1/openrtb",
+        "body": {
+          "id": "test-request-id",
+          "imp": [
+            {
+              "id": "test-banner-id",
+              "banner": {
+                "format": [
+                  {
+                    "w": 300,
+                    "h": 600
+                  }
+                ]
+              },
+              "ext": {
+                "ad_unit_id": "7fc326d4-243d-41ef-bbb4-4c76c4ad69cd",
+                "publisher_id": "4552e5bd-5ece-4f7d-a256-9069a33260a6"
+              }
+            }
+          ]
+        }
+      },
+      "mockResponse": {
+        "status": 503
+      }
+    }
+  ],
+  "expectedMakeBidsErrors": [
+    {
+      "value": "Unexpected status code: 503. Run with request.debug = 1 for more info",
+      "comparison": "literal"
+    }
+  ]
+}
diff --git a/adapters/frvradn/frvradntest/supplemental/currency_converter.json b/adapters/frvradn/frvradntest/supplemental/currency_converter.json
new file mode 100644
index 00000000..45561d0b
--- /dev/null
+++ b/adapters/frvradn/frvradntest/supplemental/currency_converter.json
@@ -0,0 +1,133 @@
+{
+  "mockBidRequest": {
+    "id": "test-request-id",
+    "imp": [
+      {
+        "id": "test-banner-id",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 600
+            }
+          ]
+        },
+        "bidfloor": 5,
+        "bidfloorcur": "EUR",
+        "ext": {
+          "bidder": {
+            "ad_unit_id": "33c76a5f-d9ef-4630-9e1e-7705673f1693",
+            "publisher_id": "0c0ac25e-e0b5-46d7-94d1-e1bf68034dd9"
+          }
+        }
+      }
+    ],
+    "ext": {
+      "prebid": {
+        "currency": {
+          "rates": {
+            "EUR": {
+              "USD": 1.5
+            }
+          },
+          "usepbsrates": false
+        }
+      }
+    }
+  },
+  "httpCalls": [
+    {
+      "expectedRequest": {
+        "uri": "https://fran.frvr.com/api/v1/openrtb",
+        "body": {
+          "id": "test-request-id",
+          "imp": [
+            {
+              "id": "test-banner-id",
+              "banner": {
+                "format": [
+                  {
+                    "w": 300,
+                    "h": 600
+                  }
+                ]
+              },
+              "bidfloor": 7.5,
+              "bidfloorcur": "USD",
+              "ext": {
+                "ad_unit_id": "33c76a5f-d9ef-4630-9e1e-7705673f1693",
+                "publisher_id": "0c0ac25e-e0b5-46d7-94d1-e1bf68034dd9"
+              }
+            }
+          ],
+          "ext": {
+            "prebid": {
+              "currency": {
+                "rates": {
+                  "EUR": {
+                    "USD": 1.5
+                  }
+                },
+                "usepbsrates": false
+              }
+            }
+          }
+        }
+      },
+      "mockResponse": {
+        "status": 200,
+        "body": {
+          "id": "test-request-id",
+          "seatbid": [
+            {
+              "seat": "test-seat",
+              "bid": [
+                {
+                  "id": "ed03a73b-3c9d-4883-a672-7919cb607c15",
+                  "impid": "test-banner-id",
+                  "price": 7.5,
+                  "adm": "a creative",
+                  "adid": "9999",
+                  "adomain": ["advertiser.com"],
+                  "crid": "9999",
+                  "w": 300,
+                  "h": 600,
+                  "ext": {
+                    "prebid": {
+                      "type": "banner"
+                    }
+                  }
+                }
+              ]
+            }
+          ]
+        }
+      }
+    }
+  ],
+  "expectedBidResponses": [
+    {
+      "bids": [
+        {
+          "bid": {
+            "id": "ed03a73b-3c9d-4883-a672-7919cb607c15",
+            "impid": "test-banner-id",
+            "price": 7.5,
+            "adm": "a creative",
+            "adid": "9999",
+            "adomain": ["advertiser.com"],
+            "crid": "9999",
+            "w": 300,
+            "h": 600,
+            "ext": {
+              "prebid": {
+                "type": "banner"
+              }
+            }
+          },
+          "type": "banner"
+        }
+      ]
+    }
+  ]
+}
diff --git a/adapters/frvradn/frvradntest/supplemental/empty_object_response.json b/adapters/frvradn/frvradntest/supplemental/empty_object_response.json
new file mode 100644
index 00000000..e0e8959e
--- /dev/null
+++ b/adapters/frvradn/frvradntest/supplemental/empty_object_response.json
@@ -0,0 +1,56 @@
+{
+  "mockBidRequest": {
+    "id": "test-request-id",
+    "imp": [
+      {
+        "id": "test-banner-id",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 600
+            }
+          ]
+        },
+        "ext": {
+          "bidder": {
+            "ad_unit_id": "33c76a5f-d9ef-4630-9e1e-7705673f1693",
+            "publisher_id": "0c0ac25e-e0b5-46d7-94d1-e1bf68034dd9"
+          }
+        }
+      }
+    ]
+  },
+  "httpCalls": [
+    {
+      "expectedRequest": {
+        "uri": "https://fran.frvr.com/api/v1/openrtb",
+        "body": {
+          "id": "test-request-id",
+          "imp": [
+            {
+              "id": "test-banner-id",
+              "banner": {
+                "format": [
+                  {
+                    "w": 300,
+                    "h": 600
+                  }
+                ]
+              },
+              "ext": {
+                "ad_unit_id": "33c76a5f-d9ef-4630-9e1e-7705673f1693",
+                "publisher_id": "0c0ac25e-e0b5-46d7-94d1-e1bf68034dd9"
+              }
+            }
+          ]
+        }
+      },
+      "mockResponse": {
+        "status": 200,
+        "body": {}
+      }
+    }
+  ],
+  "expectedBidResponses": []
+}
diff --git a/adapters/frvradn/frvradntest/supplemental/empty_reponse.json b/adapters/frvradn/frvradntest/supplemental/empty_reponse.json
new file mode 100644
index 00000000..d6e2b059
--- /dev/null
+++ b/adapters/frvradn/frvradntest/supplemental/empty_reponse.json
@@ -0,0 +1,55 @@
+{
+  "mockBidRequest": {
+    "id": "test-request-id",
+    "imp": [
+      {
+        "id": "test-banner-id",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 600
+            }
+          ]
+        },
+        "ext": {
+          "bidder": {
+            "ad_unit_id": "33c76a5f-d9ef-4630-9e1e-7705673f1693",
+            "publisher_id": "0c0ac25e-e0b5-46d7-94d1-e1bf68034dd9"
+          }
+        }
+      }
+    ]
+  },
+  "httpCalls": [
+    {
+      "expectedRequest": {
+        "uri": "https://fran.frvr.com/api/v1/openrtb",
+        "body": {
+          "id": "test-request-id",
+          "imp": [
+            {
+              "id": "test-banner-id",
+              "banner": {
+                "format": [
+                  {
+                    "w": 300,
+                    "h": 600
+                  }
+                ]
+              },
+              "ext": {
+                "ad_unit_id": "33c76a5f-d9ef-4630-9e1e-7705673f1693",
+                "publisher_id": "0c0ac25e-e0b5-46d7-94d1-e1bf68034dd9"
+              }
+            }
+          ]
+        }
+      },
+      "mockResponse": {
+        "status": 200
+      }
+    }
+  ],
+  "expectedBidResponses": []
+}
diff --git a/adapters/frvradn/frvradntest/supplemental/missing_bid_ext.json b/adapters/frvradn/frvradntest/supplemental/missing_bid_ext.json
new file mode 100644
index 00000000..32b54f20
--- /dev/null
+++ b/adapters/frvradn/frvradntest/supplemental/missing_bid_ext.json
@@ -0,0 +1,81 @@
+{
+  "mockBidRequest": {
+    "id": "test-request-id",
+    "imp": [
+      {
+        "id": "test-banner-id",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 600
+            }
+          ]
+        },
+        "ext": {
+          "bidder": {
+            "ad_unit_id": "56f94390-e632-46e3-8e0f-e399d7d6e10d",
+            "publisher_id": "96ce87c5-1c9d-4456-a093-9d8162e54b44"
+          }
+        }
+      }
+    ]
+  },
+  "httpCalls": [
+    {
+      "expectedRequest": {
+        "uri": "https://fran.frvr.com/api/v1/openrtb",
+        "body": {
+          "id": "test-request-id",
+          "imp": [
+            {
+              "id": "test-banner-id",
+              "banner": {
+                "format": [
+                  {
+                    "w": 300,
+                    "h": 600
+                  }
+                ]
+              },
+              "ext": {
+                "ad_unit_id": "56f94390-e632-46e3-8e0f-e399d7d6e10d",
+                "publisher_id": "96ce87c5-1c9d-4456-a093-9d8162e54b44"
+              }
+            }
+          ]
+        }
+      },
+      "mockResponse": {
+        "status": 200,
+        "body": {
+          "id": "test-request-id",
+          "seatbid": [
+            {
+              "seat": "test-seat",
+              "bid": [
+                {
+                  "id": "ec174d08-4c15-4ea0-95cc-e55bb5bfa0f9\n\n",
+                  "impid": "random-imp",
+                  "price": 0.50,
+                  "adm": "a creative",
+                  "adid": "9999",
+                  "adomain": ["advertiser.com"],
+                  "crid": "9999",
+                  "w": 300,
+                  "h": 600
+                }
+              ]
+            }
+          ]
+        }
+      }
+    }
+  ],
+  "expectedMakeBidsErrors": [
+    {
+      "value": "unable to deserialize imp random-imp bid.ext",
+      "comparison": "literal"
+    }
+  ]
+}
diff --git a/adapters/frvradn/frvradntest/supplemental/missing_ext.json b/adapters/frvradn/frvradntest/supplemental/missing_ext.json
new file mode 100644
index 00000000..4c22142b
--- /dev/null
+++ b/adapters/frvradn/frvradntest/supplemental/missing_ext.json
@@ -0,0 +1,24 @@
+{
+  "mockBidRequest": {
+    "id": "test-request-id",
+    "imp": [
+      {
+        "id": "test-banner-id",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 600
+            }
+          ]
+        }
+      }
+    ]
+  },
+  "expectedMakeRequestsErrors": [
+    {
+      "value": "missing ext",
+      "comparison": "literal"
+    }
+  ]
+}
diff --git a/adapters/frvradn/frvradntest/supplemental/missing_ext_bidder.json b/adapters/frvradn/frvradntest/supplemental/missing_ext_bidder.json
new file mode 100644
index 00000000..b88f989a
--- /dev/null
+++ b/adapters/frvradn/frvradntest/supplemental/missing_ext_bidder.json
@@ -0,0 +1,25 @@
+{
+  "mockBidRequest": {
+    "id": "test-request-id",
+    "imp": [
+      {
+        "id": "test-banner-id",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 600
+            }
+          ]
+        },
+        "ext": {}
+      }
+    ]
+  },
+  "expectedMakeRequestsErrors": [
+    {
+      "value": "missing ext.bidder",
+      "comparison": "literal"
+    }
+  ]
+}
diff --git a/adapters/frvradn/frvradntest/supplemental/missing_params.json b/adapters/frvradn/frvradntest/supplemental/missing_params.json
new file mode 100644
index 00000000..4aba47d2
--- /dev/null
+++ b/adapters/frvradn/frvradntest/supplemental/missing_params.json
@@ -0,0 +1,27 @@
+{
+  "mockBidRequest": {
+    "id": "test-request-id",
+    "imp": [
+      {
+        "id": "test-banner-id",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 600
+            }
+          ]
+        },
+        "ext": {
+          "bidder": {}
+        }
+      }
+    ]
+  },
+  "expectedMakeRequestsErrors": [
+    {
+      "value": "publisher_id and ad_unit_id are required",
+      "comparison": "literal"
+    }
+  ]
+}
diff --git a/adapters/frvradn/frvradntest/supplemental/unknown_currency.json b/adapters/frvradn/frvradntest/supplemental/unknown_currency.json
new file mode 100644
index 00000000..74332ad6
--- /dev/null
+++ b/adapters/frvradn/frvradntest/supplemental/unknown_currency.json
@@ -0,0 +1,44 @@
+{
+  "mockBidRequest": {
+    "id": "test-request-id",
+    "imp": [
+      {
+        "id": "test-banner-id",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 600
+            }
+          ]
+        },
+        "bidfloor": 1,
+        "bidfloorcur": "EUR",
+        "ext": {
+          "bidder": {
+            "ad_unit_id": "33c76a5f-d9ef-4630-9e1e-7705673f1693",
+            "publisher_id": "0c0ac25e-e0b5-46d7-94d1-e1bf68034dd9"
+          }
+        }
+      }
+    ],
+    "ext": {
+      "prebid": {
+        "currency": {
+          "rates": {
+            "GBP": {
+              "USD": 1.5
+            }
+          },
+          "usepbsrates": false
+        }
+      }
+    }
+  },
+  "expectedMakeRequestsErrors": [
+    {
+      "value": "Currency conversion rate not found: 'EUR' => 'USD'",
+      "comparison": "literal"
+    }
+  ]
+}
diff --git a/adapters/frvradn/frvradntest/supplemental/unknown_imp_media_type.json b/adapters/frvradn/frvradntest/supplemental/unknown_imp_media_type.json
new file mode 100644
index 00000000..7ed869d7
--- /dev/null
+++ b/adapters/frvradn/frvradntest/supplemental/unknown_imp_media_type.json
@@ -0,0 +1,66 @@
+{
+  "mockBidRequest": {
+    "id": "test-request-id",
+    "imp": [
+      {
+        "id": "test-banner-id",
+        "ext": {
+          "bidder": {
+            "ad_unit_id": "cdb3a029-ce0a-4b4b-b404-1d5f9ff86d7d",
+            "publisher_id": "dc4c0f5e-8637-4017-8aa8-af59d4bc03af"
+          }
+        }
+      }
+    ]
+  },
+  "httpCalls": [
+    {
+      "expectedRequest": {
+        "uri": "https://fran.frvr.com/api/v1/openrtb",
+        "body": {
+          "id": "test-request-id",
+          "imp": [
+            {
+              "id": "test-banner-id",
+              "ext": {
+                "ad_unit_id": "cdb3a029-ce0a-4b4b-b404-1d5f9ff86d7d",
+                "publisher_id": "dc4c0f5e-8637-4017-8aa8-af59d4bc03af"
+              }
+            }
+          ]
+        }
+      },
+      "mockResponse": {
+        "status": 200,
+        "body": {
+          "id": "test-request-id",
+          "seatbid": [
+            {
+              "seat": "test-seat",
+              "bid": [
+                {
+                  "id": "e2710817-83dd-421f-9cd6-55bed1ca8a41",
+                  "impid": "test-banner-id",
+                  "price": 5.50,
+                  "adm": "test-banner",
+                  "adid": "9999",
+                  "adomain": ["advertiser.com"],
+                  "crid": "9999",
+                  "w": 300,
+                  "h": 600,
+                  "ext": {}
+                }
+              ]
+            }
+          ]
+        }
+      }
+    }
+  ],
+  "expectedMakeBidsErrors": [
+    {
+      "value": "imp test-banner-id with unknown media type",
+      "comparison": "literal"
+    }
+  ]
+}
diff --git a/adapters/frvradn/params_test.go b/adapters/frvradn/params_test.go
new file mode 100644
index 00000000..2d4836c1
--- /dev/null
+++ b/adapters/frvradn/params_test.go
@@ -0,0 +1,48 @@
+package frvradn
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/prebid/prebid-server/openrtb_ext"
+)
+
+func TestValidParams(t *testing.T) {
+	validator, err := openrtb_ext.NewBidderParamsValidator("../../static/bidder-params")
+	if err != nil {
+		t.Fatalf("Failed to fetch the json schema. %v", err)
+	}
+
+	for _, p := range validParams {
+		if err := validator.Validate(openrtb_ext.BidderFRVRAdNetwork, json.RawMessage(p)); err != nil {
+			t.Errorf("Schema rejected valid params: %s", p)
+		}
+	}
+}
+
+func TestInvalidParams(t *testing.T) {
+	validator, err := openrtb_ext.NewBidderParamsValidator("../../static/bidder-params")
+	if err != nil {
+		t.Fatalf("Failed to fetch the json schema. %v", err)
+	}
+
+	for _, p := range invalidParams {
+		if err := validator.Validate(openrtb_ext.BidderFRVRAdNetwork, json.RawMessage(p)); err == nil {
+			t.Errorf("Schema allowed invalid params: %s", p)
+		}
+	}
+}
+
+var validParams = []string{
+	`{"publisher_id": "247f36ed-bda5-4159-86f1-e383849e7810", "ad_unit_id": "63c36c82-3246-4931-97f9-4f16a9639ba9"}`,
+}
+
+var invalidParams = []string{
+	`null`,
+	`nil`,
+	``,
+	`[]`,
+	`{}`,
+	`{"publisher_id": "247f36ed-bda5-4159-86f1-e383849e7810"}`,
+	`{"ad_unit_id": "247f36ed-bda5-4159-86f1-e383849e7810"}`,
+}
diff --git a/adapters/richaudience/richaudiencetest/exemplary/single-banner-app.json b/adapters/richaudience/richaudiencetest/exemplary/single-banner-app.json
index 4b760494..672d8ab0 100644
--- a/adapters/richaudience/richaudiencetest/exemplary/single-banner-app.json
+++ b/adapters/richaudience/richaudiencetest/exemplary/single-banner-app.json
@@ -13,6 +13,7 @@
           },
           "bidfloor": 1e-05,
           "bidfloorcur": "USD",
+          "tagid": "testTag",
           "ext": {
            "bidder": {
               "pid": "OsNsyeF68q",
@@ -24,7 +25,6 @@
       ],
       "app": {
         "id": "12345678",
-        "keywords": "tagid=",
         "name": "Richaudience TV",
         "bundle": "R12345678901011",
         "publisher": {
@@ -41,7 +41,7 @@
         }
     },
       "device": {
-        "ip": "11.222.33.44",
+        "ip": "10.20.30.40",
         "ifa": "zxcjbzxmc-zxcbmz-zxbcz-zxczx"
     },
       "user": {
@@ -86,7 +86,7 @@
             ],
             "app": {
               "id": "12345678",
-              "keywords": "tagid=",
+              "keywords": "tagid=testTag",
               "name": "Richaudience TV",
               "bundle": "R12345678901011",
               "publisher": {
diff --git a/adapters/richaudience/richaudiencetest/exemplary/single-banner-defaultCurrency.json b/adapters/richaudience/richaudiencetest/exemplary/single-banner-defaultCurrency.json
index 7583c13f..0ddec0a4 100644
--- a/adapters/richaudience/richaudiencetest/exemplary/single-banner-defaultCurrency.json
+++ b/adapters/richaudience/richaudiencetest/exemplary/single-banner-defaultCurrency.json
@@ -14,6 +14,7 @@
           ]
         },
         "bidfloor": 1e-05,
+        "tagid": "testTag",
         "ext": {
           "bidder": {
             "pid": "OsNsyeF68q",
@@ -24,12 +25,11 @@
       }
     ],
     "site": {
-      "keywords": "tagid=",
       "domain": "bridge.richmediastudio.com",
       "page": "https://bridge.richmediastudio.com//ab083674fb8200b877a6983126e4477d/PrebidServer/indexRa.html?pbjs_debug=true"
     },
     "device": {
-      "ip": "11.222.33.44",
+      "ip": "10.20.30.40",
       "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36"
     },
     "user": {
@@ -80,7 +80,7 @@
             }
           ],
           "site": {
-            "keywords": "tagid=",
+            "keywords": "tagid=testTag",
             "domain": "bridge.richmediastudio.com",
             "page": "https://bridge.richmediastudio.com//ab083674fb8200b877a6983126e4477d/PrebidServer/indexRa.html?pbjs_debug=true"
           },
diff --git a/adapters/richaudience/richaudiencetest/exemplary/single-banner-deviceConfig.json b/adapters/richaudience/richaudiencetest/exemplary/single-banner-deviceConfig.json
index e4bc3327..5ccc8f14 100644
--- a/adapters/richaudience/richaudiencetest/exemplary/single-banner-deviceConfig.json
+++ b/adapters/richaudience/richaudiencetest/exemplary/single-banner-deviceConfig.json
@@ -13,6 +13,7 @@
           },
           "bidfloor": 1e-05,
           "bidfloorcur": "USD",
+          "tagid": "testTag",
           "ext": {
            "bidder": {
               "pid": "OsNsyeF68q",
@@ -23,14 +24,13 @@
         }
       ],
       "site": {
-        "keywords": "tagid=",
         "domain": "bridge.richmediastudio.com",
         "page": "https://bridge.richmediastudio.com//ab083674fb8200b877a6983126e4477d/PrebidServer/indexRa.html?pbjs_debug=true"
       },
       "device": {
           "dnt": 1,
           "lmt": 1,
-        "ip": "11.222.33.44"
+        "ip": "10.20.30.40"
     },
       "user": {
         "buyeruid": "189f4055-78a3-46eb-b7fd-0915a1a43bd2a",
@@ -73,7 +73,7 @@
               }
             ],
             "site": {
-              "keywords": "tagid=",
+              "keywords": "tagid=testTag",
               "domain": "bridge.richmediastudio.com",
               "page": "https://bridge.richmediastudio.com//ab083674fb8200b877a6983126e4477d/PrebidServer/indexRa.html?pbjs_debug=true"
             },
diff --git a/adapters/richaudience/richaudiencetest/exemplary/single-banner-extUser.json b/adapters/richaudience/richaudiencetest/exemplary/single-banner-extUser.json
index d22e1cf6..b6227d37 100644
--- a/adapters/richaudience/richaudiencetest/exemplary/single-banner-extUser.json
+++ b/adapters/richaudience/richaudiencetest/exemplary/single-banner-extUser.json
@@ -25,12 +25,11 @@
       }
     ],
     "site": {
-      "keywords": "tagid=",
       "domain": "bridge.richmediastudio.com",
       "page": "https://bridge.richmediastudio.com//ab083674fb8200b877a6983126e4477d/PrebidServer/indexRa.html?pbjs_debug=true"
     },
     "device": {
-      "ip": "11.222.33.44",
+      "ip": "10.20.30.40",
       "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36"
     },
     "user": {
diff --git a/adapters/richaudience/richaudiencetest/exemplary/single-banner-floorPrice.json b/adapters/richaudience/richaudiencetest/exemplary/single-banner-floorPrice.json
index 7d76a12b..9fec4d46 100644
--- a/adapters/richaudience/richaudiencetest/exemplary/single-banner-floorPrice.json
+++ b/adapters/richaudience/richaudiencetest/exemplary/single-banner-floorPrice.json
@@ -24,12 +24,11 @@
         }
       ],
       "site": {
-        "keywords": "tagid=",
         "domain": "bridge.richmediastudio.com",
         "page": "https://bridge.richmediastudio.com//ab083674fb8200b877a6983126e4477d/PrebidServer/indexRa.html?pbjs_debug=true"
       },
       "device": {
-        "ip": "11.222.33.44",
+        "ip": "10.20.30.40",
         "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36"
     },
       "user": {
diff --git a/adapters/richaudience/richaudiencetest/exemplary/single-banner-iPv6.json b/adapters/richaudience/richaudiencetest/exemplary/single-banner-iPv6.json
index 93fabba3..b40e9f65 100644
--- a/adapters/richaudience/richaudiencetest/exemplary/single-banner-iPv6.json
+++ b/adapters/richaudience/richaudiencetest/exemplary/single-banner-iPv6.json
@@ -25,12 +25,11 @@
       }
     ],
     "site": {
-      "keywords": "tagid=",
       "domain": "bridge.richmediastudio.com",
       "page": "https://bridge.richmediastudio.com//ab083674fb8200b877a6983126e4477d/PrebidServer/indexRa.html?pbjs_debug=true"
     },
     "device": {
-      "ip": "11.222.33.44",
+      "ip": "10.20.30.40",
       "ipv6": "2607:fb90:f27:4512:d800:cb23:a603:e245",
       "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36"
     },
diff --git a/adapters/richaudience/richaudiencetest/exemplary/single-banner-nil-device.json b/adapters/richaudience/richaudiencetest/exemplary/single-banner-nil-device.json
new file mode 100644
index 00000000..f5e38991
--- /dev/null
+++ b/adapters/richaudience/richaudiencetest/exemplary/single-banner-nil-device.json
@@ -0,0 +1,168 @@
+{
+  "mockBidRequest": {
+    "id": "4d3f84eb-787b-42fb-a0cf-062690dadce3",
+    "test": 0,
+    "imp": [
+      {
+        "id": "div-gpt-ad-1460505748561-0",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 250
+            }
+          ]
+        },
+        "bidfloor": 1e-05,
+        "bidfloorcur": "USD",
+        "tagid": "testTag",
+        "ext": {
+          "bidder": {
+            "pid": "OsNsyeF68q",
+            "supplyType": "site",
+            "test": true
+          }
+        }
+      }
+    ],
+    "app": {
+      "id": "12345678",
+      "name": "Richaudience TV",
+      "bundle": "R12345678901011",
+      "publisher": {
+        "id": "1234567",
+        "ext": {
+          "prebid": {
+            "parentAccount": "891011"
+          }
+        }
+      },
+      "content": {
+        "title": "Richaudience TV",
+        "series": "Richaudience TV"
+      }
+    },
+    "user": {
+      "buyeruid": "189f4055-78a3-46eb-b7fd-0915a1a43bd2a",
+      "ext": {}
+    }
+  },
+  "httpCalls": [
+    {
+      "expectedRequest": {
+        "uri": "http://ortb.richaudience.com/ortb/?bidder=pbs",
+        "headers": {
+          "Content-Type": [
+            "application/json;charset=utf-8"
+          ],
+          "Accept": [
+            "application/json"
+          ],
+          "X-Openrtb-Version": [
+            "2.5"
+          ]
+        },
+        "body": {
+          "id": "4d3f84eb-787b-42fb-a0cf-062690dadce3",
+          "test": 1,
+          "imp": [
+            {
+              "id": "div-gpt-ad-1460505748561-0",
+              "tagid": "OsNsyeF68q",
+              "secure": 0,
+              "banner": {
+                "format": [
+                  {
+                    "w": 300,
+                    "h": 250
+                  }
+                ]
+              },
+              "bidfloor": 1e-05,
+              "bidfloorcur": "USD",
+              "ext": {
+                "bidder": {
+                  "pid": "OsNsyeF68q",
+                  "supplyType": "site",
+                  "test": true
+                }
+              }
+            }
+          ],
+          "app": {
+            "id": "12345678",
+            "keywords": "tagid=testTag",
+            "name": "Richaudience TV",
+            "bundle": "R12345678901011",
+            "publisher": {
+              "id": "1234567",
+              "ext": {
+                "prebid": {
+                  "parentAccount": "891011"
+                }
+              }
+            },
+            "content": {
+              "title": "Richaudience TV",
+              "series": "Richaudience TV"
+            }
+          },
+          "device": {
+            "ip": "11.222.33.44"
+          },
+          "user": {
+            "buyeruid": "189f4055-78a3-46eb-b7fd-0915a1a43bd2a",
+            "ext": {
+            }
+          }
+        }
+      },
+      "mockResponse": {
+        "status": 200,
+        "body": {
+          "id": "ff935bea-4661-40bf-95b7-80c354cf0cdc",
+          "seatbid": [
+            {
+              "bid": [
+                {
+                  "id": "47286888",
+                  "impid": "div-gpt-ad-1460505748561-0",
+                  "price": 99,
+                  "crid": "999999",
+                  "adm": "<!-- creative -->",
+                  "adomain": [
+                    "richaudience.com"
+                  ],
+                  "h": 250,
+                  "w": 300
+                }
+              ]
+            }
+          ]
+        }
+      }
+    }
+  ],
+  "expectedBidResponses": [
+    {
+      "currency": "USD",
+      "bids": [
+        {
+          "bid": {
+            "id": "47286888",
+            "impid": "div-gpt-ad-1460505748561-0",
+            "price": 99,
+            "adm": "<!-- creative -->",
+            "adomain": [
+              "richaudience.com"
+            ],
+            "crid": "999999",
+            "w": 300,
+            "h": 250
+          },
+          "type": "banner"
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/adapters/richaudience/richaudiencetest/exemplary/single-banner-nosecure.json b/adapters/richaudience/richaudiencetest/exemplary/single-banner-nosecure.json
index 68cc0af6..78886f38 100644
--- a/adapters/richaudience/richaudiencetest/exemplary/single-banner-nosecure.json
+++ b/adapters/richaudience/richaudiencetest/exemplary/single-banner-nosecure.json
@@ -23,12 +23,11 @@
         }
       ],
       "site": {
-        "keywords": "tagid=",
         "domain": "bridge.richmediastudio.com",
         "page": "http://bridge.richmediastudio.com//ab083674fb8200b877a6983126e4477d/PrebidServer/indexRa.html?pbjs_debug=true"
       },
       "device": {
-        "ip": "11.222.33.44",
+        "ip": "10.20.30.40",
         "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36"
     },
       "user": {
diff --git a/adapters/richaudience/richaudiencetest/exemplary/single-banner-setCurrency.json b/adapters/richaudience/richaudiencetest/exemplary/single-banner-setCurrency.json
index 70662052..5490197d 100644
--- a/adapters/richaudience/richaudiencetest/exemplary/single-banner-setCurrency.json
+++ b/adapters/richaudience/richaudiencetest/exemplary/single-banner-setCurrency.json
@@ -23,12 +23,11 @@
         }
       ],
       "site": {
-        "keywords": "tagid=",
         "domain": "bridge.richmediastudio.com",
         "page": "https://bridge.richmediastudio.com//ab083674fb8200b877a6983126e4477d/PrebidServer/indexRa.html?pbjs_debug=true"
       },
       "device": {
-        "ip": "11.222.33.44",
+        "ip": "10.20.30.40",
         "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36"
     },
       "user": {
diff --git a/adapters/richaudience/richaudiencetest/exemplary/single-banner-sitePage.json b/adapters/richaudience/richaudiencetest/exemplary/single-banner-sitePage.json
index 901af4a8..e6a00cbf 100644
--- a/adapters/richaudience/richaudiencetest/exemplary/single-banner-sitePage.json
+++ b/adapters/richaudience/richaudiencetest/exemplary/single-banner-sitePage.json
@@ -23,12 +23,11 @@
         }
       ],
       "site": {
-        "keywords": "tagid=",
         "domain": "bridge.richmediastudio.com",
         "page": "https://bridge.richmediastudio.com//ab083674fb8200b877a6983126e4477d/PrebidServer/indexRa.html?pbjs_debug=true"
       },
       "device": {
-        "ip": "11.222.33.44",
+        "ip": "10.20.30.40",
         "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36"
     },
       "user": {
diff --git a/adapters/richaudience/richaudiencetest/exemplary/single-banner.json b/adapters/richaudience/richaudiencetest/exemplary/single-banner.json
index 901af4a8..e6a00cbf 100644
--- a/adapters/richaudience/richaudiencetest/exemplary/single-banner.json
+++ b/adapters/richaudience/richaudiencetest/exemplary/single-banner.json
@@ -23,12 +23,11 @@
         }
       ],
       "site": {
-        "keywords": "tagid=",
         "domain": "bridge.richmediastudio.com",
         "page": "https://bridge.richmediastudio.com//ab083674fb8200b877a6983126e4477d/PrebidServer/indexRa.html?pbjs_debug=true"
       },
       "device": {
-        "ip": "11.222.33.44",
+        "ip": "10.20.30.40",
         "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36"
     },
       "user": {
diff --git a/adapters/richaudience/richaudiencetest/exemplary/single-video.json b/adapters/richaudience/richaudiencetest/exemplary/single-video.json
index 2d18c01a..0219906f 100644
--- a/adapters/richaudience/richaudiencetest/exemplary/single-video.json
+++ b/adapters/richaudience/richaudiencetest/exemplary/single-video.json
@@ -23,12 +23,11 @@
         }
       ],
       "site": {
-        "keywords": "tagid=",
         "domain": "bridge.richmediastudio.com",
         "page": "https://bridge.richmediastudio.com//ab083674fb8200b877a6983126e4477d/PrebidServer/indexRa.html?pbjs_debug=true"
       },
       "device": {
-        "ip": "11.222.33.44",
+        "ip": "10.20.30.40",
         "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36"
     },
       "user": {
diff --git a/config/config_test.go b/config/config_test.go
index d63dab8d..f45d7fcd 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -145,53 +145,53 @@ func TestExternalCacheURLValidate(t *testing.T) {
 func TestDefaults(t *testing.T) {
 	cfg, _ := newDefaultConfig(t)
 
-	cmpInts(t, "port", cfg.Port, 8000)
-	cmpInts(t, "admin_port", cfg.AdminPort, 6060)
-	cmpInts(t, "auction_timeouts_ms.max", int(cfg.AuctionTimeouts.Max), 0)
-	cmpInts(t, "max_request_size", int(cfg.MaxRequestSize), 1024*256)
-	cmpInts(t, "host_cookie.ttl_days", int(cfg.HostCookie.TTL), 90)
-	cmpInts(t, "host_cookie.max_cookie_size_bytes", cfg.HostCookie.MaxCookieSizeBytes, 0)
-	cmpInts(t, "currency_converter.fetch_interval_seconds", cfg.CurrencyConverter.FetchIntervalSeconds, 1800)
-	cmpStrings(t, "currency_converter.fetch_url", cfg.CurrencyConverter.FetchURL, "https://cdn.jsdelivr.net/gh/prebid/currency-file@1/latest.json")
-	cmpBools(t, "account_required", cfg.AccountRequired, false)
-	cmpInts(t, "metrics.influxdb.collection_rate_seconds", cfg.Metrics.Influxdb.MetricSendInterval, 20)
-	cmpBools(t, "account_adapter_details", cfg.Metrics.Disabled.AccountAdapterDetails, false)
-	cmpBools(t, "account_debug", cfg.Metrics.Disabled.AccountDebug, true)
-	cmpBools(t, "account_stored_responses", cfg.Metrics.Disabled.AccountStoredResponses, true)
-	cmpBools(t, "adapter_connections_metrics", cfg.Metrics.Disabled.AdapterConnectionMetrics, true)
-	cmpBools(t, "adapter_gdpr_request_blocked", cfg.Metrics.Disabled.AdapterGDPRRequestBlocked, false)
-	cmpStrings(t, "certificates_file", cfg.PemCertsFile, "")
+	cmpInts(t, "port", 8000, cfg.Port)
+	cmpInts(t, "admin_port", 6060, cfg.AdminPort)
+	cmpInts(t, "auction_timeouts_ms.max", 0, int(cfg.AuctionTimeouts.Max))
+	cmpInts(t, "max_request_size", 1024*256, int(cfg.MaxRequestSize))
+	cmpInts(t, "host_cookie.ttl_days", 90, int(cfg.HostCookie.TTL))
+	cmpInts(t, "host_cookie.max_cookie_size_bytes", 0, cfg.HostCookie.MaxCookieSizeBytes)
+	cmpInts(t, "currency_converter.fetch_interval_seconds", 1800, cfg.CurrencyConverter.FetchIntervalSeconds)
+	cmpStrings(t, "currency_converter.fetch_url", "https://cdn.jsdelivr.net/gh/prebid/currency-file@1/latest.json", cfg.CurrencyConverter.FetchURL)
+	cmpBools(t, "account_required", false, cfg.AccountRequired)
+	cmpInts(t, "metrics.influxdb.collection_rate_seconds", 20, cfg.Metrics.Influxdb.MetricSendInterval)
+	cmpBools(t, "account_adapter_details", false, cfg.Metrics.Disabled.AccountAdapterDetails)
+	cmpBools(t, "account_debug", true, cfg.Metrics.Disabled.AccountDebug)
+	cmpBools(t, "account_stored_responses", true, cfg.Metrics.Disabled.AccountStoredResponses)
+	cmpBools(t, "adapter_connections_metrics", true, cfg.Metrics.Disabled.AdapterConnectionMetrics)
+	cmpBools(t, "adapter_gdpr_request_blocked", false, cfg.Metrics.Disabled.AdapterGDPRRequestBlocked)
+	cmpStrings(t, "certificates_file", "", cfg.PemCertsFile)
 	cmpBools(t, "stored_requests.filesystem.enabled", false, cfg.StoredRequests.Files.Enabled)
 	cmpStrings(t, "stored_requests.filesystem.directorypath", "./stored_requests/data/by_id", cfg.StoredRequests.Files.Path)
-	cmpBools(t, "auto_gen_source_tid", cfg.AutoGenSourceTID, true)
-	cmpBools(t, "generate_bid_id", cfg.GenerateBidID, false)
-	cmpStrings(t, "experiment.adscert.mode", cfg.Experiment.AdCerts.Mode, "off")
-	cmpStrings(t, "experiment.adscert.inprocess.origin", cfg.Experiment.AdCerts.InProcess.Origin, "")
-	cmpStrings(t, "experiment.adscert.inprocess.key", cfg.Experiment.AdCerts.InProcess.PrivateKey, "")
-	cmpInts(t, "experiment.adscert.inprocess.domain_check_interval_seconds", cfg.Experiment.AdCerts.InProcess.DNSCheckIntervalInSeconds, 30)
-	cmpInts(t, "experiment.adscert.inprocess.domain_renewal_interval_seconds", cfg.Experiment.AdCerts.InProcess.DNSRenewalIntervalInSeconds, 30)
-	cmpStrings(t, "experiment.adscert.remote.url", cfg.Experiment.AdCerts.Remote.Url, "")
-	cmpInts(t, "experiment.adscert.remote.signing_timeout_ms", cfg.Experiment.AdCerts.Remote.SigningTimeoutMs, 5)
+	cmpBools(t, "auto_gen_source_tid", true, cfg.AutoGenSourceTID)
+	cmpBools(t, "generate_bid_id", false, cfg.GenerateBidID)
+	cmpStrings(t, "experiment.adscert.mode", "off", cfg.Experiment.AdCerts.Mode)
+	cmpStrings(t, "experiment.adscert.inprocess.origin", "", cfg.Experiment.AdCerts.InProcess.Origin)
+	cmpStrings(t, "experiment.adscert.inprocess.key", "", cfg.Experiment.AdCerts.InProcess.PrivateKey)
+	cmpInts(t, "experiment.adscert.inprocess.domain_check_interval_seconds", 30, cfg.Experiment.AdCerts.InProcess.DNSCheckIntervalInSeconds)
+	cmpInts(t, "experiment.adscert.inprocess.domain_renewal_interval_seconds", 30, cfg.Experiment.AdCerts.InProcess.DNSRenewalIntervalInSeconds)
+	cmpStrings(t, "experiment.adscert.remote.url", "", cfg.Experiment.AdCerts.Remote.Url)
+	cmpInts(t, "experiment.adscert.remote.signing_timeout_ms", 5, cfg.Experiment.AdCerts.Remote.SigningTimeoutMs)
 	cmpNils(t, "host_schain_node", cfg.HostSChainNode)
-	cmpStrings(t, "datacenter", cfg.DataCenter, "")
+	cmpStrings(t, "datacenter", "", cfg.DataCenter)
 
 	//Assert the price floor default values
-	cmpBools(t, "price_floors.enabled", cfg.PriceFloors.Enabled, false)
-
-	cmpBools(t, "account_defaults.price_floors.enabled", cfg.AccountDefaults.PriceFloors.Enabled, false)
-	cmpInts(t, "account_defaults.price_floors.enforce_floors_rate", cfg.AccountDefaults.PriceFloors.EnforceFloorsRate, 100)
-	cmpBools(t, "account_defaults.price_floors.adjust_for_bid_adjustment", cfg.AccountDefaults.PriceFloors.AdjustForBidAdjustment, true)
-	cmpBools(t, "account_defaults.price_floors.enforce_deal_floors", cfg.AccountDefaults.PriceFloors.EnforceDealFloors, false)
-	cmpBools(t, "account_defaults.price_floors.use_dynamic_data", cfg.AccountDefaults.PriceFloors.UseDynamicData, false)
-	cmpInts(t, "account_defaults.price_floors.max_rules", cfg.AccountDefaults.PriceFloors.MaxRule, 100)
-	cmpInts(t, "account_defaults.price_floors.max_schema_dims", cfg.AccountDefaults.PriceFloors.MaxSchemaDims, 3)
-
-	cmpBools(t, "hooks.enabled", cfg.Hooks.Enabled, false)
-	cmpStrings(t, "validations.banner_creative_max_size", cfg.Validations.BannerCreativeMaxSize, "skip")
-	cmpStrings(t, "validations.secure_markup", cfg.Validations.SecureMarkup, "skip")
-	cmpInts(t, "validations.max_creative_width", int(cfg.Validations.MaxCreativeWidth), 0)
-	cmpInts(t, "validations.max_creative_height", int(cfg.Validations.MaxCreativeHeight), 0)
-	cmpBools(t, "account_modules_metrics", cfg.Metrics.Disabled.AccountModulesMetrics, false)
+	cmpBools(t, "price_floors.enabled", false, cfg.PriceFloors.Enabled)
+
+	cmpBools(t, "account_defaults.price_floors.enabled", false, cfg.AccountDefaults.PriceFloors.Enabled)
+	cmpInts(t, "account_defaults.price_floors.enforce_floors_rate", 100, cfg.AccountDefaults.PriceFloors.EnforceFloorsRate)
+	cmpBools(t, "account_defaults.price_floors.adjust_for_bid_adjustment", true, cfg.AccountDefaults.PriceFloors.AdjustForBidAdjustment)
+	cmpBools(t, "account_defaults.price_floors.enforce_deal_floors", false, cfg.AccountDefaults.PriceFloors.EnforceDealFloors)
+	cmpBools(t, "account_defaults.price_floors.use_dynamic_data", false, cfg.AccountDefaults.PriceFloors.UseDynamicData)
+	cmpInts(t, "account_defaults.price_floors.max_rules", 100, cfg.AccountDefaults.PriceFloors.MaxRule)
+	cmpInts(t, "account_defaults.price_floors.max_schema_dims", 3, cfg.AccountDefaults.PriceFloors.MaxSchemaDims)
+
+	cmpBools(t, "hooks.enabled", false, cfg.Hooks.Enabled)
+	cmpStrings(t, "validations.banner_creative_max_size", "skip", cfg.Validations.BannerCreativeMaxSize)
+	cmpStrings(t, "validations.secure_markup", "skip", cfg.Validations.SecureMarkup)
+	cmpInts(t, "validations.max_creative_width", 0, int(cfg.Validations.MaxCreativeWidth))
+	cmpInts(t, "validations.max_creative_height", 0, int(cfg.Validations.MaxCreativeHeight))
+	cmpBools(t, "account_modules_metrics", false, cfg.Metrics.Disabled.AccountModulesMetrics)
 
 	//Assert purpose VendorExceptionMap hash tables were built correctly
 	expectedTCF2 := TCF2{
@@ -462,24 +462,24 @@ stored_requests:
   directorypath: "/somepath"
 `)
 
-func cmpStrings(t *testing.T, key string, a string, b string) {
+func cmpStrings(t *testing.T, key, expected, actual string) {
 	t.Helper()
-	assert.Equal(t, a, b, "%s: %s != %s", key, a, b)
+	assert.Equal(t, expected, actual, "%s: %s != %s", key, expected, actual)
 }
 
-func cmpInts(t *testing.T, key string, a int, b int) {
+func cmpInts(t *testing.T, key string, expected, actual int) {
 	t.Helper()
-	assert.Equal(t, a, b, "%s: %d != %d", key, a, b)
+	assert.Equal(t, expected, actual, "%s: %d != %d", key, expected, actual)
 }
 
-func cmpInt8s(t *testing.T, key string, a *int8, b *int8) {
+func cmpInt8s(t *testing.T, key string, expected, actual *int8) {
 	t.Helper()
-	assert.Equal(t, a, b, "%s: %d != %d", key, a, b)
+	assert.Equal(t, expected, actual, "%s: %d != %d", key, expected, actual)
 }
 
-func cmpBools(t *testing.T, key string, a bool, b bool) {
+func cmpBools(t *testing.T, key string, expected, actual bool) {
 	t.Helper()
-	assert.Equal(t, a, b, "%s: %t != %t", key, a, b)
+	assert.Equal(t, expected, actual, "%s: %t != %t", key, expected, actual)
 }
 
 func cmpNils(t *testing.T, key string, a interface{}) {
@@ -496,53 +496,53 @@ func TestFullConfig(t *testing.T) {
 	v.ReadConfig(bytes.NewBuffer(fullConfig))
 	cfg, err := New(v, bidderInfos, mockNormalizeBidderName)
 	assert.NoError(t, err, "Setting up config should work but it doesn't")
-	cmpStrings(t, "cookie domain", cfg.HostCookie.Domain, "cookies.prebid.org")
-	cmpStrings(t, "cookie name", cfg.HostCookie.CookieName, "userid")
-	cmpStrings(t, "cookie family", cfg.HostCookie.Family, "prebid")
-	cmpStrings(t, "opt out", cfg.HostCookie.OptOutURL, "http://prebid.org/optout")
-	cmpStrings(t, "opt in", cfg.HostCookie.OptInURL, "http://prebid.org/optin")
-	cmpStrings(t, "external url", cfg.ExternalURL, "http://prebid-server.prebid.org/")
-	cmpStrings(t, "host", cfg.Host, "prebid-server.prebid.org")
-	cmpInts(t, "port", cfg.Port, 1234)
-	cmpInts(t, "admin_port", cfg.AdminPort, 5678)
-	cmpInts(t, "garbage_collector_threshold", cfg.GarbageCollectorThreshold, 1)
-	cmpInts(t, "auction_timeouts_ms.default", int(cfg.AuctionTimeouts.Default), 50)
-	cmpInts(t, "auction_timeouts_ms.max", int(cfg.AuctionTimeouts.Max), 123)
-	cmpStrings(t, "cache.scheme", cfg.CacheURL.Scheme, "http")
-	cmpStrings(t, "cache.host", cfg.CacheURL.Host, "prebidcache.net")
-	cmpStrings(t, "cache.query", cfg.CacheURL.Query, "uuid=%PBS_CACHE_UUID%")
-	cmpStrings(t, "external_cache.scheme", cfg.ExtCacheURL.Scheme, "https")
-	cmpStrings(t, "external_cache.host", cfg.ExtCacheURL.Host, "www.externalprebidcache.net")
-	cmpStrings(t, "external_cache.path", cfg.ExtCacheURL.Path, "/endpoints/cache")
-	cmpInts(t, "http_client.max_connections_per_host", cfg.Client.MaxConnsPerHost, 10)
-	cmpInts(t, "http_client.max_idle_connections", cfg.Client.MaxIdleConns, 500)
-	cmpInts(t, "http_client.max_idle_connections_per_host", cfg.Client.MaxIdleConnsPerHost, 20)
-	cmpInts(t, "http_client.idle_connection_timeout_seconds", cfg.Client.IdleConnTimeout, 30)
-	cmpInts(t, "http_client_cache.max_connections_per_host", cfg.CacheClient.MaxConnsPerHost, 5)
-	cmpInts(t, "http_client_cache.max_idle_connections", cfg.CacheClient.MaxIdleConns, 1)
-	cmpInts(t, "http_client_cache.max_idle_connections_per_host", cfg.CacheClient.MaxIdleConnsPerHost, 2)
-	cmpInts(t, "http_client_cache.idle_connection_timeout_seconds", cfg.CacheClient.IdleConnTimeout, 3)
-	cmpInts(t, "gdpr.host_vendor_id", cfg.GDPR.HostVendorID, 15)
-	cmpStrings(t, "gdpr.default_value", cfg.GDPR.DefaultValue, "1")
-	cmpStrings(t, "host_schain_node.asi", cfg.HostSChainNode.ASI, "pbshostcompany.com")
-	cmpStrings(t, "host_schain_node.sid", cfg.HostSChainNode.SID, "00001")
-	cmpStrings(t, "host_schain_node.rid", cfg.HostSChainNode.RID, "BidRequest")
-	cmpInt8s(t, "host_schain_node.hp", cfg.HostSChainNode.HP, &int8One)
-	cmpStrings(t, "datacenter", cfg.DataCenter, "1")
-	cmpStrings(t, "validations.banner_creative_max_size", cfg.Validations.BannerCreativeMaxSize, "skip")
-	cmpStrings(t, "validations.secure_markup", cfg.Validations.SecureMarkup, "skip")
-	cmpInts(t, "validations.max_creative_width", int(cfg.Validations.MaxCreativeWidth), 0)
-	cmpInts(t, "validations.max_creative_height", int(cfg.Validations.MaxCreativeHeight), 0)
+	cmpStrings(t, "cookie domain", "cookies.prebid.org", cfg.HostCookie.Domain)
+	cmpStrings(t, "cookie name", "userid", cfg.HostCookie.CookieName)
+	cmpStrings(t, "cookie family", "prebid", cfg.HostCookie.Family)
+	cmpStrings(t, "opt out", "http://prebid.org/optout", cfg.HostCookie.OptOutURL)
+	cmpStrings(t, "opt in", "http://prebid.org/optin", cfg.HostCookie.OptInURL)
+	cmpStrings(t, "external url", "http://prebid-server.prebid.org/", cfg.ExternalURL)
+	cmpStrings(t, "host", "prebid-server.prebid.org", cfg.Host)
+	cmpInts(t, "port", 1234, cfg.Port)
+	cmpInts(t, "admin_port", 5678, cfg.AdminPort)
+	cmpInts(t, "garbage_collector_threshold", 1, cfg.GarbageCollectorThreshold)
+	cmpInts(t, "auction_timeouts_ms.default", 50, int(cfg.AuctionTimeouts.Default))
+	cmpInts(t, "auction_timeouts_ms.max", 123, int(cfg.AuctionTimeouts.Max))
+	cmpStrings(t, "cache.scheme", "http", cfg.CacheURL.Scheme)
+	cmpStrings(t, "cache.host", "prebidcache.net", cfg.CacheURL.Host)
+	cmpStrings(t, "cache.query", "uuid=%PBS_CACHE_UUID%", cfg.CacheURL.Query)
+	cmpStrings(t, "external_cache.scheme", "https", cfg.ExtCacheURL.Scheme)
+	cmpStrings(t, "external_cache.host", "www.externalprebidcache.net", cfg.ExtCacheURL.Host)
+	cmpStrings(t, "external_cache.path", "/endpoints/cache", cfg.ExtCacheURL.Path)
+	cmpInts(t, "http_client.max_connections_per_host", 10, cfg.Client.MaxConnsPerHost)
+	cmpInts(t, "http_client.max_idle_connections", 500, cfg.Client.MaxIdleConns)
+	cmpInts(t, "http_client.max_idle_connections_per_host", 20, cfg.Client.MaxIdleConnsPerHost)
+	cmpInts(t, "http_client.idle_connection_timeout_seconds", 30, cfg.Client.IdleConnTimeout)
+	cmpInts(t, "http_client_cache.max_connections_per_host", 5, cfg.CacheClient.MaxConnsPerHost)
+	cmpInts(t, "http_client_cache.max_idle_connections", 1, cfg.CacheClient.MaxIdleConns)
+	cmpInts(t, "http_client_cache.max_idle_connections_per_host", 2, cfg.CacheClient.MaxIdleConnsPerHost)
+	cmpInts(t, "http_client_cache.idle_connection_timeout_seconds", 3, cfg.CacheClient.IdleConnTimeout)
+	cmpInts(t, "gdpr.host_vendor_id", 15, cfg.GDPR.HostVendorID)
+	cmpStrings(t, "gdpr.default_value", "1", cfg.GDPR.DefaultValue)
+	cmpStrings(t, "host_schain_node.asi", "pbshostcompany.com", cfg.HostSChainNode.ASI)
+	cmpStrings(t, "host_schain_node.sid", "00001", cfg.HostSChainNode.SID)
+	cmpStrings(t, "host_schain_node.rid", "BidRequest", cfg.HostSChainNode.RID)
+	cmpInt8s(t, "host_schain_node.hp", &int8One, cfg.HostSChainNode.HP)
+	cmpStrings(t, "datacenter", "1", cfg.DataCenter)
+	cmpStrings(t, "validations.banner_creative_max_size", "skip", cfg.Validations.BannerCreativeMaxSize)
+	cmpStrings(t, "validations.secure_markup", "skip", cfg.Validations.SecureMarkup)
+	cmpInts(t, "validations.max_creative_width", 0, int(cfg.Validations.MaxCreativeWidth))
+	cmpInts(t, "validations.max_creative_height", 0, int(cfg.Validations.MaxCreativeHeight))
 
 	//Assert the price floor values
-	cmpBools(t, "price_floors.enabled", cfg.PriceFloors.Enabled, true)
-	cmpBools(t, "account_defaults.price_floors.enabled", cfg.AccountDefaults.PriceFloors.Enabled, true)
-	cmpInts(t, "account_defaults.price_floors.enforce_floors_rate", cfg.AccountDefaults.PriceFloors.EnforceFloorsRate, 50)
-	cmpBools(t, "account_defaults.price_floors.adjust_for_bid_adjustment", cfg.AccountDefaults.PriceFloors.AdjustForBidAdjustment, false)
-	cmpBools(t, "account_defaults.price_floors.enforce_deal_floors", cfg.AccountDefaults.PriceFloors.EnforceDealFloors, true)
-	cmpBools(t, "account_defaults.price_floors.use_dynamic_data", cfg.AccountDefaults.PriceFloors.UseDynamicData, true)
-	cmpInts(t, "account_defaults.price_floors.max_rules", cfg.AccountDefaults.PriceFloors.MaxRule, 120)
-	cmpInts(t, "account_defaults.price_floors.max_schema_dims", cfg.AccountDefaults.PriceFloors.MaxSchemaDims, 5)
+	cmpBools(t, "price_floors.enabled", true, cfg.PriceFloors.Enabled)
+	cmpBools(t, "account_defaults.price_floors.enabled", true, cfg.AccountDefaults.PriceFloors.Enabled)
+	cmpInts(t, "account_defaults.price_floors.enforce_floors_rate", 50, cfg.AccountDefaults.PriceFloors.EnforceFloorsRate)
+	cmpBools(t, "account_defaults.price_floors.adjust_for_bid_adjustment", false, cfg.AccountDefaults.PriceFloors.AdjustForBidAdjustment)
+	cmpBools(t, "account_defaults.price_floors.enforce_deal_floors", true, cfg.AccountDefaults.PriceFloors.EnforceDealFloors)
+	cmpBools(t, "account_defaults.price_floors.use_dynamic_data", true, cfg.AccountDefaults.PriceFloors.UseDynamicData)
+	cmpInts(t, "account_defaults.price_floors.max_rules", 120, cfg.AccountDefaults.PriceFloors.MaxRule)
+	cmpInts(t, "account_defaults.price_floors.max_schema_dims", 5, cfg.AccountDefaults.PriceFloors.MaxSchemaDims)
 
 	//Assert the NonStandardPublishers was correctly unmarshalled
 	assert.Equal(t, []string{"pub1", "pub2"}, cfg.GDPR.NonStandardPublishers, "gdpr.non_standard_publishers")
@@ -552,16 +552,16 @@ func TestFullConfig(t *testing.T) {
 	assert.Equal(t, []string{"eea1", "eea2"}, cfg.GDPR.EEACountries, "gdpr.eea_countries")
 	assert.Equal(t, map[string]struct{}{"eea1": {}, "eea2": {}}, cfg.GDPR.EEACountriesMap, "gdpr.eea_countries Hash Map")
 
-	cmpBools(t, "ccpa.enforce", cfg.CCPA.Enforce, true)
-	cmpBools(t, "lmt.enforce", cfg.LMT.Enforce, true)
+	cmpBools(t, "ccpa.enforce", true, cfg.CCPA.Enforce)
+	cmpBools(t, "lmt.enforce", true, cfg.LMT.Enforce)
 
 	//Assert the NonStandardPublishers was correctly unmarshalled
-	cmpStrings(t, "blacklisted_apps", cfg.BlacklistedApps[0], "spamAppID")
-	cmpStrings(t, "blacklisted_apps", cfg.BlacklistedApps[1], "sketchy-app-id")
+	cmpStrings(t, "blacklisted_apps", "spamAppID", cfg.BlacklistedApps[0])
+	cmpStrings(t, "blacklisted_apps", "sketchy-app-id", cfg.BlacklistedApps[1])
 
 	//Assert the BlacklistedAppMap hash table was built correctly
 	for i := 0; i < len(cfg.BlacklistedApps); i++ {
-		cmpBools(t, "cfg.BlacklistedAppMap", cfg.BlacklistedAppMap[cfg.BlacklistedApps[i]], true)
+		cmpBools(t, "cfg.BlacklistedAppMap", true, cfg.BlacklistedAppMap[cfg.BlacklistedApps[i]])
 	}
 
 	//Assert purpose VendorExceptionMap hash tables were built correctly
@@ -681,40 +681,40 @@ func TestFullConfig(t *testing.T) {
 	}
 	assert.Equal(t, expectedTCF2, cfg.GDPR.TCF2, "gdpr.tcf2")
 
-	cmpStrings(t, "currency_converter.fetch_url", cfg.CurrencyConverter.FetchURL, "https://currency.prebid.org")
-	cmpInts(t, "currency_converter.fetch_interval_seconds", cfg.CurrencyConverter.FetchIntervalSeconds, 1800)
-	cmpStrings(t, "recaptcha_secret", cfg.RecaptchaSecret, "asdfasdfasdfasdf")
-	cmpStrings(t, "metrics.influxdb.host", cfg.Metrics.Influxdb.Host, "upstream:8232")
-	cmpStrings(t, "metrics.influxdb.database", cfg.Metrics.Influxdb.Database, "metricsdb")
-	cmpStrings(t, "metrics.influxdb.measurement", cfg.Metrics.Influxdb.Measurement, "anyMeasurement")
-	cmpStrings(t, "metrics.influxdb.username", cfg.Metrics.Influxdb.Username, "admin")
-	cmpStrings(t, "metrics.influxdb.password", cfg.Metrics.Influxdb.Password, "admin1324")
-	cmpBools(t, "metrics.influxdb.align_timestamps", cfg.Metrics.Influxdb.AlignTimestamps, true)
-	cmpInts(t, "metrics.influxdb.metric_send_interval", cfg.Metrics.Influxdb.MetricSendInterval, 30)
-	cmpStrings(t, "", cfg.CacheURL.GetBaseURL(), "http://prebidcache.net")
-	cmpStrings(t, "", cfg.GetCachedAssetURL("a0eebc99-9c0b-4ef8-bb00-6bb9bd380a11"), "http://prebidcache.net/cache?uuid=a0eebc99-9c0b-4ef8-bb00-6bb9bd380a11")
-	cmpBools(t, "account_required", cfg.AccountRequired, true)
-	cmpBools(t, "auto_gen_source_tid", cfg.AutoGenSourceTID, false)
-	cmpBools(t, "account_adapter_details", cfg.Metrics.Disabled.AccountAdapterDetails, true)
-	cmpBools(t, "account_debug", cfg.Metrics.Disabled.AccountDebug, false)
-	cmpBools(t, "account_stored_responses", cfg.Metrics.Disabled.AccountStoredResponses, false)
-	cmpBools(t, "adapter_connections_metrics", cfg.Metrics.Disabled.AdapterConnectionMetrics, true)
-	cmpBools(t, "adapter_gdpr_request_blocked", cfg.Metrics.Disabled.AdapterGDPRRequestBlocked, true)
-	cmpStrings(t, "certificates_file", cfg.PemCertsFile, "/etc/ssl/cert.pem")
-	cmpStrings(t, "request_validation.ipv4_private_networks", cfg.RequestValidation.IPv4PrivateNetworks[0], "1.1.1.0/24")
-	cmpStrings(t, "request_validation.ipv6_private_networks", cfg.RequestValidation.IPv6PrivateNetworks[0], "1111::/16")
-	cmpStrings(t, "request_validation.ipv6_private_networks", cfg.RequestValidation.IPv6PrivateNetworks[1], "2222::/16")
-	cmpBools(t, "generate_bid_id", cfg.GenerateBidID, true)
-	cmpStrings(t, "debug.override_token", cfg.Debug.OverrideToken, "")
-	cmpStrings(t, "experiment.adscert.mode", cfg.Experiment.AdCerts.Mode, "inprocess")
-	cmpStrings(t, "experiment.adscert.inprocess.origin", cfg.Experiment.AdCerts.InProcess.Origin, "http://test.com")
-	cmpStrings(t, "experiment.adscert.inprocess.key", cfg.Experiment.AdCerts.InProcess.PrivateKey, "ABC123")
-	cmpInts(t, "experiment.adscert.inprocess.domain_check_interval_seconds", cfg.Experiment.AdCerts.InProcess.DNSCheckIntervalInSeconds, 40)
-	cmpInts(t, "experiment.adscert.inprocess.domain_renewal_interval_seconds", cfg.Experiment.AdCerts.InProcess.DNSRenewalIntervalInSeconds, 60)
-	cmpStrings(t, "experiment.adscert.remote.url", cfg.Experiment.AdCerts.Remote.Url, "")
-	cmpInts(t, "experiment.adscert.remote.signing_timeout_ms", cfg.Experiment.AdCerts.Remote.SigningTimeoutMs, 10)
-	cmpBools(t, "hooks.enabled", cfg.Hooks.Enabled, true)
-	cmpBools(t, "account_modules_metrics", cfg.Metrics.Disabled.AccountModulesMetrics, true)
+	cmpStrings(t, "currency_converter.fetch_url", "https://currency.prebid.org", cfg.CurrencyConverter.FetchURL)
+	cmpInts(t, "currency_converter.fetch_interval_seconds", 1800, cfg.CurrencyConverter.FetchIntervalSeconds)
+	cmpStrings(t, "recaptcha_secret", "asdfasdfasdfasdf", cfg.RecaptchaSecret)
+	cmpStrings(t, "metrics.influxdb.host", "upstream:8232", cfg.Metrics.Influxdb.Host)
+	cmpStrings(t, "metrics.influxdb.database", "metricsdb", cfg.Metrics.Influxdb.Database)
+	cmpStrings(t, "metrics.influxdb.measurement", "anyMeasurement", cfg.Metrics.Influxdb.Measurement)
+	cmpStrings(t, "metrics.influxdb.username", "admin", cfg.Metrics.Influxdb.Username)
+	cmpStrings(t, "metrics.influxdb.password", "admin1324", cfg.Metrics.Influxdb.Password)
+	cmpBools(t, "metrics.influxdb.align_timestamps", true, cfg.Metrics.Influxdb.AlignTimestamps)
+	cmpInts(t, "metrics.influxdb.metric_send_interval", 30, cfg.Metrics.Influxdb.MetricSendInterval)
+	cmpStrings(t, "", "http://prebidcache.net", cfg.CacheURL.GetBaseURL())
+	cmpStrings(t, "", "http://prebidcache.net/cache?uuid=a0eebc99-9c0b-4ef8-bb00-6bb9bd380a11", cfg.GetCachedAssetURL("a0eebc99-9c0b-4ef8-bb00-6bb9bd380a11"))
+	cmpBools(t, "account_required", true, cfg.AccountRequired)
+	cmpBools(t, "auto_gen_source_tid", false, cfg.AutoGenSourceTID)
+	cmpBools(t, "account_adapter_details", true, cfg.Metrics.Disabled.AccountAdapterDetails)
+	cmpBools(t, "account_debug", false, cfg.Metrics.Disabled.AccountDebug)
+	cmpBools(t, "account_stored_responses", false, cfg.Metrics.Disabled.AccountStoredResponses)
+	cmpBools(t, "adapter_connections_metrics", true, cfg.Metrics.Disabled.AdapterConnectionMetrics)
+	cmpBools(t, "adapter_gdpr_request_blocked", true, cfg.Metrics.Disabled.AdapterGDPRRequestBlocked)
+	cmpStrings(t, "certificates_file", "/etc/ssl/cert.pem", cfg.PemCertsFile)
+	cmpStrings(t, "request_validation.ipv4_private_networks", "1.1.1.0/24", cfg.RequestValidation.IPv4PrivateNetworks[0])
+	cmpStrings(t, "request_validation.ipv6_private_networks", "1111::/16", cfg.RequestValidation.IPv6PrivateNetworks[0])
+	cmpStrings(t, "request_validation.ipv6_private_networks", "2222::/16", cfg.RequestValidation.IPv6PrivateNetworks[1])
+	cmpBools(t, "generate_bid_id", true, cfg.GenerateBidID)
+	cmpStrings(t, "debug.override_token", "", cfg.Debug.OverrideToken)
+	cmpStrings(t, "experiment.adscert.mode", "inprocess", cfg.Experiment.AdCerts.Mode)
+	cmpStrings(t, "experiment.adscert.inprocess.origin", "http://test.com", cfg.Experiment.AdCerts.InProcess.Origin)
+	cmpStrings(t, "experiment.adscert.inprocess.key", "ABC123", cfg.Experiment.AdCerts.InProcess.PrivateKey)
+	cmpInts(t, "experiment.adscert.inprocess.domain_check_interval_seconds", 40, cfg.Experiment.AdCerts.InProcess.DNSCheckIntervalInSeconds)
+	cmpInts(t, "experiment.adscert.inprocess.domain_renewal_interval_seconds", 60, cfg.Experiment.AdCerts.InProcess.DNSRenewalIntervalInSeconds)
+	cmpStrings(t, "experiment.adscert.remote.url", "", cfg.Experiment.AdCerts.Remote.Url)
+	cmpInts(t, "experiment.adscert.remote.signing_timeout_ms", 10, cfg.Experiment.AdCerts.Remote.SigningTimeoutMs)
+	cmpBools(t, "hooks.enabled", true, cfg.Hooks.Enabled)
+	cmpBools(t, "account_modules_metrics", true, cfg.Metrics.Disabled.AccountModulesMetrics)
 }
 
 func TestValidateConfig(t *testing.T) {
diff --git a/endpoints/openrtb2/auction_test.go b/endpoints/openrtb2/auction_test.go
index 00c6d7f7..f291d2e8 100644
--- a/endpoints/openrtb2/auction_test.go
+++ b/endpoints/openrtb2/auction_test.go
@@ -3590,6 +3590,11 @@ func TestAuctionWarnings(t *testing.T) {
 			file:            "us-privacy-conflict.json",
 			expectedWarning: "regs.us_privacy consent does not match uspv1 in GPP, using regs.gpp",
 		},
+		{
+			name:            "empty-gppsid-array-conflicts-with-regs-gdpr", // gdpr set to 1, an empty non-nil gpp_sid array doesn't match
+			file:            "empty-gppsid-conflict.json",
+			expectedWarning: "regs.gdpr signal conflicts with GPP (regs.gpp_sid) and will be ignored",
+		},
 		{
 			name:            "gdpr-signals-conflict", // gdpr signals do not match
 			file:            "gdpr-conflict.json",
diff --git a/exchange/bidder_test.go b/exchange/bidder_test.go
index 2aa03573..4e367ebe 100644
--- a/exchange/bidder_test.go
+++ b/exchange/bidder_test.go
@@ -2026,6 +2026,7 @@ func TestCallRecordAdapterConnections(t *testing.T) {
 
 	mockMetricEngine.On("RecordAdapterConnections", expectedAdapterName, false, mock.MatchedBy(compareConnWaitTime)).Once()
 	mockMetricEngine.On("RecordOverheadTime", metrics.PreBidder, mock.Anything).Once()
+	mockMetricEngine.On("RecordBidderServerResponseTime", mock.Anything).Once()
 
 	// Run requestBid using an http.Client with a mock handler
 	bidder := AdaptBidder(bidderImpl, server.Client(), &config.Configuration{}, mockMetricEngine, openrtb_ext.BidderAppnexus, nil, "")
@@ -2087,6 +2088,7 @@ func TestCallRecordDNSTime(t *testing.T) {
 	metricsMock := &metrics.MetricsEngineMock{}
 	metricsMock.Mock.On("RecordDNSTime", mock.Anything).Return()
 	metricsMock.On("RecordOverheadTime", metrics.PreBidder, mock.Anything).Once()
+	metricsMock.On("RecordBidderServerResponseTime", mock.Anything).Once()
 
 	// Instantiate the bidder that will send the request. We'll make sure to use an
 	// http.Client that runs our mock RoundTripper so DNSDone(httptrace.DNSDoneInfo{})
@@ -2109,6 +2111,7 @@ func TestCallRecordTLSHandshakeTime(t *testing.T) {
 	metricsMock := &metrics.MetricsEngineMock{}
 	metricsMock.Mock.On("RecordTLSHandshakeTime", mock.Anything).Return()
 	metricsMock.On("RecordOverheadTime", metrics.PreBidder, mock.Anything).Once()
+	metricsMock.On("RecordBidderServerResponseTime", mock.Anything).Once()
 
 	// Instantiate the bidder that will send the request. We'll make sure to use an
 	// http.Client that runs our mock RoundTripper so DNSDone(httptrace.DNSDoneInfo{})
diff --git a/metrics/go_metrics_test.go b/metrics/go_metrics_test.go
index 1b0b20e4..1ab22ac1 100644
--- a/metrics/go_metrics_test.go
+++ b/metrics/go_metrics_test.go
@@ -83,6 +83,7 @@ func TestNewMetrics(t *testing.T) {
 	ensureContains(t, registry, "request_over_head_time.pre-bidder", m.OverheadTimer[PreBidder])
 	ensureContains(t, registry, "request_over_head_time.make-auction-response", m.OverheadTimer[MakeAuctionResponse])
 	ensureContains(t, registry, "request_over_head_time.make-bidder-requests", m.OverheadTimer[MakeBidderRequests])
+	ensureContains(t, registry, "bidder_server_response_time_seconds", m.BidderServerResponseTimer)
 
 	for module, stages := range moduleStageNames {
 		for _, stage := range stages {
@@ -420,6 +421,36 @@ func TestRecordTLSHandshakeTime(t *testing.T) {
 	}
 }
 
+func TestRecordBidderServerResponseTime(t *testing.T) {
+	testCases := []struct {
+		name          string
+		time          time.Duration
+		expectedCount int64
+		expectedSum   int64
+	}{
+		{
+			name:          "record-bidder-server-response-time-1",
+			time:          time.Duration(500),
+			expectedCount: 1,
+			expectedSum:   500,
+		},
+		{
+			name:          "record-bidder-server-response-time-2",
+			time:          time.Duration(500),
+			expectedCount: 2,
+			expectedSum:   1000,
+		},
+	}
+	for _, test := range testCases {
+		registry := metrics.NewRegistry()
+		m := NewMetrics(registry, []openrtb_ext.BidderName{openrtb_ext.BidderAppnexus}, config.DisabledMetrics{AccountAdapterDetails: true}, nil, nil)
+
+		m.RecordBidderServerResponseTime(test.time)
+
+		assert.Equal(t, test.time.Nanoseconds(), m.BidderServerResponseTimer.Sum(), test.name)
+	}
+}
+
 func TestRecordAdapterConnections(t *testing.T) {
 	var fakeBidder openrtb_ext.BidderName = "fooAdvertising"
 
diff --git a/metrics/prometheus/prometheus_test.go b/metrics/prometheus/prometheus_test.go
index d2ede844..32f7848c 100644
--- a/metrics/prometheus/prometheus_test.go
+++ b/metrics/prometheus/prometheus_test.go
@@ -1475,6 +1475,39 @@ func TestRecordTLSHandshakeTime(t *testing.T) {
 	}
 }
 
+func TestRecordBidderServerResponseTime(t *testing.T) {
+	testCases := []struct {
+		description   string
+		timeInMs      float64
+		expectedCount uint64
+		expectedSum   float64
+	}{
+		{
+			description:   "record-bidder-server-response-time-1",
+			timeInMs:      500,
+			expectedCount: 1,
+			expectedSum:   0.5,
+		},
+		{
+			description:   "record-bidder-server-response-time-2",
+			timeInMs:      400,
+			expectedCount: 1,
+			expectedSum:   0.4,
+		},
+	}
+	for _, test := range testCases {
+		pm := createMetricsForTesting()
+		pm.RecordBidderServerResponseTime(time.Duration(test.timeInMs) * time.Millisecond)
+
+		m := dto.Metric{}
+		pm.bidderServerResponseTimer.Write(&m)
+		histogram := *m.GetHistogram()
+
+		assert.Equal(t, test.expectedCount, histogram.GetSampleCount())
+		assert.Equal(t, test.expectedSum, histogram.GetSampleSum())
+	}
+}
+
 func TestRecordAdapterConnections(t *testing.T) {
 
 	type testIn struct {
diff --git a/ortb/clone_test.go b/ortb/clone_test.go
new file mode 100644
index 00000000..24e43bda
--- /dev/null
+++ b/ortb/clone_test.go
@@ -0,0 +1,763 @@
+package ortb
+
+import (
+	"encoding/json"
+	"reflect"
+	"testing"
+
+	"github.com/prebid/openrtb/v19/adcom1"
+	"github.com/prebid/openrtb/v19/openrtb2"
+	"github.com/prebid/prebid-server/util/ptrutil"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestCloneApp(t *testing.T) {
+	t.Run("nil", func(t *testing.T) {
+		result := CloneApp(nil)
+		assert.Nil(t, result)
+	})
+
+	t.Run("empty", func(t *testing.T) {
+		given := &openrtb2.App{}
+		result := CloneApp(given)
+		assert.Equal(t, given, result)
+		assert.NotSame(t, given, result)
+	})
+
+	t.Run("populated", func(t *testing.T) {
+		given := &openrtb2.App{
+			ID:                     "anyID",
+			Name:                   "anyName",
+			Bundle:                 "anyBundle",
+			Domain:                 "anyDomain",
+			StoreURL:               "anyStoreURL",
+			CatTax:                 adcom1.CatTaxIABContent10,
+			Cat:                    []string{"cat1"},
+			SectionCat:             []string{"sectionCat1"},
+			PageCat:                []string{"pageCat1"},
+			Ver:                    "anyVer",
+			PrivacyPolicy:          1,
+			Paid:                   2,
+			Publisher:              &openrtb2.Publisher{ID: "anyPublisher", Ext: json.RawMessage(`{"publisher":1}`)},
+			Content:                &openrtb2.Content{ID: "anyContent", Ext: json.RawMessage(`{"content":1}`)},
+			Keywords:               "anyKeywords",
+			KwArray:                []string{"key1"},
+			InventoryPartnerDomain: "anyInventoryPartnerDomain",
+			Ext:                    json.RawMessage(`{"anyField":1}`),
+		}
+		result := CloneApp(given)
+		assert.Equal(t, given, result, "equality")
+		assert.NotSame(t, given, result, "pointer")
+		assert.NotSame(t, given.Cat, result.Cat, "cat")
+		assert.NotSame(t, given.SectionCat, result.SectionCat, "sectioncat")
+		assert.NotSame(t, given.PageCat, result.PageCat, "pagecat")
+		assert.NotSame(t, given.Publisher, result.Publisher, "publisher")
+		assert.NotSame(t, given.Publisher.Ext, result.Publisher.Ext, "publisher-ext")
+		assert.NotSame(t, given.Content, result.Content, "content")
+		assert.NotSame(t, given.Content.Ext, result.Content.Ext, "content-ext")
+		assert.NotSame(t, given.KwArray, result.KwArray, "kwarray")
+		assert.NotSame(t, given.Ext, result.Ext, "ext")
+	})
+
+	t.Run("assumptions", func(t *testing.T) {
+		assert.ElementsMatch(t, discoverPointerFields(reflect.TypeOf(openrtb2.App{})),
+			[]string{
+				"Cat",
+				"SectionCat",
+				"PageCat",
+				"Publisher",
+				"Content",
+				"KwArray",
+				"Ext",
+			})
+	})
+}
+
+func TestClonePublisher(t *testing.T) {
+	t.Run("nil", func(t *testing.T) {
+		result := ClonePublisher(nil)
+		assert.Nil(t, result)
+	})
+
+	t.Run("empty", func(t *testing.T) {
+		given := &openrtb2.Publisher{}
+		result := ClonePublisher(given)
+		assert.Equal(t, given, result)
+		assert.NotSame(t, given, result)
+	})
+
+	t.Run("populated", func(t *testing.T) {
+		given := &openrtb2.Publisher{
+			ID:     "anyID",
+			Name:   "anyName",
+			CatTax: adcom1.CatTaxIABContent20,
+			Cat:    []string{"cat1"},
+			Domain: "anyDomain",
+			Ext:    json.RawMessage(`{"anyField":1}`),
+		}
+		result := ClonePublisher(given)
+		assert.Equal(t, given, result, "equality")
+		assert.NotSame(t, given, result, "pointer")
+		assert.NotSame(t, given.Cat, result.Cat, "cat")
+		assert.NotSame(t, given.Ext, result.Ext, "ext")
+	})
+
+	t.Run("assumptions", func(t *testing.T) {
+		assert.ElementsMatch(t, discoverPointerFields(reflect.TypeOf(openrtb2.Publisher{})),
+			[]string{
+				"Cat",
+				"Ext",
+			})
+	})
+}
+
+func TestCloneContent(t *testing.T) {
+	t.Run("nil", func(t *testing.T) {
+		result := CloneContent(nil)
+		assert.Nil(t, result)
+	})
+
+	t.Run("empty", func(t *testing.T) {
+		given := &openrtb2.Content{}
+		result := CloneContent(given)
+		assert.Equal(t, given, result)
+		assert.NotSame(t, given, result)
+	})
+
+	t.Run("populated", func(t *testing.T) {
+		given := &openrtb2.Content{
+			ID:                 "anyID",
+			Episode:            1,
+			Title:              "anyTitle",
+			Series:             "anySeries",
+			Season:             "anySeason",
+			Artist:             "anyArtist",
+			Genre:              "anyGenre",
+			Album:              "anyAlbum",
+			ISRC:               "anyIsrc",
+			Producer:           &openrtb2.Producer{ID: "anyID", Cat: []string{"anyCat"}},
+			URL:                "anyUrl",
+			CatTax:             adcom1.CatTaxIABContent10,
+			Cat:                []string{"cat1"},
+			ProdQ:              ptrutil.ToPtr(adcom1.ProductionProsumer),
+			VideoQuality:       ptrutil.ToPtr(adcom1.ProductionProfessional),
+			Context:            adcom1.ContentApp,
+			ContentRating:      "anyContentRating",
+			UserRating:         "anyUserRating",
+			QAGMediaRating:     adcom1.MediaRatingAll,
+			Keywords:           "anyKeywords",
+			KwArray:            []string{"key1"},
+			LiveStream:         2,
+			SourceRelationship: 3,
+			Len:                4,
+			Language:           "anyLanguage",
+			LangB:              "anyLangB",
+			Embeddable:         5,
+			Data:               []openrtb2.Data{{ID: "1", Ext: json.RawMessage(`{"data":1}`)}},
+			Network:            &openrtb2.Network{ID: "anyNetwork", Ext: json.RawMessage(`{"network":1}`)},
+			Channel:            &openrtb2.Channel{ID: "anyChannel", Ext: json.RawMessage(`{"channel":1}`)},
+			Ext:                json.RawMessage(`{"anyField":1}`),
+		}
+		result := CloneContent(given)
+		assert.Equal(t, given, result, "equality")
+		assert.NotSame(t, given, result, "pointer")
+		assert.NotSame(t, given.Producer, result.Producer, "producer")
+		assert.NotSame(t, given.Producer.Cat, result.Producer.Cat, "producer-cat")
+		assert.NotSame(t, given.Cat, result.Cat, "cat")
+		assert.NotSame(t, given.ProdQ, result.ProdQ, "prodq")
+		assert.NotSame(t, given.VideoQuality, result.VideoQuality, "videoquality")
+		assert.NotSame(t, given.KwArray, result.KwArray, "kwarray")
+		assert.NotSame(t, given.Data, result.Data, "data")
+		assert.NotSame(t, given.Data[0], result.Data[0], "data-item")
+		assert.NotSame(t, given.Data[0].Ext, result.Data[0].Ext, "data-item-ext")
+		assert.NotSame(t, given.Network, result.Network, "network")
+		assert.NotSame(t, given.Network.Ext, result.Network.Ext, "network-ext")
+		assert.NotSame(t, given.Channel, result.Channel, "channel")
+		assert.NotSame(t, given.Channel.Ext, result.Channel.Ext, "channel-ext")
+		assert.NotSame(t, given.Ext, result.Ext, "ext")
+	})
+
+	t.Run("assumptions", func(t *testing.T) {
+		assert.ElementsMatch(t, discoverPointerFields(reflect.TypeOf(openrtb2.Content{})),
+			[]string{
+				"Producer",
+				"Cat",
+				"ProdQ",
+				"VideoQuality",
+				"KwArray",
+				"Data",
+				"Network",
+				"Channel",
+				"Ext",
+			})
+	})
+}
+
+func TestCloneProducer(t *testing.T) {
+	t.Run("nil", func(t *testing.T) {
+		result := CloneProducer(nil)
+		assert.Nil(t, result)
+	})
+
+	t.Run("empty", func(t *testing.T) {
+		given := &openrtb2.Producer{}
+		result := CloneProducer(given)
+		assert.Equal(t, given, result)
+		assert.NotSame(t, given, result)
+	})
+
+	t.Run("populated", func(t *testing.T) {
+		given := &openrtb2.Producer{
+			ID:     "anyID",
+			Name:   "anyName",
+			CatTax: adcom1.CatTaxIABContent20,
+			Cat:    []string{"cat1"},
+			Domain: "anyDomain",
+			Ext:    json.RawMessage(`{"anyField":1}`),
+		}
+		result := CloneProducer(given)
+		assert.Equal(t, given, result, "equality")
+		assert.NotSame(t, given, result, "pointer")
+		assert.NotSame(t, given.Cat, result.Cat, "cat")
+		assert.NotSame(t, given.Ext, result.Ext, "ext")
+	})
+
+	t.Run("assumptions", func(t *testing.T) {
+		assert.ElementsMatch(t, discoverPointerFields(reflect.TypeOf(openrtb2.Producer{})),
+			[]string{
+				"Cat",
+				"Ext",
+			})
+	})
+}
+
+func TestCloneDataSlice(t *testing.T) {
+	t.Run("nil", func(t *testing.T) {
+		result := CloneDataSlice(nil)
+		assert.Nil(t, result)
+	})
+
+	t.Run("empty", func(t *testing.T) {
+		given := []openrtb2.Data{}
+		result := CloneDataSlice(given)
+		assert.Empty(t, result)
+		assert.NotSame(t, given, result)
+	})
+
+	t.Run("one", func(t *testing.T) {
+		given := []openrtb2.Data{
+			{ID: "1", Ext: json.RawMessage(`{"anyField":1}`)},
+		}
+		result := CloneDataSlice(given)
+		assert.Equal(t, given, result, "equality")
+		assert.NotSame(t, given[0], result[0], "item-pointer")
+		assert.NotSame(t, given[0].Ext, result[0].Ext, "item-pointer-ext")
+	})
+
+	t.Run("many", func(t *testing.T) {
+		given := []openrtb2.Data{
+			{ID: "1", Ext: json.RawMessage(`{"anyField":1}`)},
+			{ID: "2", Ext: json.RawMessage(`{"anyField":2}`)},
+		}
+		result := CloneDataSlice(given)
+		assert.Equal(t, given, result, "equality")
+		assert.NotSame(t, given[0], result[0], "item0-pointer")
+		assert.NotSame(t, given[0].Ext, result[0].Ext, "item0-pointer-ext")
+		assert.NotSame(t, given[1], result[1], "item1-pointer")
+		assert.NotSame(t, given[1].Ext, result[1].Ext, "item1-pointer-ext")
+	})
+}
+
+func TestCloneData(t *testing.T) {
+	t.Run("populated", func(t *testing.T) {
+		given := openrtb2.Data{
+			ID:      "anyID",
+			Name:    "anyName",
+			Segment: []openrtb2.Segment{{ID: "1", Ext: json.RawMessage(`{"anyField":1}`)}},
+			Ext:     json.RawMessage(`{"anyField":1}`),
+		}
+		result := CloneData(given)
+		assert.Equal(t, given, result, "equality")
+		assert.NotSame(t, given, result, "pointer")
+		assert.NotSame(t, given.Segment, result.Segment, "segment")
+		assert.NotSame(t, given.Segment[0], result.Segment[0], "segment-item")
+		assert.NotSame(t, given.Segment[0].Ext, result.Segment[0].Ext, "segment-item-ext")
+		assert.NotSame(t, given.Ext, result.Ext, "ext")
+	})
+
+	t.Run("assumptions", func(t *testing.T) {
+		assert.ElementsMatch(t, discoverPointerFields(reflect.TypeOf(openrtb2.Data{})),
+			[]string{
+				"Segment",
+				"Ext",
+			})
+	})
+}
+
+func TestCloneSegmentSlice(t *testing.T) {
+	t.Run("nil", func(t *testing.T) {
+		result := CloneSegmentSlice(nil)
+		assert.Nil(t, result)
+	})
+
+	t.Run("empty", func(t *testing.T) {
+		given := []openrtb2.Segment{}
+		result := CloneSegmentSlice(given)
+		assert.Empty(t, result)
+		assert.NotSame(t, given, result)
+	})
+
+	t.Run("one", func(t *testing.T) {
+		given := []openrtb2.Segment{
+			{ID: "1", Ext: json.RawMessage(`{"anyField":1}`)},
+		}
+		result := CloneSegmentSlice(given)
+		assert.Equal(t, given, result, "equality")
+		assert.NotSame(t, given[0], result[0], "item-pointer")
+		assert.NotSame(t, given[0].Ext, result[0].Ext, "item-pointer-ext")
+	})
+
+	t.Run("many", func(t *testing.T) {
+		given := []openrtb2.Segment{
+			{Ext: json.RawMessage(`{"anyField":1}`)},
+			{Ext: json.RawMessage(`{"anyField":2}`)},
+		}
+		result := CloneSegmentSlice(given)
+		assert.Equal(t, given, result, "equality")
+		assert.NotSame(t, given[0], result[0], "item0-pointer")
+		assert.NotSame(t, given[0].Ext, result[0].Ext, "item0-pointer-ext")
+		assert.NotSame(t, given[1], result[1], "item1-pointer")
+		assert.NotSame(t, given[1].Ext, result[1].Ext, "item1-pointer-ext")
+	})
+}
+
+func TestCloneSegment(t *testing.T) {
+	t.Run("populated", func(t *testing.T) {
+		given := openrtb2.Segment{
+			ID:    "anyID",
+			Name:  "anyName",
+			Value: "anyValue",
+			Ext:   json.RawMessage(`{"anyField":1}`),
+		}
+		result := CloneSegment(given)
+		assert.Equal(t, given, result, "equality")
+		assert.NotSame(t, given, result, "pointer")
+		assert.NotSame(t, given.Ext, result.Ext, "ext")
+	})
+
+	t.Run("assumptions", func(t *testing.T) {
+		assert.ElementsMatch(t, discoverPointerFields(reflect.TypeOf(openrtb2.Segment{})),
+			[]string{
+				"Ext",
+			})
+	})
+}
+
+func TestCloneNetwork(t *testing.T) {
+	t.Run("nil", func(t *testing.T) {
+		result := CloneNetwork(nil)
+		assert.Nil(t, result)
+	})
+
+	t.Run("empty", func(t *testing.T) {
+		given := &openrtb2.Network{}
+		result := CloneNetwork(given)
+		assert.Empty(t, result)
+		assert.NotSame(t, given, result)
+	})
+
+	t.Run("populated", func(t *testing.T) {
+		given := &openrtb2.Network{
+			ID:     "anyID",
+			Name:   "anyName",
+			Domain: "anyDomain",
+			Ext:    json.RawMessage(`{"anyField":1}`),
+		}
+		result := CloneNetwork(given)
+		assert.Equal(t, given, result, "equality")
+		assert.NotSame(t, given, result, "pointer")
+		assert.NotSame(t, given.Ext, result.Ext, "ext")
+	})
+
+	t.Run("assumptions", func(t *testing.T) {
+		assert.ElementsMatch(t, discoverPointerFields(reflect.TypeOf(openrtb2.Network{})),
+			[]string{
+				"Ext",
+			})
+	})
+}
+
+func TestCloneChannel(t *testing.T) {
+	t.Run("nil", func(t *testing.T) {
+		result := CloneChannel(nil)
+		assert.Nil(t, result)
+	})
+
+	t.Run("empty", func(t *testing.T) {
+		given := &openrtb2.Channel{}
+		result := CloneChannel(given)
+		assert.Empty(t, result)
+		assert.NotSame(t, given, result)
+	})
+
+	t.Run("populated", func(t *testing.T) {
+		given := &openrtb2.Channel{
+			ID:     "anyID",
+			Name:   "anyName",
+			Domain: "anyDomain",
+			Ext:    json.RawMessage(`{"anyField":1}`),
+		}
+		result := CloneChannel(given)
+		assert.Equal(t, given, result, "equality")
+		assert.NotSame(t, given, result, "pointer")
+		assert.NotSame(t, given.Ext, result.Ext, "ext")
+	})
+
+	t.Run("assumptions", func(t *testing.T) {
+		assert.ElementsMatch(t, discoverPointerFields(reflect.TypeOf(openrtb2.Channel{})),
+			[]string{
+				"Ext",
+			})
+	})
+}
+
+func TestCloneSite(t *testing.T) {
+	t.Run("nil", func(t *testing.T) {
+		result := CloneSite(nil)
+		assert.Nil(t, result)
+	})
+
+	t.Run("empty", func(t *testing.T) {
+		given := &openrtb2.Site{}
+		result := CloneSite(given)
+		assert.Empty(t, result)
+		assert.NotSame(t, given, result)
+	})
+
+	t.Run("populated", func(t *testing.T) {
+		given := &openrtb2.Site{
+			ID:                     "anyID",
+			Name:                   "anyName",
+			Domain:                 "anyDomain",
+			CatTax:                 adcom1.CatTaxIABContent10,
+			Cat:                    []string{"cat1"},
+			SectionCat:             []string{"sectionCat1"},
+			PageCat:                []string{"pageCat1"},
+			Page:                   "anyPage",
+			Ref:                    "anyRef",
+			Search:                 "anySearch",
+			Mobile:                 1,
+			PrivacyPolicy:          2,
+			Publisher:              &openrtb2.Publisher{ID: "anyPublisher", Ext: json.RawMessage(`{"publisher":1}`)},
+			Content:                &openrtb2.Content{ID: "anyContent", Ext: json.RawMessage(`{"content":1}`)},
+			Keywords:               "anyKeywords",
+			KwArray:                []string{"key1"},
+			InventoryPartnerDomain: "anyInventoryPartnerDomain",
+			Ext:                    json.RawMessage(`{"anyField":1}`),
+		}
+		result := CloneSite(given)
+		assert.Equal(t, given, result, "equality")
+		assert.NotSame(t, given, result, "pointer")
+		assert.NotSame(t, given.Cat, result.Cat, "cat")
+		assert.NotSame(t, given.SectionCat, result.SectionCat, "sectioncat")
+		assert.NotSame(t, given.PageCat, result.PageCat, "pagecat")
+		assert.NotSame(t, given.Publisher, result.Publisher, "publisher")
+		assert.NotSame(t, given.Publisher.Ext, result.Publisher.Ext, "publisher-ext")
+		assert.NotSame(t, given.Content, result.Content, "content")
+		assert.NotSame(t, given.Content.Ext, result.Content.Ext, "content-ext")
+		assert.NotSame(t, given.KwArray, result.KwArray, "kwarray")
+		assert.NotSame(t, given.Ext, result.Ext, "ext")
+	})
+
+	t.Run("assumptions", func(t *testing.T) {
+		assert.ElementsMatch(t, discoverPointerFields(reflect.TypeOf(openrtb2.Site{})),
+			[]string{
+				"Cat",
+				"SectionCat",
+				"PageCat",
+				"Publisher",
+				"Content",
+				"KwArray",
+				"Ext",
+			})
+	})
+}
+
+func TestCloneUser(t *testing.T) {
+	t.Run("nil", func(t *testing.T) {
+		result := CloneUser(nil)
+		assert.Nil(t, result)
+	})
+
+	t.Run("empty", func(t *testing.T) {
+		given := &openrtb2.User{}
+		result := CloneUser(given)
+		assert.Empty(t, result)
+		assert.NotSame(t, given, result)
+	})
+
+	t.Run("populated", func(t *testing.T) {
+		given := &openrtb2.User{
+			ID:         "anyID",
+			BuyerUID:   "anyBuyerUID",
+			Yob:        1,
+			Gender:     "anyGender",
+			Keywords:   "anyKeywords",
+			KwArray:    []string{"key1"},
+			CustomData: "anyCustomData",
+			Geo:        &openrtb2.Geo{Lat: 1.2, Lon: 2.3, Ext: json.RawMessage(`{"geo":1}`)},
+			Data:       []openrtb2.Data{{ID: "1", Ext: json.RawMessage(`{"data":1}`)}},
+			Consent:    "anyConsent",
+			EIDs:       []openrtb2.EID{{Source: "1", Ext: json.RawMessage(`{"eid":1}`)}},
+			Ext:        json.RawMessage(`{"anyField":1}`),
+		}
+		result := CloneUser(given)
+		assert.Equal(t, given, result, "equality")
+		assert.NotSame(t, given, result, "pointer")
+		assert.NotSame(t, given.KwArray, result.KwArray, "cat")
+		assert.NotSame(t, given.Geo, result.Geo, "geo")
+		assert.NotSame(t, given.Geo.Ext, result.Geo.Ext, "geo-ext")
+		assert.NotSame(t, given.Data[0], result.Data[0], "data-item")
+		assert.NotSame(t, given.Data[0].Ext, result.Data[0].Ext, "data-item-ext")
+		assert.NotSame(t, given.EIDs[0], result.EIDs[0], "eids-item")
+		assert.NotSame(t, given.EIDs[0].Ext, result.EIDs[0].Ext, "eids-item-ext")
+		assert.NotSame(t, given.Ext, result.Ext, "ext")
+	})
+
+	t.Run("assumptions", func(t *testing.T) {
+		assert.ElementsMatch(t, discoverPointerFields(reflect.TypeOf(openrtb2.User{})),
+			[]string{
+				"KwArray",
+				"Geo",
+				"Data",
+				"EIDs",
+				"Ext",
+			})
+	})
+}
+
+func TestCloneGeo(t *testing.T) {
+	t.Run("nil", func(t *testing.T) {
+		result := CloneGeo(nil)
+		assert.Nil(t, result)
+	})
+
+	t.Run("empty", func(t *testing.T) {
+		given := &openrtb2.Geo{}
+		result := CloneGeo(given)
+		assert.Empty(t, result)
+		assert.NotSame(t, given, result)
+	})
+
+	t.Run("populated", func(t *testing.T) {
+		given := &openrtb2.Geo{
+			Lat:           1.234,
+			Lon:           5.678,
+			Type:          adcom1.LocationGPS,
+			Accuracy:      1,
+			LastFix:       2,
+			IPService:     adcom1.LocationServiceIP2Location,
+			Country:       "anyCountry",
+			Region:        "anyRegion",
+			RegionFIPS104: "anyRegionFIPS104",
+			Metro:         "anyMetro",
+			City:          "anyCity",
+			ZIP:           "anyZIP",
+			UTCOffset:     3,
+			Ext:           json.RawMessage(`{"anyField":1}`),
+		}
+		result := CloneGeo(given)
+		assert.Equal(t, given, result, "equality")
+		assert.NotSame(t, given, result, "pointer")
+		assert.NotSame(t, given.Ext, result.Ext, "ext")
+	})
+
+	t.Run("assumptions", func(t *testing.T) {
+		assert.ElementsMatch(t, discoverPointerFields(reflect.TypeOf(openrtb2.Geo{})),
+			[]string{
+				"Ext",
+			})
+	})
+}
+
+func TestCloneEIDSlice(t *testing.T) {
+	t.Run("nil", func(t *testing.T) {
+		result := CloneEIDSlice(nil)
+		assert.Nil(t, result)
+	})
+
+	t.Run("empty", func(t *testing.T) {
+		given := []openrtb2.EID{}
+		result := CloneEIDSlice(given)
+		assert.Empty(t, result)
+		assert.NotSame(t, given, result)
+	})
+
+	t.Run("one", func(t *testing.T) {
+		given := []openrtb2.EID{
+			{Source: "1", Ext: json.RawMessage(`{"anyField":1}`)},
+		}
+		result := CloneEIDSlice(given)
+		assert.Equal(t, given, result, "equality")
+		assert.NotSame(t, given[0], result[0], "item-pointer")
+		assert.NotSame(t, given[0].Ext, result[0].Ext, "item-pointer-ext")
+	})
+
+	t.Run("many", func(t *testing.T) {
+		given := []openrtb2.EID{
+			{Source: "1", Ext: json.RawMessage(`{"anyField":1}`)},
+			{Source: "2", Ext: json.RawMessage(`{"anyField":2}`)},
+		}
+		result := CloneEIDSlice(given)
+		assert.Equal(t, given, result, "equality")
+		assert.NotSame(t, given[0], result[0], "item0-pointer")
+		assert.NotSame(t, given[0].Ext, result[0].Ext, "item0-pointer-ext")
+		assert.NotSame(t, given[1], result[1], "item1-pointer")
+		assert.NotSame(t, given[1].Ext, result[1].Ext, "item1-pointer-ext")
+	})
+}
+
+func TestCloneEID(t *testing.T) {
+	t.Run("populated", func(t *testing.T) {
+		given := openrtb2.EID{
+			Source: "anySource",
+			UIDs:   []openrtb2.UID{{ID: "1", Ext: json.RawMessage(`{"uid":1}`)}},
+			Ext:    json.RawMessage(`{"anyField":1}`),
+		}
+		result := CloneEID(given)
+		assert.Equal(t, given, result, "equality")
+		assert.NotSame(t, given, result, "pointer")
+		assert.NotSame(t, given.UIDs, result.UIDs, "uids")
+		assert.NotSame(t, given.UIDs[0], result.UIDs[0], "uids-item")
+		assert.NotSame(t, given.UIDs[0].Ext, result.UIDs[0].Ext, "uids-item-ext")
+		assert.NotSame(t, given.Ext, result.Ext, "ext")
+	})
+
+	t.Run("assumptions", func(t *testing.T) {
+		assert.ElementsMatch(t, discoverPointerFields(reflect.TypeOf(openrtb2.EID{})),
+			[]string{
+				"UIDs",
+				"Ext",
+			})
+	})
+}
+
+func TestCloneUIDSlice(t *testing.T) {
+	t.Run("nil", func(t *testing.T) {
+		result := CloneUIDSlice(nil)
+		assert.Nil(t, result)
+	})
+
+	t.Run("empty", func(t *testing.T) {
+		given := []openrtb2.UID{}
+		result := CloneUIDSlice(given)
+		assert.Empty(t, result)
+		assert.NotSame(t, given, result)
+	})
+
+	t.Run("one", func(t *testing.T) {
+		given := []openrtb2.UID{
+			{ID: "1", Ext: json.RawMessage(`{"anyField":1}`)},
+		}
+		result := CloneUIDSlice(given)
+		assert.Equal(t, given, result, "equality")
+		assert.NotSame(t, given[0], result[0], "item-pointer")
+		assert.NotSame(t, given[0].Ext, result[0].Ext, "item-pointer-ext")
+	})
+
+	t.Run("many", func(t *testing.T) {
+		given := []openrtb2.UID{
+			{ID: "1", Ext: json.RawMessage(`{"anyField":1}`)},
+			{ID: "2", Ext: json.RawMessage(`{"anyField":2}`)},
+		}
+		result := CloneUIDSlice(given)
+		assert.Equal(t, given, result, "equality")
+		assert.NotSame(t, given[0], result[0], "item0-pointer")
+		assert.NotSame(t, given[0].Ext, result[0].Ext, "item0-pointer-ext")
+		assert.NotSame(t, given[1], result[1], "item1-pointer")
+		assert.NotSame(t, given[1].Ext, result[1].Ext, "item1-pointer-ext")
+	})
+}
+
+func TestCloneUID(t *testing.T) {
+	t.Run("populated", func(t *testing.T) {
+		given := openrtb2.UID{
+			ID:    "anyID",
+			AType: adcom1.AgentTypePerson,
+			Ext:   json.RawMessage(`{"anyField":1}`),
+		}
+		result := CloneUID(given)
+		assert.Equal(t, given, result, "equality")
+		assert.NotSame(t, given, result, "pointer")
+		assert.NotSame(t, given.Ext, result.Ext, "ext")
+	})
+
+	t.Run("assumptions", func(t *testing.T) {
+		assert.ElementsMatch(t, discoverPointerFields(reflect.TypeOf(openrtb2.UID{})),
+			[]string{
+				"Ext",
+			})
+	})
+}
+
+func TestCloneDOOH(t *testing.T) {
+	t.Run("nil", func(t *testing.T) {
+		result := CloneDOOH(nil)
+		assert.Nil(t, result)
+	})
+
+	t.Run("empty", func(t *testing.T) {
+		given := &openrtb2.DOOH{}
+		result := CloneDOOH(given)
+		assert.Empty(t, result)
+		assert.NotSame(t, given, result)
+	})
+
+	t.Run("populated", func(t *testing.T) {
+		given := &openrtb2.DOOH{
+			ID:           "anyID",
+			Name:         "anyName",
+			VenueType:    []string{"venue1"},
+			VenueTypeTax: ptrutil.ToPtr(adcom1.VenueTaxonomyAdCom),
+			Publisher:    &openrtb2.Publisher{ID: "anyPublisher", Ext: json.RawMessage(`{"publisher":1}`)},
+			Domain:       "anyDomain",
+			Keywords:     "anyKeywords",
+			Content:      &openrtb2.Content{ID: "anyContent", Ext: json.RawMessage(`{"content":1}`)},
+			Ext:          json.RawMessage(`{"anyField":1}`),
+		}
+		result := CloneDOOH(given)
+		assert.Equal(t, given, result, "equality")
+		assert.NotSame(t, given, result, "pointer")
+		assert.NotSame(t, given.VenueType, result.VenueType, "venuetype")
+		assert.NotSame(t, given.VenueTypeTax, result.VenueTypeTax, "venuetypetax")
+		assert.NotSame(t, given.Publisher, result.Publisher, "publisher")
+		assert.NotSame(t, given.Publisher.Ext, result.Publisher.Ext, "publisher-ext")
+		assert.NotSame(t, given.Content, result.Content, "content")
+		assert.NotSame(t, given.Content.Ext, result.Content.Ext, "content-ext")
+		assert.NotSame(t, given.Ext, result.Ext, "ext")
+	})
+
+	t.Run("assumptions", func(t *testing.T) {
+		assert.ElementsMatch(t, discoverPointerFields(reflect.TypeOf(openrtb2.DOOH{})),
+			[]string{
+				"VenueType",
+				"VenueTypeTax",
+				"Publisher",
+				"Content",
+				"Ext",
+			})
+	})
+}
+
+// discoverPointerFields returns the names of all fields of an object that are
+// pointers and would need to be cloned. This method is specific to types which can
+// appear within an OpenRTB data model object.
+func discoverPointerFields(t reflect.Type) []string {
+	var fields []string
+	for _, f := range reflect.VisibleFields(t) {
+		if f.Type.Kind() == reflect.Slice || f.Type.Kind() == reflect.Pointer {
+			fields = append(fields, f.Name)
+		}
+	}
+	return fields
+}
diff --git a/util/sliceutil/clone_test.go b/util/sliceutil/clone_test.go
new file mode 100644
index 00000000..6e905b5c
--- /dev/null
+++ b/util/sliceutil/clone_test.go
@@ -0,0 +1,39 @@
+package sliceutil
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestCloneSlice(t *testing.T) {
+	testCases := []struct {
+		name  string
+		given []int
+	}{
+		{
+			name:  "nil",
+			given: nil,
+		},
+		{
+			name:  "empty",
+			given: []int{},
+		},
+		{
+			name:  "one",
+			given: []int{1},
+		},
+		{
+			name:  "many",
+			given: []int{1, 2},
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.name, func(t *testing.T) {
+			result := Clone(test.given)
+			assert.Equal(t, test.given, result, "equality")
+			assert.NotSame(t, test.given, result, "pointer")
+		})
+	}
+}