Repository: prebid/prebid-server
Pull request #1377 (2020-07-02 12:01:08)

TheMediaGrid: added app type support


Patch:
diff --git a/config/config.go b/config/config.go
index 16bab299..cc1d4a0a 100755
--- a/config/config.go
+++ b/config/config.go
@@ -215,6 +215,7 @@ type Analytics struct {
 type CurrencyConverter struct {
 	FetchURL             string `mapstructure:"fetch_url"`
 	FetchIntervalSeconds int    `mapstructure:"fetch_interval_seconds"`
+	StaleRatesSeconds    int    `mapstructure:"stale_rates_seconds"`
 }
 
 func (cfg *CurrencyConverter) validate(errs configErrors) configErrors {
@@ -791,7 +792,7 @@ func SetupViper(v *viper.Viper, filename string) {
 	v.SetDefault("adapters.beintoo.endpoint", "https://ib.beintoo.com/um")
 	v.SetDefault("adapters.brightroll.endpoint", "http://east-bid.ybp.yahoo.com/bid/appnexuspbs")
 	v.SetDefault("adapters.consumable.endpoint", "https://e.serverbid.com/api/v2")
-	v.SetDefault("adapters.conversant.endpoint", "http://api.hb.ad.cpe.dotomi.com/s2s/header/24")
+	v.SetDefault("adapters.conversant.endpoint", "http://api.hb.ad.cpe.dotomi.com/cvx/server/hb/ortb/25")
 	v.SetDefault("adapters.cpmstar.endpoint", "https://server.cpmstar.com/openrtbbidrq.aspx")
 	v.SetDefault("adapters.datablocks.endpoint", "http://{{.Host}}/openrtb2?sid={{.SourceId}}")
 	v.SetDefault("adapters.emx_digital.endpoint", "https://hb.emxdgt.com")
@@ -866,6 +867,7 @@ func SetupViper(v *viper.Viper, filename string) {
 	v.SetDefault("lmt.enforce", true)
 	v.SetDefault("currency_converter.fetch_url", "https://cdn.jsdelivr.net/gh/prebid/currency-file@1/latest.json")
 	v.SetDefault("currency_converter.fetch_interval_seconds", 1800) // fetch currency rates every 30 minutes
+	v.SetDefault("currency_converter.stale_rates_seconds", 0)
 	v.SetDefault("default_request.type", "")
 	v.SetDefault("default_request.file.name", "")
 	v.SetDefault("default_request.alias_info", false)
diff --git a/currencies/rate_converter.go b/currencies/rate_converter.go
index 6c6ed172..d22f347b 100644
--- a/currencies/rate_converter.go
+++ b/currencies/rate_converter.go
@@ -12,14 +12,15 @@ import (
 
 // RateConverter holds the currencies conversion rates dictionary
 type RateConverter struct {
-	httpClient       httpClient
-	done             chan bool
-	updateNotifier   chan<- int
-	fetchingInterval time.Duration
-	syncSourceURL    string
-	rates            atomic.Value // Should only hold Rates struct
-	lastUpdated      atomic.Value // Should only hold time.Time
-	constantRates    Conversions
+	httpClient          httpClient
+	done                chan bool
+	updateNotifier      chan<- int
+	fetchingInterval    time.Duration
+	staleRatesThreshold time.Duration
+	syncSourceURL       string
+	rates               atomic.Value // Should only hold Rates struct
+	lastUpdated         atomic.Value // Should only hold time.Time
+	constantRates       Conversions
 }
 
 // NewRateConverter returns a new RateConverter
@@ -27,11 +28,13 @@ func NewRateConverter(
 	httpClient httpClient,
 	syncSourceURL string,
 	fetchingInterval time.Duration,
+	staleRatesThreshold time.Duration,
 ) *RateConverter {
 	return NewRateConverterWithNotifier(
 		httpClient,
 		syncSourceURL,
 		fetchingInterval,
+		staleRatesThreshold,
 		nil, // no notifier channel specified, won't send any notifications
 	)
 }
@@ -40,7 +43,7 @@ func NewRateConverter(
 // By default there will be no currencies conversions done.
 // `currencies.ConstantRate` will be used.
 func NewRateConverterDefault() *RateConverter {
-	return NewRateConverter(&http.Client{}, "", time.Duration(0))
+	return NewRateConverter(&http.Client{}, "", time.Duration(0), time.Duration(0))
 }
 
 // NewRateConverterWithNotifier returns a new RateConverter
@@ -51,22 +54,24 @@ func NewRateConverterWithNotifier(
 	httpClient httpClient,
 	syncSourceURL string,
 	fetchingInterval time.Duration,
+	staleRatesThreshold time.Duration,
 	updateNotifier chan<- int,
 ) *RateConverter {
 	rc := &RateConverter{
-		httpClient:       httpClient,
-		done:             make(chan bool),
-		updateNotifier:   updateNotifier,
-		fetchingInterval: fetchingInterval,
-		syncSourceURL:    syncSourceURL,
-		rates:            atomic.Value{},
-		lastUpdated:      atomic.Value{},
+		httpClient:          httpClient,
+		done:                make(chan bool),
+		updateNotifier:      updateNotifier,
+		fetchingInterval:    fetchingInterval,
+		staleRatesThreshold: staleRatesThreshold,
+		syncSourceURL:       syncSourceURL,
+		rates:               atomic.Value{},
+		lastUpdated:         atomic.Value{},
+		constantRates:       NewConstantRates(),
 	}
 
 	// In case host do not want to support currency lookup
 	// we just stop here and do nothing
 	if rc.fetchingInterval == time.Duration(0) {
-		rc.constantRates = NewConstantRates()
 		return rc
 	}
 
@@ -111,7 +116,12 @@ func (rc *RateConverter) Update() error {
 		rc.rates.Store(rates)
 		rc.lastUpdated.Store(time.Now())
 	} else {
-		glog.Errorf("Error updating conversion rates: %v", err)
+		if rc.CheckStaleRates() {
+			rc.ClearRates()
+			glog.Errorf("Error updating conversion rates, falling back to constant rates: %v", err)
+		} else {
+			glog.Errorf("Error updating conversion rates: %v", err)
+		}
 	}
 
 	return err
@@ -160,14 +170,33 @@ func (rc *RateConverter) LastUpdated() time.Time {
 
 // Rates returns current conversions rates
 func (rc *RateConverter) Rates() Conversions {
-	if rc.constantRates != nil {
-		// Converter is not active, returning the constant rates
-		return rc.constantRates
-	}
-	if rates := rc.rates.Load(); rates != nil {
+	// atomic.Value field rates is an empty interface and will be of type *Rates the first time rates are stored
+	// or nil if the rates have never been stored
+	if rates := rc.rates.Load(); rates != (*Rates)(nil) && rates != nil {
 		return rates.(*Rates)
 	}
-	return nil
+	return rc.constantRates
+}
+
+// ClearRates sets the rates to nil
+func (rc *RateConverter) ClearRates() {
+	// atomic.Value field rates must be of type *Rates so we cast nil to that type
+	rc.rates.Store((*Rates)(nil))
+}
+
+// CheckStaleRates checks if loaded third party conversion rates are stale
+func (rc *RateConverter) CheckStaleRates() bool {
+	if rc.staleRatesThreshold <= 0 {
+		return false
+	}
+	currentTime := time.Now().UTC()
+	if lastUpdated := rc.lastUpdated.Load(); lastUpdated != nil {
+		delta := currentTime.Sub(lastUpdated.(time.Time).UTC())
+		if delta.Seconds() > rc.staleRatesThreshold.Seconds() {
+			return true
+		}
+	}
+	return false
 }
 
 // GetInfo returns setup information about the converter
diff --git a/exchange/exchange.go b/exchange/exchange.go
index d7eab0f4..174a0b3e 100644
--- a/exchange/exchange.go
+++ b/exchange/exchange.go
@@ -104,8 +104,11 @@ func (e *exchange) HoldAuction(ctx context.Context, bidRequest *openrtb.BidReque
 
 	// Slice of BidRequests, each a copy of the original cleaned to only contain bidder data for the named bidder
 	blabels := make(map[openrtb_ext.BidderName]*pbsmetrics.AdapterLabels)
-	cleanRequests, aliases, errs := cleanOpenRTBRequests(ctx, bidRequest, usersyncs, blabels, labels, e.gDPR, e.UsersyncIfAmbiguous, e.privacyConfig)
+	cleanRequests, aliases, cleanMetrics, errs := cleanOpenRTBRequests(ctx, bidRequest, usersyncs, blabels, labels, e.gDPR, e.UsersyncIfAmbiguous, e.privacyConfig)
 
+	if cleanMetrics.gdprEnforced {
+		e.me.RecordTCFReq(pbsmetrics.TCFVersionToValue(cleanMetrics.gdprTcfVersion))
+	}
 	// List of bidders we have requests for.
 	liveAdapters := listBiddersWithRequests(cleanRequests)
 
diff --git a/exchange/utils.go b/exchange/utils.go
index 54122d13..96c00ec0 100644
--- a/exchange/utils.go
+++ b/exchange/utils.go
@@ -6,6 +6,8 @@ import (
 	"fmt"
 	"math/rand"
 
+	"github.com/prebid/go-gdpr/vendorconsent"
+
 	"github.com/buger/jsonparser"
 	"github.com/mxmCherry/openrtb"
 	"github.com/prebid/prebid-server/config"
@@ -17,6 +19,15 @@ import (
 	"github.com/prebid/prebid-server/privacy/lmt"
 )
 
+// cleanMetrics is a struct to export any metrics data resulting from cleanOpenRTBRequests(). It starts with just
+// the TCF version, but made a struct to facilitate future expansion
+type cleanMetrics struct {
+	// A simple flag if GDPR is being enforced on this request.
+	gdprEnforced bool
+	// a zero value means a missing or invalid GDPR string
+	gdprTcfVersion int
+}
+
 // cleanOpenRTBRequests splits the input request into requests which are sanitized for each bidder. Intended behavior is:
 //
 //   1. BidRequest.Imp[].Ext will only contain the "prebid" field and a "bidder" field which has the params for the intended Bidder.
@@ -29,7 +40,7 @@ func cleanOpenRTBRequests(ctx context.Context,
 	labels pbsmetrics.Labels,
 	gDPR gdpr.Permissions,
 	usersyncIfAmbiguous bool,
-	privacyConfig config.Privacy) (requestsByBidder map[openrtb_ext.BidderName]*openrtb.BidRequest, aliases map[string]string, errs []error) {
+	privacyConfig config.Privacy) (requestsByBidder map[openrtb_ext.BidderName]*openrtb.BidRequest, aliases map[string]string, cleanMetrics cleanMetrics, errs []error) {
 
 	impsByBidder, errs := splitImps(orig.Imp)
 	if len(errs) > 0 {
@@ -64,6 +75,13 @@ func cleanOpenRTBRequests(ctx context.Context,
 		LMT:   lmtPolicy.ShouldEnforce(),
 	}
 
+	if gdpr == 1 {
+		cleanMetrics.gdprEnforced = true
+		parsedConsent, err := vendorconsent.ParseString(consent)
+		if err == nil {
+			cleanMetrics.gdprTcfVersion = int(parsedConsent.Version())
+		}
+	}
 	// bidder level privacy policies
 	for bidder, bidReq := range requestsByBidder {
 
diff --git a/go.sum b/go.sum
index 5d941b89..35b2b765 100644
--- a/go.sum
+++ b/go.sum
@@ -76,6 +76,8 @@ github.com/prebid/prebid-cache v0.0.0-20200218152159-6d6d678c1caf h1:CcE+KN1tCtW
 github.com/prebid/prebid-cache v0.0.0-20200218152159-6d6d678c1caf/go.mod h1:k5xrl5ZpnumN1S2x8w8cMiFYsgRuVyAeFJz+BkSi+98=
 github.com/prometheus/client_golang v0.0.0-20180623155954-77e8f2ddcfed h1:0dloFFFNNDG7c+8qtkYw2FdADrWy9s5cI8wHp6tK3Mg=
 github.com/prometheus/client_golang v0.0.0-20180623155954-77e8f2ddcfed/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
+github.com/prometheus/client_golang v1.6.0 h1:YVPodQOcK15POxhgARIvnDRVpLcuK8mglnMrWfyrw6A=
+github.com/prometheus/client_golang v1.7.0 h1:wCi7urQOGBsYcQROHqpUUX4ct84xp40t9R9JX0FuA/U=
 github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910 h1:idejC8f05m9MGOsuEi1ATq9shN03HrxNkD/luQvxCv8=
 github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
 github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e h1:n/3MEhJQjQxrOUCzh1Y3Re6aJUUWRp2M9+Oc3eVn/54=
@@ -126,6 +128,7 @@ golang.org/x/net v0.0.0-20180906233101-161cd47e91fd h1:nTDtHvHSdCn1m6ITfMRqtOd/9
 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297 h1:k7pJ2yAPLPgbskkFdhRCsA77k2fySZ1zf2zCjvQCiIM=
 golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200602114024-627f9648deb9 h1:pNX+40auqi2JqRfOP1akLGtYcn15TUbkhwuCO3foqqM=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY=
 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
diff --git a/main.go b/main.go
index d6ba430f..9a835f42 100644
--- a/main.go
+++ b/main.go
@@ -52,7 +52,9 @@ func loadConfig() (*config.Configuration, error) {
 
 func serve(revision string, cfg *config.Configuration) error {
 	fetchingInterval := time.Duration(cfg.CurrencyConverter.FetchIntervalSeconds) * time.Second
-	currencyConverter := currencies.NewRateConverter(&http.Client{}, cfg.CurrencyConverter.FetchURL, fetchingInterval)
+	staleRatesThreshold := time.Duration(cfg.CurrencyConverter.StaleRatesSeconds) * time.Second
+	currencyConverter := currencies.NewRateConverter(&http.Client{}, cfg.CurrencyConverter.FetchURL,
+		fetchingInterval, staleRatesThreshold)
 
 	r, err := router.New(cfg, currencyConverter)
 	if err != nil {
diff --git a/pbsmetrics/config/metrics.go b/pbsmetrics/config/metrics.go
index 4e249785..3d105dea 100644
--- a/pbsmetrics/config/metrics.go
+++ b/pbsmetrics/config/metrics.go
@@ -195,6 +195,13 @@ func (me *MultiMetricsEngine) RecordTimeoutNotice(success bool) {
 	}
 }
 
+// RecordTCFReq across all engines
+func (me *MultiMetricsEngine) RecordTCFReq(version pbsmetrics.TCFVersionValue) {
+	for _, thisME := range *me {
+		thisME.RecordTCFReq(version)
+	}
+}
+
 // DummyMetricsEngine is a Noop metrics engine in case no metrics are configured. (may also be useful for tests)
 type DummyMetricsEngine struct{}
 
@@ -273,3 +280,7 @@ func (me *DummyMetricsEngine) RecordRequestQueueTime(success bool, requestType p
 // RecordTimeoutNotice as a noop
 func (me *DummyMetricsEngine) RecordTimeoutNotice(success bool) {
 }
+
+// RecordReq as a noop
+func (me *DummyMetricsEngine) RecordTCFReq(version pbsmetrics.TCFVersionValue) {
+}
diff --git a/pbsmetrics/go_metrics.go b/pbsmetrics/go_metrics.go
index 1ced4d57..73eb30a1 100644
--- a/pbsmetrics/go_metrics.go
+++ b/pbsmetrics/go_metrics.go
@@ -48,9 +48,13 @@ type Metrics struct {
 	ImpsTypeAudio  metrics.Meter
 	ImpsTypeNative metrics.Meter
 
+	// Notification timeout metrics
 	TimeoutNotificationSuccess metrics.Meter
 	TimeoutNotificationFailure metrics.Meter
 
+	// TCF adaption metrics
+	TCFReqVersion map[TCFVersionValue]metrics.Meter
+
 	AdapterMetrics map[openrtb_ext.BidderName]*AdapterMetrics
 	// Don't export accountMetrics because we need helper functions here to insure its properly populated dynamically
 	accountMetrics        map[string]*accountMetrics
@@ -137,6 +141,8 @@ func NewBlankMetrics(registry metrics.Registry, exchanges []openrtb_ext.BidderNa
 		TimeoutNotificationSuccess: blankMeter,
 		TimeoutNotificationFailure: blankMeter,
 
+		TCFReqVersion: make(map[TCFVersionValue]metrics.Meter, len(TCFVersions())),
+
 		AdapterMetrics:  make(map[openrtb_ext.BidderName]*AdapterMetrics, len(exchanges)),
 		accountMetrics:  make(map[string]*accountMetrics),
 		MetricsDisabled: disableMetrics,
@@ -154,6 +160,15 @@ func NewBlankMetrics(registry metrics.Registry, exchanges []openrtb_ext.BidderNa
 		}
 	}
 
+	for _, c := range CacheResults() {
+		newMetrics.StoredReqCacheMeter[c] = blankMeter
+		newMetrics.StoredImpCacheMeter[c] = blankMeter
+	}
+
+	for _, v := range TCFVersions() {
+		newMetrics.TCFReqVersion[v] = blankMeter
+	}
+
 	//to minimize memory usage, queuedTimeout metric is now supported for video endpoint only
 	//boolean value represents 2 general request statuses: accepted and rejected
 	newMetrics.RequestsQueueTimer["video"] = make(map[bool]metrics.Timer)
@@ -218,6 +233,11 @@ func NewMetrics(registry metrics.Registry, exchanges []openrtb_ext.BidderName, d
 
 	newMetrics.TimeoutNotificationSuccess = metrics.GetOrRegisterMeter("timeout_notification.ok", registry)
 	newMetrics.TimeoutNotificationFailure = metrics.GetOrRegisterMeter("timeout_notification.failed", registry)
+
+	for _, version := range TCFVersions() {
+		newMetrics.TCFReqVersion[version] = metrics.GetOrRegisterMeter(fmt.Sprintf("privacy.request.tcf.%s", string(version)), registry)
+	}
+
 	return newMetrics
 }
 
@@ -562,6 +582,16 @@ func (me *Metrics) RecordTimeoutNotice(success bool) {
 	return
 }
 
+func (me *Metrics) RecordTCFReq(version TCFVersionValue) {
+	met, ok := me.TCFReqVersion[version]
+	if ok {
+		met.Mark(1)
+	} else {
+		me.TCFReqVersion[TCFVersionErr].Mark(1)
+	}
+	return
+}
+
 func doMark(bidder openrtb_ext.BidderName, meters map[openrtb_ext.BidderName]metrics.Meter) {
 	met, ok := meters[bidder]
 	if ok {
diff --git a/pbsmetrics/metrics.go b/pbsmetrics/metrics.go
index e65ba313..0e94fe71 100644
--- a/pbsmetrics/metrics.go
+++ b/pbsmetrics/metrics.go
@@ -248,6 +248,35 @@ func RequestActions() []RequestAction {
 	}
 }
 
+// TCFVersionValue : The possible values for TCF versions
+type TCFVersionValue string
+
+const (
+	TCFVersionErr TCFVersionValue = "err"
+	TCFVersionV1  TCFVersionValue = "v1"
+	TCFVersionV2  TCFVersionValue = "v2"
+)
+
+// TCFVersions rtuens the possible values for the TCF version
+func TCFVersions() []TCFVersionValue {
+	return []TCFVersionValue{
+		TCFVersionErr,
+		TCFVersionV1,
+		TCFVersionV2,
+	}
+}
+
+// TCFVersionToValue takes an integer TCF version and returns the corresponding TCFVersionValue
+func TCFVersionToValue(version int) TCFVersionValue {
+	switch {
+	case version == 1:
+		return TCFVersionV1
+	case version == 2:
+		return TCFVersionV2
+	}
+	return TCFVersionErr
+}
+
 // MetricsEngine is a generic interface to record PBS metrics into the desired backend
 // The first three metrics function fire off once per incoming request, so total metrics
 // will equal the total number of incoming requests. The remaining 5 fire off per outgoing
@@ -276,4 +305,5 @@ type MetricsEngine interface {
 	RecordPrebidCacheRequestTime(success bool, length time.Duration)
 	RecordRequestQueueTime(success bool, requestType RequestType, length time.Duration)
 	RecordTimeoutNotice(sucess bool)
+	RecordTCFReq(version TCFVersionValue)
 }
diff --git a/pbsmetrics/metrics_mock.go b/pbsmetrics/metrics_mock.go
index 482cbf24..a6d36a72 100644
--- a/pbsmetrics/metrics_mock.go
+++ b/pbsmetrics/metrics_mock.go
@@ -106,3 +106,8 @@ func (me *MetricsEngineMock) RecordRequestQueueTime(success bool, requestType Re
 func (me *MetricsEngineMock) RecordTimeoutNotice(success bool) {
 	me.Called(success)
 }
+
+// RecordTCFReq mock
+func (me *MetricsEngineMock) RecordTCFReq(version TCFVersionValue) {
+	me.Called(version)
+}
diff --git a/pbsmetrics/prometheus/preload.go b/pbsmetrics/prometheus/preload.go
index 11e6bdc1..19f4f225 100644
--- a/pbsmetrics/prometheus/preload.go
+++ b/pbsmetrics/prometheus/preload.go
@@ -99,6 +99,11 @@ func preloadLabelValues(m *Metrics) {
 		requestTypeLabel:   {string(pbsmetrics.ReqTypeVideo)},
 		requestStatusLabel: {requestSuccessLabel, requestRejectLabel},
 	})
+
+	preloadLabelValuesForCounter(m.tcfVersion, map[string][]string{
+		versionLabel: tcfVersionsAsString(),
+		sourceLabel:  {string(sourceRequest)},
+	})
 }
 
 func preloadLabelValuesForCounter(counter *prometheus.CounterVec, labelsWithValues map[string][]string) {
diff --git a/pbsmetrics/prometheus/prometheus.go b/pbsmetrics/prometheus/prometheus.go
index e385b044..bf854746 100644
--- a/pbsmetrics/prometheus/prometheus.go
+++ b/pbsmetrics/prometheus/prometheus.go
@@ -28,7 +28,8 @@ type Metrics struct {
 	requestsWithoutCookie        *prometheus.CounterVec
 	storedImpressionsCacheResult *prometheus.CounterVec
 	storedRequestCacheResult     *prometheus.CounterVec
-	timeout_notifications        *prometheus.CounterVec
+	timeoutNotifications         *prometheus.CounterVec
+	tcfVersion                   *prometheus.CounterVec
 
 	// Adapter Metrics
 	adapterBids          *prometheus.CounterVec
@@ -63,6 +64,7 @@ const (
 	requestStatusLabel   = "request_status"
 	requestTypeLabel     = "request_type"
 	successLabel         = "success"
+	versionLabel         = "version"
 )
 
 const (
@@ -85,6 +87,11 @@ const (
 	requestFailed     = "failed"
 )
 
+const (
+	sourceLabel   = "source"
+	sourceRequest = "request"
+)
+
 // NewMetrics initializes a new Prometheus metrics instance with preloaded label values.
 func NewMetrics(cfg config.PrometheusMetrics) *Metrics {
 	requestTimeBuckets := []float64{0.05, 0.1, 0.15, 0.20, 0.25, 0.3, 0.4, 0.5, 0.75, 1}
@@ -153,11 +160,16 @@ func NewMetrics(cfg config.PrometheusMetrics) *Metrics {
 		"Count of stored request cache requests attempts by hits or miss.",
 		[]string{cacheResultLabel})
 
-	metrics.timeout_notifications = newCounter(cfg, metrics.Registry,
+	metrics.timeoutNotifications = newCounter(cfg, metrics.Registry,
 		"timeout_notification",
 		"Count of timeout notifications triggered, and if they were successfully sent.",
 		[]string{successLabel})
 
+	metrics.tcfVersion = newCounter(cfg, metrics.Registry,
+		"privacy_tcf",
+		"Count of TCF versions for requests where GDPR was enforced.",
+		[]string{versionLabel, sourceLabel})
+
 	metrics.adapterBids = newCounter(cfg, metrics.Registry,
 		"adapter_bids",
 		"Count of bids labeled by adapter and markup delivery type (adm or nurl).",
@@ -412,12 +424,19 @@ func (m *Metrics) RecordRequestQueueTime(success bool, requestType pbsmetrics.Re
 
 func (m *Metrics) RecordTimeoutNotice(success bool) {
 	if success {
-		m.timeout_notifications.With(prometheus.Labels{
+		m.timeoutNotifications.With(prometheus.Labels{
 			successLabel: requestSuccessful,
 		}).Inc()
 	} else {
-		m.timeout_notifications.With(prometheus.Labels{
+		m.timeoutNotifications.With(prometheus.Labels{
 			successLabel: requestFailed,
 		}).Inc()
 	}
 }
+
+func (m *Metrics) RecordTCFReq(version pbsmetrics.TCFVersionValue) {
+	m.tcfVersion.With(prometheus.Labels{
+		versionLabel: string(version),
+		sourceLabel:  sourceRequest,
+	}).Inc()
+}
diff --git a/pbsmetrics/prometheus/type_conversion.go b/pbsmetrics/prometheus/type_conversion.go
index 8294ede0..55a7092e 100644
--- a/pbsmetrics/prometheus/type_conversion.go
+++ b/pbsmetrics/prometheus/type_conversion.go
@@ -76,3 +76,12 @@ func requestTypesAsString() []string {
 	}
 	return valuesAsString
 }
+
+func tcfVersionsAsString() []string {
+	values := pbsmetrics.TCFVersions()
+	valuesAsString := make([]string, len(values))
+	for i, v := range values {
+		valuesAsString[i] = string(v)
+	}
+	return valuesAsString
+}
diff --git a/static/bidder-info/grid.yaml b/static/bidder-info/grid.yaml
index 9594830c..325421a2 100644
--- a/static/bidder-info/grid.yaml
+++ b/static/bidder-info/grid.yaml
@@ -1,7 +1,11 @@
 maintainer:
   email: "grid-tech@themediagrid.com"
 capabilities:
-  site:
+  app:
     mediaTypes:
       - banner
       - video
+  site:
+    mediaTypes:
+      - banner
+      - video
\ No newline at end of file


Test patch:
diff --git a/currencies/rate_converter_test.go b/currencies/rate_converter_test.go
index cb5e2a0b..d717d1a3 100644
--- a/currencies/rate_converter_test.go
+++ b/currencies/rate_converter_test.go
@@ -13,6 +13,20 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
+func getMockRates() []byte {
+	return []byte(`{
+		"dataAsOf":"2018-09-12",
+		"conversions":{
+			"USD":{
+				"GBP":0.77208
+			},
+			"GBP":{
+				"USD":1.2952
+			}
+		}
+	}`)
+}
+
 func TestFetch_Success(t *testing.T) {
 
 	// Setup:
@@ -21,19 +35,7 @@ func TestFetch_Success(t *testing.T) {
 		func(rw http.ResponseWriter, req *http.Request) {
 			calledURLs = append(calledURLs, req.RequestURI)
 			rw.WriteHeader(http.StatusOK)
-			rw.Write([]byte(
-				`{
-					"dataAsOf":"2018-09-12",
-					"conversions":{
-						"USD":{
-							"GBP":0.77208
-						},
-						"GBP":{
-							"USD":1.2952
-						}
-					}
-				}`,
-			))
+			rw.Write([]byte(getMockRates()))
 		}),
 	)
 
@@ -57,6 +59,7 @@ func TestFetch_Success(t *testing.T) {
 		&http.Client{},
 		mockedHttpServer.URL,
 		time.Duration(24)*time.Hour,
+		time.Duration(24)*time.Hour,
 	)
 
 	// Verify:
@@ -87,12 +90,13 @@ func TestFetch_Fail404(t *testing.T) {
 		&http.Client{},
 		mockedHttpServer.URL,
 		time.Duration(24)*time.Hour,
+		time.Duration(24)*time.Hour,
 	)
 
 	// Verify:
 	assert.Equal(t, 1, len(calledURLs), "sync URL should have been called %d times but was %d", 1, len(calledURLs))
 	assert.Equal(t, currencyConverter.LastUpdated(), (time.Time{}), "LastUpdated() shouldn't return a time set")
-	assert.Nil(t, currencyConverter.Rates(), "Rates() should return nil")
+	assert.Equal(t, currencyConverter.Rates(), &currencies.ConstantRates{}, "Rates() should return constant rates")
 	assert.NotNil(t, currencyConverter.GetInfo(), "GetInfo() should not return nil")
 }
 
@@ -114,12 +118,13 @@ func TestFetch_FailErrorHttpClient(t *testing.T) {
 		&http.Client{},
 		mockedHttpServer.URL,
 		time.Duration(24)*time.Hour,
+		time.Duration(24)*time.Hour,
 	)
 
 	// Verify:
 	assert.Equal(t, 1, len(calledURLs), "sync URL should have been called %d times but was %d", 1, len(calledURLs))
 	assert.Equal(t, currencyConverter.LastUpdated(), (time.Time{}), "LastUpdated() shouldn't return a time set")
-	assert.Nil(t, currencyConverter.Rates(), "Rates() should return nil")
+	assert.Equal(t, currencyConverter.Rates(), &currencies.ConstantRates{}, "Rates() should return constant rates")
 	assert.NotNil(t, currencyConverter.GetInfo(), "GetInfo() should not return nil")
 }
 
@@ -132,11 +137,12 @@ func TestFetch_FailBadSyncURL(t *testing.T) {
 		&http.Client{},
 		"justaweirdurl",
 		time.Duration(24)*time.Hour,
+		time.Duration(24)*time.Hour,
 	)
 
 	// Verify:
 	assert.Equal(t, currencyConverter.LastUpdated(), (time.Time{}), "LastUpdated() shouldn't return a time set")
-	assert.Nil(t, currencyConverter.Rates(), "Rates() should return nil")
+	assert.Equal(t, currencyConverter.Rates(), &currencies.ConstantRates{}, "Rates() should return constant rates")
 	assert.NotNil(t, currencyConverter.GetInfo(), "GetInfo() should not return nil")
 }
 
@@ -172,12 +178,13 @@ func TestFetch_FailBadJSON(t *testing.T) {
 		&http.Client{},
 		mockedHttpServer.URL,
 		time.Duration(24)*time.Hour,
+		time.Duration(24)*time.Hour,
 	)
 
 	// Verify:
 	assert.Equal(t, 1, len(calledURLs), "sync URL should have been called %d times but was %d", 1, len(calledURLs))
 	assert.Equal(t, currencyConverter.LastUpdated(), (time.Time{}), "LastUpdated() shouldn't return a time set")
-	assert.Nil(t, currencyConverter.Rates(), "Rates() should return nil")
+	assert.Equal(t, currencyConverter.Rates(), &currencies.ConstantRates{}, "Rates() should return constant rates")
 	assert.NotNil(t, currencyConverter.GetInfo(), "GetInfo() should not return nil")
 }
 
@@ -200,12 +207,13 @@ func TestFetch_InvalidRemoteResponseContent(t *testing.T) {
 		&http.Client{},
 		mockedHttpServer.URL,
 		time.Duration(24)*time.Hour,
+		time.Duration(24)*time.Hour,
 	)
 
 	// Verify:
 	assert.Equal(t, 1, len(calledURLs), "sync URL should have been called %d times but was %d", 1, len(calledURLs))
 	assert.Equal(t, currencyConverter.LastUpdated(), (time.Time{}), "LastUpdated() shouldn't return a time set")
-	assert.Nil(t, currencyConverter.Rates(), "Rates() should return nil")
+	assert.Equal(t, currencyConverter.Rates(), &currencies.ConstantRates{}, "Rates() should return constant rates")
 	assert.NotNil(t, currencyConverter.GetInfo(), "GetInfo() should not return nil")
 }
 
@@ -215,19 +223,7 @@ func TestInit(t *testing.T) {
 	mockedHttpServer := httptest.NewServer(http.HandlerFunc(
 		func(rw http.ResponseWriter, req *http.Request) {
 			rw.WriteHeader(http.StatusOK)
-			rw.Write([]byte(
-				`{
-					"dataAsOf":"2018-09-12",
-					"conversions":{
-						"USD":{
-							"GBP":0.77208
-						},
-						"GBP":{
-							"USD":1.2952
-						}
-					}
-				}`,
-			))
+			rw.Write([]byte(getMockRates()))
 		}),
 	)
 
@@ -239,6 +235,7 @@ func TestInit(t *testing.T) {
 		&http.Client{},
 		mockedHttpServer.URL,
 		time.Duration(100)*time.Millisecond,
+		time.Duration(24)*time.Hour,
 		ticks,
 	)
 
@@ -266,10 +263,8 @@ func TestInit(t *testing.T) {
 			assert.False(t, intervalDiff > float64(errorMargin*100), "Interval between ticks should be: %d but was: %d", expectedIntervalDuration, intervalDuration)
 		}
 
-		assert.NotNil(t, currencyConverter.Rates(), "Rates shouldn't be nil")
 		assert.NotEqual(t, currencyConverter.LastUpdated(), (time.Time{}), "LastUpdated should be set")
-		rates := currencyConverter.Rates()
-		assert.Equal(t, expectedRates, rates, "Conversions.Rates weren't the expected ones")
+		assert.Equal(t, expectedRates, currencyConverter.Rates(), "Conversions.Rates weren't the expected ones")
 		assert.NotNil(t, currencyConverter.GetInfo(), "GetInfo() should not return nil")
 
 		if ticksCount == expectedTicks {
@@ -287,19 +282,7 @@ func TestStop(t *testing.T) {
 		func(rw http.ResponseWriter, req *http.Request) {
 			calledURLs = append(calledURLs, req.RequestURI)
 			rw.WriteHeader(http.StatusOK)
-			rw.Write([]byte(
-				`{
-					"dataAsOf":"2018-09-12",
-					"conversions":{
-						"USD":{
-							"GBP":0.77208
-						},
-						"GBP":{
-							"USD":1.2952
-						}
-					}
-				}`,
-			))
+			rw.Write([]byte(getMockRates()))
 		}),
 	)
 
@@ -310,6 +293,7 @@ func TestStop(t *testing.T) {
 		&http.Client{},
 		mockedHttpServer.URL,
 		time.Duration(100)*time.Millisecond,
+		time.Duration(24)*time.Hour,
 		ticks,
 	)
 
@@ -337,19 +321,7 @@ func TestInitWithZeroDuration(t *testing.T) {
 		func(rw http.ResponseWriter, req *http.Request) {
 			calledURLs = append(calledURLs, req.RequestURI)
 			rw.WriteHeader(http.StatusOK)
-			rw.Write([]byte(
-				`{
-					"dataAsOf":"2018-09-12",
-					"conversions":{
-						"USD":{
-							"GBP":0.77208
-						},
-						"GBP":{
-							"USD":1.2952
-						}
-					}
-				}`,
-			))
+			rw.Write([]byte(getMockRates()))
 		}),
 	)
 
@@ -358,6 +330,7 @@ func TestInitWithZeroDuration(t *testing.T) {
 		&http.Client{},
 		mockedHttpServer.URL,
 		time.Duration(0),
+		time.Duration(24)*time.Hour,
 	)
 
 	// Verify:
@@ -366,8 +339,7 @@ func TestInitWithZeroDuration(t *testing.T) {
 
 	assert.Equal(t, 0, len(calledURLs), "sync URL shouldn't have been called but was called %d times", 0, len(calledURLs))
 	assert.Equal(t, (time.Time{}), currencyConverter.LastUpdated(), "LastUpdated() shouldn't be set")
-	_, ok := currencyConverter.Rates().(*currencies.ConstantRates)
-	assert.True(t, ok, "Rates should be type of `currencies.ConstantRates`")
+	assert.Equal(t, currencyConverter.Rates(), &currencies.ConstantRates{}, "Rates() should return constant rates")
 	assert.NotNil(t, currencyConverter.GetInfo(), "GetInfo() should not return nil")
 }
 
@@ -393,19 +365,7 @@ func TestRates(t *testing.T) {
 	mockedHttpServer := httptest.NewServer(http.HandlerFunc(
 		func(rw http.ResponseWriter, req *http.Request) {
 			rw.WriteHeader(http.StatusOK)
-			rw.Write([]byte(
-				`{
-					"dataAsOf":"2018-09-12",
-					"conversions":{
-						"USD":{
-							"GBP":0.77208
-						},
-						"GBP":{
-							"USD":1.2952
-						}
-					}
-				}`,
-			))
+			rw.Write([]byte(getMockRates()))
 		}),
 	)
 
@@ -415,6 +375,7 @@ func TestRates(t *testing.T) {
 		&http.Client{},
 		mockedHttpServer.URL,
 		time.Duration(100)*time.Millisecond,
+		time.Duration(24)*time.Hour,
 		ticks,
 	)
 	rates := currencyConverter.Rates()
@@ -456,12 +417,161 @@ func TestRates_EmptyRates(t *testing.T) {
 		&http.Client{},
 		mockedHttpServer.URL,
 		time.Duration(100)*time.Millisecond,
+		time.Duration(24)*time.Hour,
 	)
 	defer currencyConverter.StopPeriodicFetching()
-	rates := currencyConverter.Rates()
 
 	// Verify:
-	assert.Nil(t, rates, "rates should be nil")
+	assert.Equal(t, currencyConverter.Rates(), &currencies.ConstantRates{}, "Rates() should return constant rates")
+}
+
+func TestSelectRatesBasedOnStaleness(t *testing.T) {
+	calledURLs := []string{}
+	callCnt := 0
+	mockedHttpServer := httptest.NewServer(http.HandlerFunc(
+		func(rw http.ResponseWriter, req *http.Request) {
+			calledURLs = append(calledURLs, req.RequestURI)
+			if callCnt == 0 || callCnt >= 5 {
+				rw.WriteHeader(http.StatusOK)
+				rw.Write([]byte(getMockRates()))
+			} else {
+				rw.WriteHeader(http.StatusNotFound)
+			}
+			callCnt++
+		}),
+	)
+
+	defer mockedHttpServer.Close()
+
+	expectedRates := &currencies.Rates{
+		DataAsOf: time.Date(2018, time.September, 12, 0, 0, 0, 0, time.UTC),
+		Conversions: map[string]map[string]float64{
+			"USD": {
+				"GBP": 0.77208,
+			},
+			"GBP": {
+				"USD": 1.2952,
+			},
+		},
+	}
+
+	// Execute:
+	currencyConverter := currencies.NewRateConverter(
+		&http.Client{},
+		mockedHttpServer.URL,
+		time.Duration(100)*time.Millisecond,
+		time.Duration(200)*time.Millisecond,
+	)
+
+	// Verify:
+	// Rates are valid at t=0, then invalid for 500ms before being valid again
+	assert.Equal(t, currencyConverter.Rates(), expectedRates, "Rates() should return expected rates")
+
+	time.Sleep(100 * time.Millisecond)
+	// Rates have been invalid for ~100ms, rates not stale yet
+	assert.Equal(t, currencyConverter.Rates(), expectedRates, "Rates() should return expected rates")
+
+	time.Sleep(200 * time.Millisecond)
+	// Rates have been invalid for ~300ms, rates are stale
+	assert.Equal(t, currencyConverter.Rates(), &currencies.ConstantRates{}, "Rates() should return constant rates")
+
+	time.Sleep(300 * time.Millisecond)
+	// Rates have been valid again for ~100ms
+	assert.Equal(t, currencyConverter.Rates(), expectedRates, "Rates() should return expected rates")
+}
+
+func TestUseConstantRatesUntilFetchIsSuccessful(t *testing.T) {
+	callCnt := 0
+	mockedHttpServer := httptest.NewServer(http.HandlerFunc(
+		func(rw http.ResponseWriter, req *http.Request) {
+			if callCnt >= 5 {
+				rw.WriteHeader(http.StatusOK)
+				rw.Write([]byte(getMockRates()))
+			} else {
+				rw.WriteHeader(http.StatusNotFound)
+			}
+			callCnt++
+		}),
+	)
+
+	defer mockedHttpServer.Close()
+
+	expectedRates := &currencies.Rates{
+		DataAsOf: time.Date(2018, time.September, 12, 0, 0, 0, 0, time.UTC),
+		Conversions: map[string]map[string]float64{
+			"USD": {
+				"GBP": 0.77208,
+			},
+			"GBP": {
+				"USD": 1.2952,
+			},
+		},
+	}
+
+	// Execute:
+	currencyConverter := currencies.NewRateConverter(
+		&http.Client{},
+		mockedHttpServer.URL,
+		time.Duration(100)*time.Millisecond,
+		time.Duration(1)*time.Second,
+	)
+
+	// Verify:
+	// Rates are invalid at t=0 and remain invalid until 500ms have elapsed
+	assert.Equal(t, currencyConverter.Rates(), &currencies.ConstantRates{}, "Rates() should return constant rates")
+
+	time.Sleep(400 * time.Millisecond)
+	// Rates have been invalid for ~400ms
+	assert.Equal(t, currencyConverter.Rates(), &currencies.ConstantRates{}, "Rates() should return constant rates")
+
+	time.Sleep(200 * time.Millisecond)
+	// Rates have been valid for ~100ms
+	assert.Equal(t, currencyConverter.Rates(), expectedRates, "Rates() should return expected rates")
+}
+
+func TestRatesAreNeverStale(t *testing.T) {
+	callCnt := 0
+	mockedHttpServer := httptest.NewServer(http.HandlerFunc(
+		func(rw http.ResponseWriter, req *http.Request) {
+			if callCnt == 0 {
+				rw.WriteHeader(http.StatusOK)
+				rw.Write([]byte(getMockRates()))
+			} else {
+				rw.WriteHeader(http.StatusNotFound)
+			}
+			callCnt++
+		}),
+	)
+
+	defer mockedHttpServer.Close()
+
+	expectedRates := &currencies.Rates{
+		DataAsOf: time.Date(2018, time.September, 12, 0, 0, 0, 0, time.UTC),
+		Conversions: map[string]map[string]float64{
+			"USD": {
+				"GBP": 0.77208,
+			},
+			"GBP": {
+				"USD": 1.2952,
+			},
+		},
+	}
+
+	// Execute:
+	currencyConverter := currencies.NewRateConverter(
+		&http.Client{},
+		mockedHttpServer.URL,
+		time.Duration(100)*time.Millisecond,
+		time.Duration(0)*time.Millisecond,
+	)
+
+	// Verify:
+	// Rates are valid at t=0 and are then invalid at 100ms
+	assert.Equal(t, currencyConverter.Rates(), expectedRates, "Rates() should return expected rates")
+
+	time.Sleep(500 * time.Millisecond)
+	// Rates have been invalid for ~400ms
+	assert.Equal(t, currencyConverter.Rates(), expectedRates, "Rates() should return expected rates")
 }
 
 func TestRace(t *testing.T) {
@@ -495,6 +605,7 @@ func TestRace(t *testing.T) {
 		mockedHttpClient,
 		"currency.fake.com",
 		time.Duration(10)*time.Millisecond,
+		time.Duration(24)*time.Hour,
 	)
 	defer currencyConverter.StopPeriodicFetching()
 
diff --git a/exchange/bidder_test.go b/exchange/bidder_test.go
index fff397f0..b776715a 100644
--- a/exchange/bidder_test.go
+++ b/exchange/bidder_test.go
@@ -517,6 +517,7 @@ func TestMultiCurrencies(t *testing.T) {
 			&http.Client{},
 			mockedHTTPServer.URL,
 			time.Duration(10)*time.Second,
+			time.Duration(24)*time.Hour,
 		)
 		seatBid, errs := bidder.requestBid(
 			context.Background(),
@@ -831,6 +832,7 @@ func TestMultiCurrencies_RequestCurrencyPick(t *testing.T) {
 			&http.Client{},
 			mockedHTTPServer.URL,
 			time.Duration(10)*time.Second,
+			time.Duration(24)*time.Hour,
 		)
 		seatBid, errs := bidder.requestBid(
 			context.Background(),
diff --git a/exchange/utils_test.go b/exchange/utils_test.go
index 4dad3f54..e50d0f77 100644
--- a/exchange/utils_test.go
+++ b/exchange/utils_test.go
@@ -80,7 +80,7 @@ func TestCleanOpenRTBRequests(t *testing.T) {
 	}
 
 	for _, test := range testCases {
-		reqByBidders, _, err := cleanOpenRTBRequests(context.Background(), test.req, &emptyUsersync{}, map[openrtb_ext.BidderName]*pbsmetrics.AdapterLabels{}, pbsmetrics.Labels{}, &permissionsMock{}, true, privacyConfig)
+		reqByBidders, _, _, err := cleanOpenRTBRequests(context.Background(), test.req, &emptyUsersync{}, map[openrtb_ext.BidderName]*pbsmetrics.AdapterLabels{}, pbsmetrics.Labels{}, &permissionsMock{}, true, privacyConfig)
 		if test.hasError {
 			assert.NotNil(t, err, "Error shouldn't be nil")
 		} else {
@@ -120,7 +120,7 @@ func TestCleanOpenRTBRequestsCCPA(t *testing.T) {
 			},
 		}
 
-		results, _, errs := cleanOpenRTBRequests(context.Background(), req, &emptyUsersync{}, map[openrtb_ext.BidderName]*pbsmetrics.AdapterLabels{}, pbsmetrics.Labels{}, &permissionsMock{}, true, privacyConfig)
+		results, _, _, errs := cleanOpenRTBRequests(context.Background(), req, &emptyUsersync{}, map[openrtb_ext.BidderName]*pbsmetrics.AdapterLabels{}, pbsmetrics.Labels{}, &permissionsMock{}, true, privacyConfig)
 		result := results["appnexus"]
 
 		assert.Nil(t, errs)
@@ -182,7 +182,7 @@ func TestCleanOpenRTBRequestsLMT(t *testing.T) {
 			},
 		}
 
-		results, _, errs := cleanOpenRTBRequests(context.Background(), req, &emptyUsersync{}, map[openrtb_ext.BidderName]*pbsmetrics.AdapterLabels{}, pbsmetrics.Labels{}, &permissionsMock{}, true, privacyConfig)
+		results, _, _, errs := cleanOpenRTBRequests(context.Background(), req, &emptyUsersync{}, map[openrtb_ext.BidderName]*pbsmetrics.AdapterLabels{}, pbsmetrics.Labels{}, &permissionsMock{}, true, privacyConfig)
 		result := results["appnexus"]
 
 		assert.Nil(t, errs)
diff --git a/pbsmetrics/go_metrics_test.go b/pbsmetrics/go_metrics_test.go
index 25f75e77..6d9eaf9f 100644
--- a/pbsmetrics/go_metrics_test.go
+++ b/pbsmetrics/go_metrics_test.go
@@ -56,6 +56,10 @@ func TestNewMetrics(t *testing.T) {
 
 	ensureContains(t, registry, "timeout_notification.ok", m.TimeoutNotificationSuccess)
 	ensureContains(t, registry, "timeout_notification.failed", m.TimeoutNotificationFailure)
+	ensureContains(t, registry, "privacy.request.tcf.v1", m.TCFReqVersion[TCFVersionV1])
+	ensureContains(t, registry, "privacy.request.tcf.v2", m.TCFReqVersion[TCFVersionV2])
+	ensureContains(t, registry, "privacy.request.tcf.err", m.TCFReqVersion[TCFVersionErr])
+
 }
 
 func TestRecordBidType(t *testing.T) {
diff --git a/pbsmetrics/prometheus/prometheus_test.go b/pbsmetrics/prometheus/prometheus_test.go
index 24c50492..03daff0d 100644
--- a/pbsmetrics/prometheus/prometheus_test.go
+++ b/pbsmetrics/prometheus/prometheus_test.go
@@ -930,13 +930,13 @@ func TestTimeoutNotifications(t *testing.T) {
 	m.RecordTimeoutNotice(true)
 	m.RecordTimeoutNotice(false)
 
-	assertCounterVecValue(t, "", "timeout_notifications:ok", m.timeout_notifications,
+	assertCounterVecValue(t, "", "timeout_notifications:ok", m.timeoutNotifications,
 		float64(2),
 		prometheus.Labels{
 			successLabel: requestSuccessful,
 		})
 
-	assertCounterVecValue(t, "", "timeout_notifications:fail", m.timeout_notifications,
+	assertCounterVecValue(t, "", "timeout_notifications:fail", m.timeoutNotifications,
 		float64(1),
 		prometheus.Labels{
 			successLabel: requestFailed,
@@ -944,6 +944,36 @@ func TestTimeoutNotifications(t *testing.T) {
 
 }
 
+func TestTCFMetrics(t *testing.T) {
+	m := createMetricsForTesting()
+
+	m.RecordTCFReq(pbsmetrics.TCFVersionToValue(0))
+	m.RecordTCFReq(pbsmetrics.TCFVersionToValue(1))
+	m.RecordTCFReq(pbsmetrics.TCFVersionToValue(2))
+	m.RecordTCFReq(pbsmetrics.TCFVersionToValue(1))
+
+	assertCounterVecValue(t, "", "privacy_tcf:err", m.tcfVersion,
+		float64(1),
+		prometheus.Labels{
+			versionLabel: "err",
+			sourceLabel:  sourceRequest,
+		})
+
+	assertCounterVecValue(t, "", "privacy_tcf:v1", m.tcfVersion,
+		float64(2),
+		prometheus.Labels{
+			versionLabel: "v1",
+			sourceLabel:  sourceRequest,
+		})
+
+	assertCounterVecValue(t, "", "privacy_tcf:v2", m.tcfVersion,
+		float64(1),
+		prometheus.Labels{
+			versionLabel: "v2",
+			sourceLabel:  sourceRequest,
+		})
+}
+
 func assertCounterValue(t *testing.T, description, name string, counter prometheus.Counter, expected float64) {
 	m := dto.Metric{}
 	counter.Write(&m)