Repository: prebid/prebid-server
Pull request #2289 (2022-06-29 21:58:40)

Add ENV Bindings For Top Level UserSync
I missed a few environment variable bindings when I initially added this config.


Patch:
diff --git a/analytics/config/config.go b/analytics/config/config.go
index fa63cb5a..ae33e464 100644
--- a/analytics/config/config.go
+++ b/analytics/config/config.go
@@ -1,6 +1,7 @@
 package config
 
 import (
+	"github.com/benbjohnson/clock"
 	"github.com/golang/glog"
 	"github.com/prebid/prebid-server/analytics"
 	"github.com/prebid/prebid-server/analytics/clients"
@@ -19,15 +20,17 @@ func NewPBSAnalytics(analytics *config.Analytics) analytics.PBSAnalyticsModule {
 			glog.Fatalf("Could not initialize FileLogger for file %v :%v", analytics.File.Filename, err)
 		}
 	}
+
 	if analytics.Pubstack.Enabled {
-		pubstackModule, err := pubstack.NewPubstackModule(
+		pubstackModule, err := pubstack.NewModule(
 			clients.GetDefaultHttpInstance(),
 			analytics.Pubstack.ScopeId,
 			analytics.Pubstack.IntakeUrl,
 			analytics.Pubstack.ConfRefresh,
 			analytics.Pubstack.Buffers.EventCount,
 			analytics.Pubstack.Buffers.BufferSize,
-			analytics.Pubstack.Buffers.Timeout)
+			analytics.Pubstack.Buffers.Timeout,
+			clock.New())
 		if err == nil {
 			modules = append(modules, pubstackModule)
 		} else {
diff --git a/analytics/pubstack/config.go b/analytics/pubstack/config.go
index 472acf68..3f604af5 100644
--- a/analytics/pubstack/config.go
+++ b/analytics/pubstack/config.go
@@ -2,14 +2,14 @@ package pubstack
 
 import (
 	"encoding/json"
-	"github.com/docker/go-units"
 	"net/http"
 	"net/url"
 	"time"
+
+	"github.com/docker/go-units"
 )
 
 func fetchConfig(client *http.Client, endpoint *url.URL) (*Configuration, error) {
-
 	res, err := client.Get(endpoint.String())
 	if err != nil {
 		return nil, err
@@ -49,3 +49,24 @@ func (a *Configuration) isSameAs(b *Configuration) bool {
 	}
 	return sameFeature && sameEndpoint && sameScopeID
 }
+
+func (a *Configuration) clone() *Configuration {
+	c := &Configuration{
+		ScopeID:  a.ScopeID,
+		Endpoint: a.Endpoint,
+		Features: make(map[string]bool, len(a.Features)),
+	}
+
+	for k, v := range a.Features {
+		c.Features[k] = v
+	}
+
+	return c
+}
+
+func (a *Configuration) disableAllFeatures() *Configuration {
+	for k := range a.Features {
+		a.Features[k] = false
+	}
+	return a
+}
diff --git a/analytics/pubstack/configupdate.go b/analytics/pubstack/configupdate.go
new file mode 100644
index 00000000..622161a0
--- /dev/null
+++ b/analytics/pubstack/configupdate.go
@@ -0,0 +1,61 @@
+package pubstack
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"time"
+
+	"github.com/prebid/prebid-server/util/task"
+)
+
+// ConfigUpdateTask publishes configurations until the stop channel is signaled.
+type ConfigUpdateTask interface {
+	Start(stop <-chan struct{}) <-chan *Configuration
+}
+
+// ConfigUpdateHttpTask polls an HTTP endpoint on a specified interval and publishes configurations until
+// the stop channel is signaled.
+type ConfigUpdateHttpTask struct {
+	task       *task.TickerTask
+	configChan chan *Configuration
+}
+
+func NewConfigUpdateHttpTask(httpClient *http.Client, scope, endpoint, refreshInterval string) (*ConfigUpdateHttpTask, error) {
+	refreshDuration, err := time.ParseDuration(refreshInterval)
+	if err != nil {
+		return nil, fmt.Errorf("fail to parse the module args, arg=analytics.pubstack.configuration_refresh_delay: %v", err)
+	}
+
+	endpointUrl, err := url.Parse(endpoint + "/bootstrap?scopeId=" + scope)
+	if err != nil {
+		return nil, err
+	}
+
+	configChan := make(chan *Configuration)
+
+	tr := task.NewTickerTaskFromFunc(refreshDuration, func() error {
+		config, err := fetchConfig(httpClient, endpointUrl)
+		if err != nil {
+			return fmt.Errorf("[pubstack] Fail to fetch remote configuration: %v", err)
+		}
+		configChan <- config
+		return nil
+	})
+
+	return &ConfigUpdateHttpTask{
+		task:       tr,
+		configChan: configChan,
+	}, nil
+}
+
+func (t *ConfigUpdateHttpTask) Start(stop <-chan struct{}) <-chan *Configuration {
+	go t.task.Start()
+
+	go func() {
+		<-stop
+		t.task.Stop()
+	}()
+
+	return t.configChan
+}
diff --git a/analytics/pubstack/eventchannel/eventchannel.go b/analytics/pubstack/eventchannel/eventchannel.go
index d9c2bc41..022cc5bc 100644
--- a/analytics/pubstack/eventchannel/eventchannel.go
+++ b/analytics/pubstack/eventchannel/eventchannel.go
@@ -6,6 +6,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/benbjohnson/clock"
 	"github.com/golang/glog"
 )
 
@@ -13,11 +14,13 @@ type Metrics struct {
 	bufferSize int64
 	eventCount int64
 }
+
 type Limit struct {
 	maxByteSize   int64
 	maxEventCount int64
 	maxTime       time.Duration
 }
+
 type EventChannel struct {
 	gz   *gzip.Writer
 	buff *bytes.Buffer
@@ -28,9 +31,10 @@ type EventChannel struct {
 	muxGzBuffer sync.RWMutex
 	send        Sender
 	limit       Limit
+	clock       clock.Clock
 }
 
-func NewEventChannel(sender Sender, maxByteSize, maxEventCount int64, maxTime time.Duration) *EventChannel {
+func NewEventChannel(sender Sender, clock clock.Clock, maxByteSize, maxEventCount int64, maxTime time.Duration) *EventChannel {
 	b := &bytes.Buffer{}
 	gzw := gzip.NewWriter(b)
 
@@ -42,6 +46,7 @@ func NewEventChannel(sender Sender, maxByteSize, maxEventCount int64, maxTime ti
 		metrics: Metrics{},
 		send:    sender,
 		limit:   Limit{maxByteSize, maxEventCount, maxTime},
+		clock:   clock,
 	}
 	go c.start()
 	return &c
@@ -116,19 +121,21 @@ func (c *EventChannel) flush() {
 }
 
 func (c *EventChannel) start() {
-	ticker := time.NewTicker(c.limit.maxTime)
+	ticker := c.clock.Ticker(c.limit.maxTime)
 
 	for {
 		select {
 		case <-c.endCh:
 			c.flush()
 			return
+
 		// event is received
 		case event := <-c.ch:
 			c.buffer(event)
 			if c.isBufferFull() {
 				c.flush()
 			}
+
 		// time between 2 flushes has passed
 		case <-ticker.C:
 			c.flush()
diff --git a/analytics/pubstack/pubstack_module.go b/analytics/pubstack/pubstack_module.go
index 60ccde02..987c935f 100644
--- a/analytics/pubstack/pubstack_module.go
+++ b/analytics/pubstack/pubstack_module.go
@@ -2,19 +2,19 @@ package pubstack
 
 import (
 	"fmt"
-	"github.com/prebid/prebid-server/analytics/pubstack/eventchannel"
 	"net/http"
-	"net/url"
 	"os"
 	"os/signal"
 	"sync"
 	"syscall"
 	"time"
 
+	"github.com/benbjohnson/clock"
 	"github.com/golang/glog"
-	"github.com/prebid/prebid-server/analytics/pubstack/helpers"
 
 	"github.com/prebid/prebid-server/analytics"
+	"github.com/prebid/prebid-server/analytics/pubstack/eventchannel"
+	"github.com/prebid/prebid-server/analytics/pubstack/helpers"
 )
 
 type Configuration struct {
@@ -41,24 +41,32 @@ type bufferConfig struct {
 type PubstackModule struct {
 	eventChannels map[string]*eventchannel.EventChannel
 	httpClient    *http.Client
-	configCh      chan *Configuration
 	sigTermCh     chan os.Signal
+	stopCh        chan struct{}
 	scope         string
 	cfg           *Configuration
 	buffsCfg      *bufferConfig
 	muxConfig     sync.RWMutex
+	clock         clock.Clock
 }
 
-func NewPubstackModule(client *http.Client, scope, endpoint, configRefreshDelay string, maxEventCount int, maxByteSize, maxTime string) (analytics.PBSAnalyticsModule, error) {
-	glog.Infof("[pubstack] Initializing module scope=%s endpoint=%s\n", scope, endpoint)
-
-	// parse args
-
-	refreshDelay, err := time.ParseDuration(configRefreshDelay)
+func NewModule(client *http.Client, scope, endpoint, configRefreshDelay string, maxEventCount int, maxByteSize, maxTime string, clock clock.Clock) (analytics.PBSAnalyticsModule, error) {
+	configUpdateTask, err := NewConfigUpdateHttpTask(
+		client,
+		scope,
+		endpoint,
+		configRefreshDelay)
 	if err != nil {
-		return nil, fmt.Errorf("fail to parse the module args, arg=analytics.pubstack.configuration_refresh_delay, :%v", err)
+		return nil, err
 	}
 
+	return NewModuleWithConfigTask(client, scope, endpoint, maxEventCount, maxByteSize, maxTime, configUpdateTask, clock)
+}
+
+func NewModuleWithConfigTask(client *http.Client, scope, endpoint string, maxEventCount int, maxByteSize, maxTime string, configTask ConfigUpdateTask, clock clock.Clock) (analytics.PBSAnalyticsModule, error) {
+	glog.Infof("[pubstack] Initializing module scope=%s endpoint=%s\n", scope, endpoint)
+
+	// parse args
 	bufferCfg, err := newBufferConfig(maxEventCount, maxByteSize, maxTime)
 	if err != nil {
 		return nil, fmt.Errorf("fail to parse the module args, arg=analytics.pubstack.buffers, :%v", err)
@@ -84,24 +92,16 @@ func NewPubstackModule(client *http.Client, scope, endpoint, configRefreshDelay
 		cfg:           defaultConfig,
 		buffsCfg:      bufferCfg,
 		sigTermCh:     make(chan os.Signal),
-		configCh:      make(chan *Configuration),
+		stopCh:        make(chan struct{}),
 		eventChannels: make(map[string]*eventchannel.EventChannel),
 		muxConfig:     sync.RWMutex{},
+		clock:         clock,
 	}
+
 	signal.Notify(pb.sigTermCh, os.Interrupt, syscall.SIGTERM)
 
-	configUrl, err := url.Parse(pb.cfg.Endpoint + "/bootstrap?scopeId=" + pb.cfg.ScopeID)
-	if err != nil {
-		glog.Error(err)
-		return nil, err
-	}
-	go pb.start(configUrl, refreshDelay)
-	go func() {
-		err = pb.reloadConfig(configUrl)
-		if err != nil {
-			glog.Errorf("[pubstack] Fail to fetch remote configuration: %v", err)
-		}
-	}()
+	configChannel := configTask.Start(pb.stopCh)
+	go pb.start(configChannel)
 
 	glog.Info("[pubstack] Pubstack analytics configured and ready")
 	return &pb, nil
@@ -180,7 +180,6 @@ func (p *PubstackModule) LogCookieSyncObject(cso *analytics.CookieSyncObject) {
 	}
 
 	p.eventChannels[cookieSync].Push(payload)
-
 }
 
 func (p *PubstackModule) LogAmpObject(ao *analytics.AmpObject) {
@@ -199,40 +198,21 @@ func (p *PubstackModule) LogAmpObject(ao *analytics.AmpObject) {
 	}
 
 	p.eventChannels[amp].Push(payload)
-
-}
-
-func (p *PubstackModule) reloadConfig(configUrl *url.URL) error {
-	config, err := fetchConfig(p.httpClient, configUrl)
-	if err != nil {
-		return err
-	}
-	p.configCh <- config
-	return nil
 }
 
-func (p *PubstackModule) start(configUrl *url.URL, refreshDelay time.Duration) {
-
-	tick := time.NewTicker(refreshDelay)
-
+func (p *PubstackModule) start(c <-chan *Configuration) {
 	for {
 		select {
 		case <-p.sigTermCh:
-			p.closeAllEventChannels()
+			close(p.stopCh)
+			cfg := p.cfg.clone().disableAllFeatures()
+			p.updateConfig(cfg)
 			return
-		case config := <-p.configCh:
+		case config := <-c:
 			p.updateConfig(config)
 			glog.Infof("[pubstack] Updating config: %v", p.cfg)
-		case <-tick.C:
-			go func() {
-				err := p.reloadConfig(configUrl)
-				if err != nil {
-					glog.Errorf("[pubstack] Fail to fetch remote configuration: %v", err)
-				}
-			}()
 		}
 	}
-
 }
 
 func (p *PubstackModule) updateConfig(config *Configuration) {
@@ -246,20 +226,22 @@ func (p *PubstackModule) updateConfig(config *Configuration) {
 	p.cfg = config
 	p.closeAllEventChannels()
 
-	if p.isFeatureEnable(amp) {
-		p.eventChannels[amp] = eventchannel.NewEventChannel(eventchannel.BuildEndpointSender(p.httpClient, p.cfg.Endpoint, amp), p.buffsCfg.size, p.buffsCfg.count, p.buffsCfg.timeout)
-	}
-	if p.isFeatureEnable(auction) {
-		p.eventChannels[auction] = eventchannel.NewEventChannel(eventchannel.BuildEndpointSender(p.httpClient, p.cfg.Endpoint, auction), p.buffsCfg.size, p.buffsCfg.count, p.buffsCfg.timeout)
-	}
-	if p.isFeatureEnable(cookieSync) {
-		p.eventChannels[cookieSync] = eventchannel.NewEventChannel(eventchannel.BuildEndpointSender(p.httpClient, p.cfg.Endpoint, cookieSync), p.buffsCfg.size, p.buffsCfg.count, p.buffsCfg.timeout)
-	}
-	if p.isFeatureEnable(video) {
-		p.eventChannels[video] = eventchannel.NewEventChannel(eventchannel.BuildEndpointSender(p.httpClient, p.cfg.Endpoint, video), p.buffsCfg.size, p.buffsCfg.count, p.buffsCfg.timeout)
-	}
-	if p.isFeatureEnable(setUID) {
-		p.eventChannels[setUID] = eventchannel.NewEventChannel(eventchannel.BuildEndpointSender(p.httpClient, p.cfg.Endpoint, setUID), p.buffsCfg.size, p.buffsCfg.count, p.buffsCfg.timeout)
+	p.registerChannel(amp)
+	p.registerChannel(auction)
+	p.registerChannel(cookieSync)
+	p.registerChannel(video)
+	p.registerChannel(setUID)
+}
+
+func (p *PubstackModule) isFeatureEnable(feature string) bool {
+	val, ok := p.cfg.Features[feature]
+	return ok && val
+}
+
+func (p *PubstackModule) registerChannel(feature string) {
+	if p.isFeatureEnable(feature) {
+		sender := eventchannel.BuildEndpointSender(p.httpClient, p.cfg.Endpoint, feature)
+		p.eventChannels[feature] = eventchannel.NewEventChannel(sender, p.clock, p.buffsCfg.size, p.buffsCfg.count, p.buffsCfg.timeout)
 	}
 }
 
@@ -269,8 +251,3 @@ func (p *PubstackModule) closeAllEventChannels() {
 		delete(p.eventChannels, key)
 	}
 }
-
-func (p *PubstackModule) isFeatureEnable(feature string) bool {
-	val, ok := p.cfg.Features[feature]
-	return ok && val
-}
diff --git a/config/config.go b/config/config.go
index 01c677fd..ef98498f 100644
--- a/config/config.go
+++ b/config/config.go
@@ -91,7 +91,8 @@ type Configuration struct {
 	//When true, new bid id will be generated in seatbid[].bid[].ext.prebid.bidid and used in event urls instead
 	GenerateBidID bool `mapstructure:"generate_bid_id"`
 	// GenerateRequestID overrides the bidrequest.id in an AMP Request or an App Stored Request with a generated UUID if set to true. The default is false.
-	GenerateRequestID bool `mapstructure:"generate_request_id"`
+	GenerateRequestID bool                                          `mapstructure:"generate_request_id"`
+	HostSChainNode    *openrtb_ext.ExtRequestPrebidSChainSChainNode `mapstructure:"host_schain_node"`
 }
 
 const MIN_COOKIE_SIZE_BYTES = 500
@@ -773,6 +774,7 @@ func SetupViper(v *viper.Viper, filename string) {
 	v.SetDefault("host_cookie.value", "")
 	v.SetDefault("host_cookie.ttl_days", 90)
 	v.SetDefault("host_cookie.max_cookie_size_bytes", 0)
+	v.SetDefault("host_schain_node", nil)
 	v.SetDefault("http_client.max_connections_per_host", 0) // unlimited
 	v.SetDefault("http_client.max_idle_connections", 400)
 	v.SetDefault("http_client.max_idle_connections_per_host", 10)
@@ -890,6 +892,9 @@ func SetupViper(v *viper.Viper, filename string) {
 	v.SetDefault("accounts.filesystem.directorypath", "./stored_requests/data/by_id")
 	v.SetDefault("accounts.in_memory_cache.type", "none")
 
+	v.BindEnv("user_sync.external_url")
+	v.BindEnv("user_sync.coop_sync.default")
+
 	// some adapters append the user id to the end of the redirect url instead of using
 	// macro substitution. it is important for the uid to be the last query parameter.
 	v.SetDefault("user_sync.redirect_url", "{{.ExternalURL}}/setuid?bidder={{.SyncerKey}}&gdpr={{.GDPR}}&gdpr_consent={{.GDPRConsent}}&f={{.SyncType}}&uid={{.UserMacro}}")
diff --git a/endpoints/openrtb2/auction.go b/endpoints/openrtb2/auction.go
index ffbd358a..645c2505 100644
--- a/endpoints/openrtb2/auction.go
+++ b/endpoints/openrtb2/auction.go
@@ -1259,6 +1259,8 @@ func isBidderToValidate(bidder string) bool {
 		return false
 	case openrtb_ext.BidderReservedSKAdN:
 		return false
+	case openrtb_ext.BidderReservedTID:
+		return false
 	default:
 		return true
 	}
diff --git a/exchange/exchange.go b/exchange/exchange.go
index 25888071..96e6a40f 100644
--- a/exchange/exchange.go
+++ b/exchange/exchange.go
@@ -65,6 +65,7 @@ type exchange struct {
 	privacyConfig     config.Privacy
 	categoriesFetcher stored_requests.CategoryFetcher
 	bidIDGenerator    BidIDGenerator
+	hostSChainNode    *openrtb_ext.ExtRequestPrebidSChainSChainNode
 }
 
 // Container to pass out response ext data from the GetAllBids goroutines back into the main thread
@@ -141,6 +142,7 @@ func NewExchange(adapters map[openrtb_ext.BidderName]AdaptedBidder, cache prebid
 			LMT:  cfg.LMT,
 		},
 		bidIDGenerator: &bidIDGenerator{cfg.GenerateBidID},
+		hostSChainNode: cfg.HostSChainNode,
 	}
 }
 
@@ -244,7 +246,7 @@ func (e *exchange) HoldAuction(ctx context.Context, r AuctionRequest, debugLog *
 	gdprDefaultValue := e.parseGDPRDefaultValue(r.BidRequestWrapper.BidRequest)
 
 	// Slice of BidRequests, each a copy of the original cleaned to only contain bidder data for the named bidder
-	bidderRequests, privacyLabels, errs := cleanOpenRTBRequests(ctx, r, requestExt, e.bidderToSyncerKey, e.me, gdprDefaultValue, e.privacyConfig, e.gdprPermsBuilder, e.tcf2ConfigBuilder)
+	bidderRequests, privacyLabels, errs := cleanOpenRTBRequests(ctx, r, requestExt, e.bidderToSyncerKey, e.me, gdprDefaultValue, e.privacyConfig, e.gdprPermsBuilder, e.tcf2ConfigBuilder, e.hostSChainNode)
 
 	e.me.RecordRequestPrivacy(privacyLabels)
 
diff --git a/exchange/utils.go b/exchange/utils.go
index 610a1587..3ce053e5 100644
--- a/exchange/utils.go
+++ b/exchange/utils.go
@@ -46,6 +46,7 @@ func cleanOpenRTBRequests(ctx context.Context,
 	privacyConfig config.Privacy,
 	gdprPermsBuilder gdpr.PermissionsBuilder,
 	tcf2ConfigBuilder gdpr.TCF2ConfigBuilder,
+	hostSChainNode *openrtb_ext.ExtRequestPrebidSChainSChainNode,
 ) (allowedBidderRequests []BidderRequest, privacyLabels metrics.PrivacyLabels, errs []error) {
 
 	req := auctionReq.BidRequestWrapper
@@ -70,7 +71,7 @@ func cleanOpenRTBRequests(ctx context.Context,
 	}
 
 	var allBidderRequests []BidderRequest
-	allBidderRequests, errs = getAuctionBidderRequests(auctionReq, requestExt, bidderToSyncerKey, impsByBidder, aliases)
+	allBidderRequests, errs = getAuctionBidderRequests(auctionReq, requestExt, bidderToSyncerKey, impsByBidder, aliases, hostSChainNode)
 
 	bidderNameToBidderReq := buildBidResponseRequest(req.BidRequest, bidderImpWithBidResp, aliases)
 	//this function should be executed after getAuctionBidderRequests
@@ -206,7 +207,8 @@ func getAuctionBidderRequests(auctionRequest AuctionRequest,
 	requestExt *openrtb_ext.ExtRequest,
 	bidderToSyncerKey map[string]string,
 	impsByBidder map[string][]openrtb2.Imp,
-	aliases map[string]string) ([]BidderRequest, []error) {
+	aliases map[string]string,
+	hostSChainNode *openrtb_ext.ExtRequestPrebidSChainSChainNode) ([]BidderRequest, []error) {
 
 	bidderRequests := make([]BidderRequest, 0, len(impsByBidder))
 	req := auctionRequest.BidRequestWrapper
@@ -220,7 +222,7 @@ func getAuctionBidderRequests(auctionRequest AuctionRequest,
 		return nil, []error{err}
 	}
 
-	sChainWriter, err := schain.NewSChainWriter(requestExt)
+	sChainWriter, err := schain.NewSChainWriter(requestExt, hostSChainNode)
 	if err != nil {
 		return nil, []error{err}
 	}
@@ -421,6 +423,10 @@ func createSanitizedImpExt(impExt, impExtPrebid map[string]json.RawMessage) (map
 		sanitizedImpExt[openrtb_ext.GPIDKey] = v
 	}
 
+	if v, exists := impExt[string(openrtb_ext.TIDKey)]; exists {
+		sanitizedImpExt[openrtb_ext.TIDKey] = v
+	}
+
 	return sanitizedImpExt, nil
 }
 
@@ -451,7 +457,8 @@ func isSpecialField(bidder string) bool {
 		bidder == openrtb_ext.FirstPartyDataExtKey ||
 		bidder == openrtb_ext.SKAdNExtKey ||
 		bidder == openrtb_ext.GPIDKey ||
-		bidder == openrtb_ext.PrebidExtKey
+		bidder == openrtb_ext.PrebidExtKey ||
+		bidder == openrtb_ext.TIDKey
 }
 
 // prepareUser changes req.User so that it's ready for the given bidder.
diff --git a/go.mod b/go.mod
index fc1555f1..cdb94928 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ require (
 	github.com/DATA-DOG/go-sqlmock v1.5.0
 	github.com/NYTimes/gziphandler v1.1.1
 	github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d
+	github.com/benbjohnson/clock v1.3.0 // indirect
 	github.com/buger/jsonparser v1.1.1
 	github.com/chasex/glog v0.0.0-20160217080310-c62392af379c
 	github.com/coocood/freecache v1.2.1
diff --git a/go.sum b/go.sum
index d3c37578..f2736017 100644
--- a/go.sum
+++ b/go.sum
@@ -75,6 +75,8 @@ github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj
 github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
 github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ=
 github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
+github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
+github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
diff --git a/openrtb_ext/bidders.go b/openrtb_ext/bidders.go
index a3f7840c..9eaa81ca 100644
--- a/openrtb_ext/bidders.go
+++ b/openrtb_ext/bidders.go
@@ -35,6 +35,7 @@ const (
 	BidderReservedGPID    BidderName = "gpid"    // Reserved for Global Placement ID (GPID).
 	BidderReservedPrebid  BidderName = "prebid"  // Reserved for Prebid Server configuration.
 	BidderReservedSKAdN   BidderName = "skadn"   // Reserved for Apple's SKAdNetwork OpenRTB extension.
+	BidderReservedTID     BidderName = "tid"     // Reserved for Per-Impression Transactions IDs for Multi-Impression Bid Requests.
 )
 
 // IsBidderNameReserved returns true if the specified name is a case insensitive match for a reserved bidder name.
@@ -67,6 +68,10 @@ func IsBidderNameReserved(name string) bool {
 		return true
 	}
 
+	if strings.EqualFold(name, string(BidderReservedTID)) {
+		return true
+	}
+
 	return false
 }
 
diff --git a/openrtb_ext/request.go b/openrtb_ext/request.go
index 3abda32e..33d168f6 100644
--- a/openrtb_ext/request.go
+++ b/openrtb_ext/request.go
@@ -17,6 +17,9 @@ const SKAdNExtKey = "skadn"
 // GPIDKey defines the field name within request.ext reserved for the Global Placement ID (GPID),
 const GPIDKey = "gpid"
 
+// TIDKey reserved for Per-Impression Transactions IDs for Multi-Impression Bid Requests.
+const TIDKey = "tid"
+
 // NativeExchangeSpecificLowerBound defines the lower threshold of exchange specific types for native ads. There is no upper bound.
 const NativeExchangeSpecificLowerBound = 500
 
diff --git a/schain/schainwriter.go b/schain/schainwriter.go
index bf2f5640..b6788229 100644
--- a/schain/schainwriter.go
+++ b/schain/schainwriter.go
@@ -8,9 +8,9 @@ import (
 )
 
 // NewSChainWriter creates an ORTB 2.5 schain writer instance
-func NewSChainWriter(reqExt *openrtb_ext.ExtRequest) (*SChainWriter, error) {
+func NewSChainWriter(reqExt *openrtb_ext.ExtRequest, hostSChainNode *openrtb_ext.ExtRequestPrebidSChainSChainNode) (*SChainWriter, error) {
 	if !extPrebidSChainExists(reqExt) {
-		return &SChainWriter{}, nil
+		return &SChainWriter{hostSChainNode: hostSChainNode}, nil
 	}
 
 	sChainsByBidder, err := BidderToPrebidSChains(reqExt.Prebid.SChains)
@@ -20,6 +20,7 @@ func NewSChainWriter(reqExt *openrtb_ext.ExtRequest) (*SChainWriter, error) {
 
 	writer := SChainWriter{
 		sChainsByBidder: sChainsByBidder,
+		hostSChainNode:  hostSChainNode,
 	}
 	return &writer, nil
 }
@@ -28,6 +29,7 @@ func NewSChainWriter(reqExt *openrtb_ext.ExtRequest) (*SChainWriter, error) {
 // location (req.ext.prebid.schain) to the ORTB 2.5 location (req.source.ext)
 type SChainWriter struct {
 	sChainsByBidder map[string]*openrtb_ext.ExtRequestPrebidSChainSChain
+	hostSChainNode  *openrtb_ext.ExtRequestPrebidSChainSChainNode
 }
 
 // Write selects an schain from the multi-schain ORTB 2.5 location (req.ext.prebid.schains) for the specified bidder
@@ -41,29 +43,38 @@ func (w SChainWriter) Write(req *openrtb2.BidRequest, bidder string) {
 	bidderSChain := w.sChainsByBidder[bidder]
 
 	// source should not be modified
-	if bidderSChain == nil && wildCardSChain == nil {
+	if bidderSChain == nil && wildCardSChain == nil && w.hostSChainNode == nil {
 		return
 	}
 
+	selectedSChain = &openrtb_ext.ExtRequestPrebidSChainSChain{Ver: "1.0"}
+
 	if bidderSChain != nil {
 		selectedSChain = bidderSChain
-	} else {
+	} else if wildCardSChain != nil {
 		selectedSChain = wildCardSChain
 	}
 
+	schain := openrtb_ext.ExtRequestPrebidSChain{
+		SChain: *selectedSChain,
+	}
+
 	if req.Source == nil {
 		req.Source = &openrtb2.Source{}
 	} else {
 		sourceCopy := *req.Source
 		req.Source = &sourceCopy
 	}
-	schain := openrtb_ext.ExtRequestPrebidSChain{
-		SChain: *selectedSChain,
+
+	if w.hostSChainNode != nil {
+		schain.SChain.Nodes = append(schain.SChain.Nodes, w.hostSChainNode)
 	}
+
 	sourceExt, err := json.Marshal(schain)
 	if err == nil {
 		req.Source.Ext = sourceExt
 	}
+
 }
 
 // extPrebidSChainExists checks if an schain exists in the ORTB 2.5 req.ext.prebid.schain location
diff --git a/util/task/func_runner.go b/util/task/func_runner.go
new file mode 100644
index 00000000..fe6de614
--- /dev/null
+++ b/util/task/func_runner.go
@@ -0,0 +1,15 @@
+package task
+
+import "time"
+
+type funcRunner struct {
+	run func() error
+}
+
+func (r funcRunner) Run() error {
+	return r.run()
+}
+
+func NewTickerTaskFromFunc(interval time.Duration, runner func() error) *TickerTask {
+	return NewTickerTask(interval, funcRunner{run: runner})
+}


Test patch:
diff --git a/analytics/pubstack/config_test.go b/analytics/pubstack/config_test.go
index bb6fd0bd..1b763729 100644
--- a/analytics/pubstack/config_test.go
+++ b/analytics/pubstack/config_test.go
@@ -1,11 +1,12 @@
 package pubstack
 
 import (
-	"github.com/stretchr/testify/assert"
 	"net/http"
 	"net/http/httptest"
 	"net/url"
 	"testing"
+
+	"github.com/stretchr/testify/assert"
 )
 
 func TestFetchConfig(t *testing.T) {
@@ -24,7 +25,6 @@ func TestFetchConfig(t *testing.T) {
 	server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
 		defer req.Body.Close()
 		res.Write([]byte(configResponse))
-		res.WriteHeader(200)
 	}))
 
 	defer server.Close()
@@ -32,13 +32,13 @@ func TestFetchConfig(t *testing.T) {
 	endpoint, _ := url.Parse(server.URL)
 	cfg, _ := fetchConfig(server.Client(), endpoint)
 
-	assert.Equal(t, cfg.ScopeID, "scopeId")
-	assert.Equal(t, cfg.Endpoint, "https://pubstack.io")
-	assert.Equal(t, cfg.Features[auction], true)
-	assert.Equal(t, cfg.Features[cookieSync], true)
-	assert.Equal(t, cfg.Features[amp], true)
-	assert.Equal(t, cfg.Features[setUID], false)
-	assert.Equal(t, cfg.Features[video], false)
+	assert.Equal(t, "scopeId", cfg.ScopeID)
+	assert.Equal(t, "https://pubstack.io", cfg.Endpoint)
+	assert.Equal(t, true, cfg.Features[auction])
+	assert.Equal(t, true, cfg.Features[cookieSync])
+	assert.Equal(t, true, cfg.Features[amp])
+	assert.Equal(t, false, cfg.Features[setUID])
+	assert.Equal(t, false, cfg.Features[video])
 }
 
 func TestFetchConfig_Error(t *testing.T) {
@@ -49,7 +49,6 @@ func TestFetchConfig_Error(t *testing.T) {
 	server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
 		defer req.Body.Close()
 		res.Write([]byte(configResponse))
-		res.WriteHeader(200)
 	}))
 
 	defer server.Close()
@@ -58,7 +57,7 @@ func TestFetchConfig_Error(t *testing.T) {
 	cfg, err := fetchConfig(server.Client(), endpoint)
 
 	assert.Nil(t, cfg)
-	assert.NotNil(t, err)
+	assert.Error(t, err)
 }
 
 func TestIsSameAs(t *testing.T) {
@@ -98,5 +97,54 @@ func TestIsSameAs(t *testing.T) {
 	assert.True(t, a.isSameAs(b))
 	b.Features["auction"] = false
 	assert.False(t, a.isSameAs(b))
+}
+
+func TestClone(t *testing.T) {
+	config := &Configuration{
+		ScopeID:  "scopeId",
+		Endpoint: "endpoint",
+		Features: map[string]bool{
+			"auction":    true,
+			"cookiesync": true,
+			"amp":        true,
+			"setuid":     false,
+			"video":      false,
+		},
+	}
+
+	clone := config.clone()
+
+	assert.Equal(t, config, clone)
+	assert.NotSame(t, config, clone)
+}
+
+func TestDisableAllFeatures(t *testing.T) {
+	config := &Configuration{
+		ScopeID:  "scopeId",
+		Endpoint: "endpoint",
+		Features: map[string]bool{
+			"auction":    true,
+			"cookiesync": true,
+			"amp":        true,
+			"setuid":     false,
+			"video":      false,
+		},
+	}
+
+	expected := &Configuration{
+		ScopeID:  "scopeId",
+		Endpoint: "endpoint",
+		Features: map[string]bool{
+			"auction":    false,
+			"cookiesync": false,
+			"amp":        false,
+			"setuid":     false,
+			"video":      false,
+		},
+	}
+
+	disabled := config.disableAllFeatures()
 
+	assert.Equal(t, expected, disabled)
+	assert.Same(t, config, disabled)
 }
diff --git a/analytics/pubstack/configupdate_test.go b/analytics/pubstack/configupdate_test.go
new file mode 100644
index 00000000..fdc76425
--- /dev/null
+++ b/analytics/pubstack/configupdate_test.go
@@ -0,0 +1,105 @@
+package pubstack
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestNewConfigUpdateHttpTask(t *testing.T) {
+	// configure test config endpoint
+	var isFirstQuery bool = true
+	var isFirstQueryMutex sync.Mutex
+	mux := http.NewServeMux()
+	mux.HandleFunc("/", func(res http.ResponseWriter, req *http.Request) {
+		isFirstQueryMutex.Lock()
+		defer isFirstQueryMutex.Unlock()
+
+		defer req.Body.Close()
+
+		if isFirstQuery {
+			res.Write([]byte(`{ "scopeId":  "scope1", "endpoint": "https://pubstack.io", "features": { "auction": true, "cookiesync": true }}`))
+		} else {
+			res.Write([]byte(`{ "scopeId":  "scope2", "endpoint": "https://pubstack.io", "features": { "auction": false, "cookiesync": false }}`))
+		}
+
+		isFirstQuery = false
+	})
+	server := httptest.NewServer(mux)
+	client := server.Client()
+
+	// create task
+	task, err := NewConfigUpdateHttpTask(client, "scope", server.URL, "5ms")
+	require.NoError(t, err, "create config task")
+
+	// start task
+	stopChan := make(chan struct{})
+	configChan := task.Start(stopChan)
+
+	// read initial config
+	expectedInitialConfig := &Configuration{ScopeID: "scope1", Endpoint: "https://pubstack.io", Features: map[string]bool{"auction": true, "cookiesync": true}}
+	assertConfigChanOne(t, configChan, expectedInitialConfig, "initial config")
+
+	// read updated config
+	expectedUpdatedConfig := &Configuration{ScopeID: "scope2", Endpoint: "https://pubstack.io", Features: map[string]bool{"auction": false, "cookiesync": false}}
+	assertConfigChanOne(t, configChan, expectedUpdatedConfig, "updated config")
+
+	// stop task
+	close(stopChan)
+
+	// no further updates
+	assertConfigChanNone(t, configChan)
+}
+
+func TestNewConfigUpdateHttpTaskErrors(t *testing.T) {
+	tests := []struct {
+		description          string
+		givenEndpoint        string
+		givenRefreshInterval string
+		expectedError        string
+	}{
+		{
+			description:          "refresh interval invalid",
+			givenEndpoint:        "http://valid.com",
+			givenRefreshInterval: "invalid",
+			expectedError:        `fail to parse the module args, arg=analytics.pubstack.configuration_refresh_delay: time: invalid duration "invalid"`,
+		},
+		{
+			description:          "endpoint invalid",
+			givenEndpoint:        "://invalid.com",
+			givenRefreshInterval: "10ms",
+			expectedError:        `parse "://invalid.com/bootstrap?scopeId=anyScope": missing protocol scheme`,
+		},
+	}
+
+	for _, test := range tests {
+		task, err := NewConfigUpdateHttpTask(nil, "anyScope", test.givenEndpoint, test.givenRefreshInterval)
+		assert.Nil(t, task, test.description)
+		assert.EqualError(t, err, test.expectedError, test.description)
+	}
+}
+
+func assertConfigChanNone(t *testing.T, c <-chan *Configuration) bool {
+	t.Helper()
+	select {
+	case <-c:
+		return assert.Fail(t, "received a unexpected configuration channel event")
+	case <-time.After(200 * time.Millisecond):
+		return true
+	}
+}
+
+func assertConfigChanOne(t *testing.T, c <-chan *Configuration, expectedConfig *Configuration, msgAndArgs ...interface{}) bool {
+	t.Helper()
+	select {
+	case v := <-c:
+		return assert.Equal(t, expectedConfig, v, msgAndArgs...)
+	case <-time.After(200 * time.Millisecond):
+		return assert.Fail(t, "Should receive an event, but did NOT", msgAndArgs...)
+	}
+}
diff --git a/analytics/pubstack/eventchannel/eventchannel_test.go b/analytics/pubstack/eventchannel/eventchannel_test.go
index f450fb61..24639da6 100644
--- a/analytics/pubstack/eventchannel/eventchannel_test.go
+++ b/analytics/pubstack/eventchannel/eventchannel_test.go
@@ -4,178 +4,144 @@ import (
 	"bytes"
 	"compress/gzip"
 	"io/ioutil"
+	"math"
 	"sync"
 	"testing"
 	"time"
 
+	"github.com/benbjohnson/clock"
 	"github.com/stretchr/testify/assert"
 )
 
-var maxByteSize = int64(15)
-var maxEventCount = int64(3)
+var largeBufferSize = int64(math.MaxInt64)
+var largeEventCount = int64(math.MaxInt64)
 var maxTime = 2 * time.Hour
 
-func readGz(encoded bytes.Buffer) string {
-	gr, _ := gzip.NewReader(bytes.NewBuffer(encoded.Bytes()))
+func readGz(encoded []byte) string {
+	gr, _ := gzip.NewReader(bytes.NewReader(encoded))
 	defer gr.Close()
 
 	decoded, _ := ioutil.ReadAll(gr)
 	return string(decoded)
 }
 
-func newSender(data *[]byte) Sender {
+func newSender(dataSent chan []byte) Sender {
 	mux := &sync.Mutex{}
 	return func(payload []byte) error {
 		mux.Lock()
 		defer mux.Unlock()
-		event := bytes.Buffer{}
-		event.Write(payload)
-		*data = append(*data, readGz(event)...)
+		dataSent <- payload
 		return nil
 	}
 }
 
-func TestEventChannel_isBufferFull(t *testing.T) {
+func readChanOrTimeout(t *testing.T, c <-chan []byte, msgAndArgs ...interface{}) ([]byte, bool) {
+	t.Helper()
+	select {
+	case actual := <-c:
+		return actual, false
+	case <-time.After(200 * time.Millisecond):
+		return nil, assert.Fail(t, "Should receive an event, but did NOT", msgAndArgs...)
+	}
+}
 
-	send := func(_ []byte) error { return nil }
+func TestEventChannelIsBufferFull(t *testing.T) {
+	send := func([]byte) error { return nil }
+	clockMock := clock.NewMock()
 
-	eventChannel := NewEventChannel(send, maxByteSize, maxEventCount, maxTime)
+	maxBufferSize := int64(15)
+	maxEventCount := int64(3)
+
+	eventChannel := NewEventChannel(send, clockMock, maxBufferSize, maxEventCount, maxTime)
 	defer eventChannel.Close()
 
 	eventChannel.buffer([]byte("one"))
 	eventChannel.buffer([]byte("two"))
 
-	assert.Equal(t, eventChannel.isBufferFull(), false)
+	assert.False(t, eventChannel.isBufferFull()) // not yet full by either max buffer size or max event count
 
 	eventChannel.buffer([]byte("three"))
 
-	assert.Equal(t, eventChannel.isBufferFull(), true)
+	assert.True(t, eventChannel.isBufferFull()) // full by event count (3)
 
 	eventChannel.reset()
 
-	assert.Equal(t, eventChannel.isBufferFull(), false)
-
-	eventChannel.buffer([]byte("big-event-abcdefghijklmnopqrstuvwxyz"))
+	assert.False(t, eventChannel.isBufferFull()) // was just reset, should not be full
 
-	assert.Equal(t, eventChannel.isBufferFull(), true)
+	eventChannel.buffer([]byte("larger-than-15-characters"))
 
+	assert.True(t, eventChannel.isBufferFull()) // full by max buffer size
 }
 
-func TestEventChannel_reset(t *testing.T) {
-	send := func(_ []byte) error { return nil }
+func TestEventChannelReset(t *testing.T) {
+	send := func([]byte) error { return nil }
+	clockMock := clock.NewMock()
 
-	eventChannel := NewEventChannel(send, maxByteSize, maxEventCount, maxTime)
+	eventChannel := NewEventChannel(send, clockMock, largeBufferSize, largeEventCount, maxTime)
 	defer eventChannel.Close()
 
-	assert.Equal(t, eventChannel.metrics.eventCount, int64(0))
-	assert.Equal(t, eventChannel.metrics.bufferSize, int64(0))
+	assert.Zero(t, eventChannel.metrics.eventCount)
+	assert.Zero(t, eventChannel.metrics.bufferSize)
 
 	eventChannel.buffer([]byte("one"))
-	eventChannel.buffer([]byte("two"))
 
-	assert.NotEqual(t, eventChannel.metrics.eventCount, int64(0))
-	assert.NotEqual(t, eventChannel.metrics.bufferSize, int64(0))
+	assert.NotZero(t, eventChannel.metrics.eventCount)
+	assert.NotZero(t, eventChannel.metrics.bufferSize)
 
 	eventChannel.reset()
 
-	assert.Equal(t, eventChannel.buff.Len(), 0)
-	assert.Equal(t, eventChannel.metrics.eventCount, int64(0))
-	assert.Equal(t, eventChannel.metrics.bufferSize, int64(0))
+	assert.Zero(t, eventChannel.buff.Len())
+	assert.Zero(t, eventChannel.metrics.eventCount)
+	assert.Zero(t, eventChannel.metrics.bufferSize)
 }
 
-func TestEventChannel_flush(t *testing.T) {
-	data := make([]byte, 0)
-	send := newSender(&data)
+func TestEventChannelFlush(t *testing.T) {
+	dataSent := make(chan []byte)
+	send := newSender(dataSent)
+	clockMock := clock.NewMock()
 
-	eventChannel := NewEventChannel(send, maxByteSize, maxEventCount, maxTime)
+	eventChannel := NewEventChannel(send, clockMock, largeBufferSize, largeEventCount, maxTime)
 	defer eventChannel.Close()
 
 	eventChannel.buffer([]byte("one"))
 	eventChannel.buffer([]byte("two"))
 	eventChannel.buffer([]byte("three"))
 	eventChannel.flush()
-	time.Sleep(10 * time.Millisecond)
 
-	assert.Equal(t, string(data), "onetwothree")
+	data, _ := readChanOrTimeout(t, dataSent)
+	assert.Equal(t, "onetwothree", readGz(data))
 }
 
-func TestEventChannel_close(t *testing.T) {
-	data := make([]byte, 0)
-	send := newSender(&data)
+func TestEventChannelClose(t *testing.T) {
+	dataSent := make(chan []byte)
+	send := newSender(dataSent)
+	clockMock := clock.NewMock()
 
-	eventChannel := NewEventChannel(send, 15000, 15000, 2*time.Hour)
+	eventChannel := NewEventChannel(send, clockMock, largeBufferSize, largeEventCount, maxTime)
 
 	eventChannel.buffer([]byte("one"))
 	eventChannel.buffer([]byte("two"))
 	eventChannel.buffer([]byte("three"))
 	eventChannel.Close()
 
-	time.Sleep(10 * time.Millisecond)
-
-	assert.Equal(t, string(data), "onetwothree")
+	data, _ := readChanOrTimeout(t, dataSent)
+	assert.Equal(t, "onetwothree", readGz(data))
 }
 
-func TestEventChannel_Push(t *testing.T) {
-	data := make([]byte, 0)
-	send := newSender(&data)
+func TestEventChannelPush(t *testing.T) {
+	dataSent := make(chan []byte)
+	send := newSender(dataSent)
+	clockMock := clock.NewMock()
 
-	eventChannel := NewEventChannel(send, 15000, 5, 5*time.Millisecond)
+	eventChannel := NewEventChannel(send, clockMock, largeBufferSize, largeEventCount, 1*time.Second)
 	defer eventChannel.Close()
 
-	eventChannel.Push([]byte("one"))
-	eventChannel.Push([]byte("two"))
-	eventChannel.Push([]byte("three"))
-	eventChannel.Push([]byte("four"))
-	eventChannel.Push([]byte("five"))
-	eventChannel.Push([]byte("six"))
-	eventChannel.Push([]byte("seven"))
-
-	time.Sleep(10 * time.Millisecond)
-
-	assert.Equal(t, string(data), "onetwothreefourfivesixseven")
-
-}
-
-func TestEventChannel_OutputFormat(t *testing.T) {
-
-	toGzip := func(payload string) []byte {
-		var buf bytes.Buffer
-		zw := gzip.NewWriter(&buf)
-
-		if _, err := zw.Write([]byte(payload)); err != nil {
-			assert.Fail(t, err.Error())
-		}
-
-		if err := zw.Close(); err != nil {
-			assert.Fail(t, err.Error())
-		}
-		return buf.Bytes()
-	}
-
-	data := make([]byte, 0)
-	send := func(payload []byte) error {
-		data = append(data, payload...)
-		return nil
-	}
-
-	eventChannel := NewEventChannel(send, 15000, 10, 2*time.Minute)
-
-	eventChannel.Push([]byte("one"))
-	time.Sleep(1 * time.Millisecond)
-
-	eventChannel.flush()
-
-	eventChannel.Push([]byte("two"))
-	time.Sleep(1 * time.Millisecond)
-
-	eventChannel.Push([]byte("three"))
-	time.Sleep(1 * time.Millisecond)
-
-	eventChannel.Close()
-
-	time.Sleep(1 * time.Millisecond)
+	eventChannel.Push([]byte("1"))
+	eventChannel.Push([]byte("2"))
+	eventChannel.Push([]byte("3"))
 
-	expected := append(toGzip("one"), toGzip("twothree")...)
+	clockMock.Add(1 * time.Second) // trigger event timer
 
-	assert.Equal(t, expected, data)
+	data, _ := readChanOrTimeout(t, dataSent)
+	assert.ElementsMatch(t, []byte{'1', '2', '3'}, []byte(readGz(data)))
 }
diff --git a/analytics/pubstack/eventchannel/sender_test.go b/analytics/pubstack/eventchannel/sender_test.go
index 1185435e..2699ba0c 100644
--- a/analytics/pubstack/eventchannel/sender_test.go
+++ b/analytics/pubstack/eventchannel/sender_test.go
@@ -22,8 +22,8 @@ func TestBuildEndpointSender(t *testing.T) {
 	sender := BuildEndpointSender(server.Client(), server.URL, "module")
 	err := sender([]byte("message"))
 
-	assert.Equal(t, requestBody, []byte("message"))
-	assert.Nil(t, err)
+	assert.Equal(t, []byte("message"), requestBody)
+	assert.NoError(t, err)
 }
 
 func TestBuildEndpointSender_Error(t *testing.T) {
@@ -36,5 +36,5 @@ func TestBuildEndpointSender_Error(t *testing.T) {
 	sender := BuildEndpointSender(server.Client(), server.URL, "module")
 	err := sender([]byte("message"))
 
-	assert.NotNil(t, err)
+	assert.Error(t, err)
 }
diff --git a/analytics/pubstack/helpers/json_test.go b/analytics/pubstack/helpers/json_test.go
index 9c33407d..6bd96b88 100644
--- a/analytics/pubstack/helpers/json_test.go
+++ b/analytics/pubstack/helpers/json_test.go
@@ -6,25 +6,25 @@ import (
 
 	"github.com/mxmCherry/openrtb/v15/openrtb2"
 	"github.com/prebid/prebid-server/analytics"
+	"github.com/stretchr/testify/assert"
 )
 
 func TestJsonifyAuctionObject(t *testing.T) {
 	ao := &analytics.AuctionObject{
 		Status: http.StatusOK,
 	}
-	if _, err := JsonifyAuctionObject(ao, "scopeId"); err != nil {
-		t.Fail()
-	}
 
+	_, err := JsonifyAuctionObject(ao, "scopeId")
+	assert.NoError(t, err)
 }
 
 func TestJsonifyVideoObject(t *testing.T) {
 	vo := &analytics.VideoObject{
 		Status: http.StatusOK,
 	}
-	if _, err := JsonifyVideoObject(vo, "scopeId"); err != nil {
-		t.Fail()
-	}
+
+	_, err := JsonifyVideoObject(vo, "scopeId")
+	assert.NoError(t, err)
 }
 
 func TestJsonifyCookieSync(t *testing.T) {
@@ -32,9 +32,9 @@ func TestJsonifyCookieSync(t *testing.T) {
 		Status:       http.StatusOK,
 		BidderStatus: []*analytics.CookieSyncBidder{},
 	}
-	if _, err := JsonifyCookieSync(cso, "scopeId"); err != nil {
-		t.Fail()
-	}
+
+	_, err := JsonifyCookieSync(cso, "scopeId")
+	assert.NoError(t, err)
 }
 
 func TestJsonifySetUIDObject(t *testing.T) {
@@ -43,9 +43,9 @@ func TestJsonifySetUIDObject(t *testing.T) {
 		Bidder: "any-bidder",
 		UID:    "uid string",
 	}
-	if _, err := JsonifySetUIDObject(so, "scopeId"); err != nil {
-		t.Fail()
-	}
+
+	_, err := JsonifySetUIDObject(so, "scopeId")
+	assert.NoError(t, err)
 }
 
 func TestJsonifyAmpObject(t *testing.T) {
@@ -55,7 +55,7 @@ func TestJsonifyAmpObject(t *testing.T) {
 		AuctionResponse:    &openrtb2.BidResponse{},
 		AmpTargetingValues: map[string]string{},
 	}
-	if _, err := JsonifyAmpObject(ao, "scopeId"); err != nil {
-		t.Fail()
-	}
+
+	_, err := JsonifyAmpObject(ao, "scopeId")
+	assert.NoError(t, err)
 }
diff --git a/analytics/pubstack/pubstack_module_test.go b/analytics/pubstack/pubstack_module_test.go
index 0e0b3634..67598360 100644
--- a/analytics/pubstack/pubstack_module_test.go
+++ b/analytics/pubstack/pubstack_module_test.go
@@ -1,17 +1,18 @@
 package pubstack
 
 import (
-	"encoding/json"
 	"net/http"
 	"net/http/httptest"
+	"os"
 	"testing"
 	"time"
 
+	"github.com/benbjohnson/clock"
 	"github.com/prebid/prebid-server/analytics"
 	"github.com/stretchr/testify/assert"
 )
 
-func TestPubstackModuleErrors(t *testing.T) {
+func TestNewModuleErrors(t *testing.T) {
 	tests := []struct {
 		description  string
 		refreshDelay string
@@ -39,12 +40,12 @@ func TestPubstackModuleErrors(t *testing.T) {
 	}
 
 	for _, tt := range tests {
-		_, err := NewPubstackModule(&http.Client{}, "scope", "http://example.com", tt.refreshDelay, 100, tt.maxByteSize, tt.maxTime)
-		assert.NotNil(t, err, tt.description)
+		_, err := NewModule(&http.Client{}, "scope", "http://example.com", tt.refreshDelay, 100, tt.maxByteSize, tt.maxTime, clock.NewMock())
+		assert.Error(t, err, tt.description)
 	}
 }
 
-func TestPubstackModuleSuccess(t *testing.T) {
+func TestNewModuleSuccess(t *testing.T) {
 	tests := []struct {
 		description string
 		feature     string
@@ -88,94 +89,100 @@ func TestPubstackModuleSuccess(t *testing.T) {
 	}
 
 	for _, tt := range tests {
-		// original config is loaded when the module is created
-		// the feature is disabled so no events should be sent
+		// original config with the feature disabled so no events should be sent
 		origConfig := &Configuration{
 			Features: map[string]bool{
 				tt.feature: false,
 			},
 		}
-		// updated config is hot-reloaded after some time passes
-		// the feature is enabled so events should be sent
+
+		// updated config with the feature enabled so events should be sent
 		updatedConfig := &Configuration{
 			Features: map[string]bool{
 				tt.feature: true,
 			},
 		}
 
-		// create server with root endpoint that returns the current config
-		// add an intake endpoint that PBS hits when events are sent
-		rootCount := 0
+		// create server with an intake endpoint that PBS hits when events are sent
 		mux := http.NewServeMux()
-		mux.HandleFunc("/", func(res http.ResponseWriter, req *http.Request) {
-			rootCount++
-			defer req.Body.Close()
-
-			if rootCount > 1 {
-				if data, err := json.Marshal(updatedConfig); err != nil {
-					res.WriteHeader(http.StatusBadRequest)
-				} else {
-					res.Write(data)
-				}
-			} else {
-				if data, err := json.Marshal(origConfig); err != nil {
-					res.WriteHeader(http.StatusBadRequest)
-				} else {
-					res.Write(data)
-				}
-			}
-		})
-
-		intakeChannel := make(chan int) // using a channel rather than examining the count directly to avoid race
+		intakeChannel := make(chan int)
 		mux.HandleFunc("/intake/"+tt.feature+"/", func(res http.ResponseWriter, req *http.Request) {
 			intakeChannel <- 1
 		})
 		server := httptest.NewServer(mux)
 		client := server.Client()
 
-		// set the server url on each of the configs
+		// set the event server url on each of the configs
 		origConfig.Endpoint = server.URL
 		updatedConfig.Endpoint = server.URL
 
-		// instantiate module with 25ms config refresh rate
-		module, err := NewPubstackModule(client, "scope", server.URL, "15ms", 100, "1B", "10ms")
-		assert.Nil(t, err, tt.description)
-
-		// allow time for the module to load the original config
-		time.Sleep(10 * time.Millisecond)
+		// instantiate module with a manual config update task
+		clockMock := clock.NewMock()
+		configTask := fakeConfigUpdateTask{}
+		module, err := NewModuleWithConfigTask(client, "scope", server.URL, 100, "1B", "1s", &configTask, clockMock)
+		assert.NoError(t, err, tt.description)
 
 		pubstack, _ := module.(*PubstackModule)
-		// attempt to log but no event channel was created because the feature is disabled in the original config
-		tt.logObject(pubstack)
-
-		// verify no event was received over a 10ms period
-		assertChanNone(t, intakeChannel, tt.description)
-
-		// allow time for the server to start serving the updated config
-		time.Sleep(10 * time.Millisecond)
-
-		// attempt to log; the event channel should have been created because the feature is enabled in updated config
-		tt.logObject(pubstack)
 
-		// verify an event was received within 10ms
-		assertChanOne(t, intakeChannel, tt.description)
+		// original config
+		configTask.Push(origConfig)
+		time.Sleep(10 * time.Millisecond)                            // allow time for the module to load the original config
+		tt.logObject(pubstack)                                       // attempt to log; no event channel created because feature is disabled in original config
+		clockMock.Add(1 * time.Second)                               // trigger event channel sending
+		assertChanNone(t, intakeChannel, tt.description+":original") // verify no event was received
+
+		// updated config
+		configTask.Push(updatedConfig)
+		time.Sleep(10 * time.Millisecond)                          // allow time for the server to start serving the updated config
+		tt.logObject(pubstack)                                     // attempt to log; event channel should be created because feature is enabled in updated config
+		clockMock.Add(1 * time.Second)                             // trigger event channel sending
+		assertChanOne(t, intakeChannel, tt.description+":updated") // verify an event was received
+
+		// no config change
+		configTask.Push(updatedConfig)
+		time.Sleep(10 * time.Millisecond)                            // allow time for the server to determine no config change
+		tt.logObject(pubstack)                                       // attempt to log; event channel should still be created from loading updated config
+		clockMock.Add(1 * time.Second)                               // trigger event channel sending
+		assertChanOne(t, intakeChannel, tt.description+":no_change") // verify an event was received
+
+		// shutdown
+		pubstack.sigTermCh <- os.Kill                                // simulate os shutdown signal
+		time.Sleep(10 * time.Millisecond)                            // allow time for the server to switch to shutdown generated config
+		tt.logObject(pubstack)                                       // attempt to log; event channel should be closed from the os kill signal
+		clockMock.Add(1 * time.Second)                               // trigger event channel sending
+		assertChanNone(t, intakeChannel, tt.description+":shutdown") // verify no event was received
 	}
 }
 
 func assertChanNone(t *testing.T, c <-chan int, msgAndArgs ...interface{}) bool {
+	t.Helper()
 	select {
 	case <-c:
 		return assert.Fail(t, "Should NOT receive an event, but did", msgAndArgs...)
-	case <-time.After(10 * time.Millisecond):
+	case <-time.After(100 * time.Millisecond):
 		return true
 	}
 }
 
 func assertChanOne(t *testing.T, c <-chan int, msgAndArgs ...interface{}) bool {
+	t.Helper()
 	select {
 	case <-c:
 		return true
-	case <-time.After(10 * time.Millisecond):
+	case <-time.After(200 * time.Millisecond):
 		return assert.Fail(t, "Should receive an event, but did NOT", msgAndArgs...)
 	}
 }
+
+type fakeConfigUpdateTask struct {
+	configChan chan *Configuration
+}
+
+func (f *fakeConfigUpdateTask) Start(stop <-chan struct{}) <-chan *Configuration {
+	f.configChan = make(chan *Configuration)
+	return f.configChan
+}
+
+func (f *fakeConfigUpdateTask) Push(c *Configuration) {
+	f.configChan <- c
+}
diff --git a/config/config_test.go b/config/config_test.go
index 5a7d3899..ef57d7c3 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -145,6 +145,7 @@ func TestDefaults(t *testing.T) {
 	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)
+	cmpNils(t, "host_schain_node", cfg.HostSChainNode)
 
 	//Assert purpose VendorExceptionMap hash tables were built correctly
 	expectedTCF2 := TCF2{
@@ -376,6 +377,11 @@ request_validation:
     ipv4_private_networks: ["1.1.1.0/24"]
     ipv6_private_networks: ["1111::/16", "2222::/16"]
 generate_bid_id: true
+host_schain_node:
+    asi: "pbshostcompany.com"
+    sid: "00001"
+    rid: "BidRequest"
+    hp: 1
 `)
 
 var adapterExtraInfoConfig = []byte(`
@@ -463,6 +469,10 @@ func TestFullConfig(t *testing.T) {
 	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", cfg.HostSChainNode.ASI, "pbshostcompany.com")
+	cmpStrings(t, "host_schain_node", cfg.HostSChainNode.SID, "00001")
+	cmpStrings(t, "host_schain_node", cfg.HostSChainNode.RID, "BidRequest")
+	cmpInts(t, "host_schain_node", cfg.HostSChainNode.HP, 1)
 
 	//Assert the NonStandardPublishers was correctly unmarshalled
 	assert.Equal(t, []string{"pub1", "pub2"}, cfg.GDPR.NonStandardPublishers, "gdpr.non_standard_publishers")
diff --git a/exchange/exchange_test.go b/exchange/exchange_test.go
index 9d44b53e..0cd1c6df 100644
--- a/exchange/exchange_test.go
+++ b/exchange/exchange_test.go
@@ -2207,7 +2207,7 @@ func runSpec(t *testing.T, filename string, spec *exchangeSpec) {
 	if spec.BidIDGenerator != nil {
 		*bidIdGenerator = *spec.BidIDGenerator
 	}
-	ex := newExchangeForTests(t, filename, spec.OutgoingRequests, aliases, privacyConfig, bidIdGenerator)
+	ex := newExchangeForTests(t, filename, spec.OutgoingRequests, aliases, privacyConfig, bidIdGenerator, spec.HostSChainFlag)
 	biddersInAuction := findBiddersInAuction(t, filename, &spec.IncomingRequest.OrtbRequest)
 	debugLog := &DebugLog{}
 	if spec.DebugLog != nil {
@@ -2311,7 +2311,7 @@ func extractResponseTimes(t *testing.T, context string, bid *openrtb2.BidRespons
 	}
 }
 
-func newExchangeForTests(t *testing.T, filename string, expectations map[string]*bidderSpec, aliases map[string]string, privacyConfig config.Privacy, bidIDGenerator BidIDGenerator) Exchange {
+func newExchangeForTests(t *testing.T, filename string, expectations map[string]*bidderSpec, aliases map[string]string, privacyConfig config.Privacy, bidIDGenerator BidIDGenerator, hostSChainFlag bool) Exchange {
 	bidderAdapters := make(map[openrtb_ext.BidderName]AdaptedBidder, len(expectations))
 	bidderInfos := make(config.BidderInfos, len(expectations))
 	for _, bidderName := range openrtb_ext.CoreBidderNames() {
@@ -2368,6 +2368,15 @@ func newExchangeForTests(t *testing.T, filename string, expectations map[string]
 		gdprDefaultValue = gdpr.SignalNo
 	}
 
+	hostSChainNode := &openrtb_ext.ExtRequestPrebidSChainSChainNode{}
+	if hostSChainFlag {
+		hostSChainNode = &openrtb_ext.ExtRequestPrebidSChainSChainNode{
+			ASI: "pbshostcompany.com", SID: "00001", RID: "BidRequest", HP: 1,
+		}
+	} else {
+		hostSChainNode = nil
+	}
+
 	return &exchange{
 		adapterMap:        bidderAdapters,
 		me:                metricsConf.NewMetricsEngine(&config.Configuration{}, openrtb_ext.CoreBidderNames(), nil),
@@ -2383,6 +2392,7 @@ func newExchangeForTests(t *testing.T, filename string, expectations map[string]
 		bidderToSyncerKey: bidderToSyncerKey,
 		externalURL:       "http://localhost",
 		bidIDGenerator:    bidIDGenerator,
+		hostSChainNode:    hostSChainNode,
 	}
 }
 
@@ -4076,6 +4086,7 @@ type exchangeSpec struct {
 	StartTime         int64                  `json:"start_time_ms,omitempty"`
 	BidIDGenerator    *mockBidIDGenerator    `json:"bidIDGenerator,omitempty"`
 	RequestType       *metrics.RequestType   `json:"requestType,omitempty"`
+	HostSChainFlag    bool                   `json:"host_schain_flag,omitempty"`
 }
 
 type exchangeRequest struct {
diff --git a/exchange/exchangetest/schain-host-and-request.json b/exchange/exchangetest/schain-host-and-request.json
new file mode 100644
index 00000000..7a071124
--- /dev/null
+++ b/exchange/exchangetest/schain-host-and-request.json
@@ -0,0 +1,94 @@
+{
+    "host_schain_flag": true,
+    "incomingRequest": {
+        "ortbRequest": {
+            "id": "some-request-id",
+            "site": {
+                "page": "test.somepage.com"
+            },
+            "imp": [{
+                "id": "my-imp-id",
+                "video": {
+                    "mimes": ["video/mp4"]
+                },
+                "ext": {
+                    "appnexus": {
+                        "placementId": 1
+                    }
+                }
+            }],
+            "ext": {
+                "prebid": {
+                    "bidadjustmentfactors": null,
+                    "schains": [{
+                        "bidders": ["appnexus"],
+                        "schain": {
+                            "complete": 1,
+                            "nodes": [{
+                                "asi": "whatever.com",
+                                "sid": "1234",
+                                "rid": "123-456-7890",
+                                "hp": 1
+                            }],
+                            "ver": "2.0"
+                        }
+                    }],
+                    "debug": true,
+                    "nosale": ["appnexus"]
+                }
+            }
+        }
+    },
+    "outgoingRequests": {
+        "appnexus": {
+            "expectRequest": {
+                "ortbRequest": {
+                    "id": "some-request-id",
+                    "site": {
+                        "page": "test.somepage.com"
+                    },
+                    "imp": [{
+                        "id": "my-imp-id",
+                        "video": {
+                            "mimes": ["video/mp4"]
+                        },
+                        "ext": {
+                            "bidder": {
+                                "placementId": 1
+                            }
+                        }
+                    }],
+                    "ext": {
+                        "prebid": {
+                            "debug": true,
+                            "nosale": ["appnexus"]
+                        }
+                    },
+                    "source": {
+                        "ext": {
+                            "schain": {
+                                "complete": 1,
+                                "nodes": [
+                                  {
+                                    "asi": "whatever.com",
+                                    "sid": "1234",
+                                    "rid": "123-456-7890",
+                                    "hp": 1
+                                  },
+                                  {
+                                    "asi": "pbshostcompany.com",
+                                    "sid": "00001",
+                                    "rid": "BidRequest",
+                                    "hp": 1
+                                  }
+                                ],
+                                "ver": "2.0"
+                              }
+                        }
+                    }
+                },
+                "bidAdjustment": 1.0
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/exchange/exchangetest/schain-host-only.json b/exchange/exchangetest/schain-host-only.json
new file mode 100644
index 00000000..1750a8b4
--- /dev/null
+++ b/exchange/exchangetest/schain-host-only.json
@@ -0,0 +1,74 @@
+{
+    "host_schain_flag": true,
+    "incomingRequest": {
+        "ortbRequest": {
+            "id": "some-request-id",
+            "site": {
+                "page": "test.somepage.com"
+            },
+            "imp": [{
+                "id": "my-imp-id",
+                "video": {
+                    "mimes": ["video/mp4"]
+                },
+                "ext": {
+                    "appnexus": {
+                        "placementId": 1
+                    }
+                }
+            }],
+            "ext": {
+                "prebid": {
+                    "debug": true,
+                    "nosale": ["appnexus"]
+                }
+            }
+        }
+    },
+    "outgoingRequests": {
+        "appnexus": {
+            "expectRequest": {
+                "ortbRequest": {
+                    "id": "some-request-id",
+                    "site": {
+                        "page": "test.somepage.com"
+                    },
+                    "imp": [{
+                        "id": "my-imp-id",
+                        "video": {
+                            "mimes": ["video/mp4"]
+                        },
+                        "ext": {
+                            "bidder": {
+                                "placementId": 1
+                            }
+                        }
+                    }],
+                    "ext": {
+                        "prebid": {
+                            "debug": true,
+                            "nosale": ["appnexus"]
+                        }
+                    },
+                    "source": {
+                        "ext": {
+                            "schain": {
+                                "complete": 0,
+                                "nodes": [
+                                  {
+                                    "asi": "pbshostcompany.com",
+                                    "sid": "00001",
+                                    "rid": "BidRequest",
+                                    "hp": 1
+                                  }
+                                ],
+                                "ver": "1.0"
+                              }
+                        }
+                    }
+                },
+                "bidAdjustment": 1.0
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/exchange/utils_test.go b/exchange/utils_test.go
index dd51b3b3..16ac9ef1 100644
--- a/exchange/utils_test.go
+++ b/exchange/utils_test.go
@@ -354,6 +354,7 @@ func TestCreateSanitizedImpExt(t *testing.T) {
 				"context":   json.RawMessage(`"anyContext"`),
 				"skadn":     json.RawMessage(`"anySKAdNetwork"`),
 				"gpid":      json.RawMessage(`"anyGPID"`),
+				"tid":       json.RawMessage(`"anyTID"`),
 			},
 			givenImpExtPrebid: map[string]json.RawMessage{
 				"bidder":    json.RawMessage(`"anyBidder"`),
@@ -365,6 +366,7 @@ func TestCreateSanitizedImpExt(t *testing.T) {
 				"context": json.RawMessage(`"anyContext"`),
 				"skadn":   json.RawMessage(`"anySKAdNetwork"`),
 				"gpid":    json.RawMessage(`"anyGPID"`),
+				"tid":     json.RawMessage(`"anyTID"`),
 			},
 			expectedError: "",
 		},
@@ -434,7 +436,7 @@ func TestExtractBidderExts(t *testing.T) {
 		},
 		{
 			description:              "Special Names Ignored - imp.ext.BIDDER",
-			givenImpExt:              map[string]json.RawMessage{"prebid": json.RawMessage(`{"prebid":"value1"}}`), "context": json.RawMessage(`{"firstPartyData":"value2"}}`), "skadn": json.RawMessage(`{"skAdNetwork":"value3"}}`), "gpid": json.RawMessage(`{"gpid":"value4"}}`)},
+			givenImpExt:              map[string]json.RawMessage{"prebid": json.RawMessage(`{"prebid":"value1"}}`), "context": json.RawMessage(`{"firstPartyData":"value2"}}`), "skadn": json.RawMessage(`{"skAdNetwork":"value3"}}`), "gpid": json.RawMessage(`{"gpid":"value4"}}`), "tid": json.RawMessage("6e927474-4c83-40ac-8180-16858314b7c5")},
 			givenImpExtPrebidBidders: map[string]json.RawMessage{},
 			expected:                 map[string]json.RawMessage{},
 		},
@@ -524,7 +526,7 @@ func TestCleanOpenRTBRequests(t *testing.T) {
 			cfg: gdpr.NewTCF2Config(config.TCF2{}, config.AccountGDPR{}),
 		}.Builder
 
-		bidderRequests, _, err := cleanOpenRTBRequests(context.Background(), test.req, nil, bidderToSyncerKey, &metricsMock, gdpr.SignalNo, privacyConfig, gdprPermsBuilder, tcf2ConfigBuilder)
+		bidderRequests, _, err := cleanOpenRTBRequests(context.Background(), test.req, nil, bidderToSyncerKey, &metricsMock, gdpr.SignalNo, privacyConfig, gdprPermsBuilder, tcf2ConfigBuilder, nil)
 		if test.hasError {
 			assert.NotNil(t, err, "Error shouldn't be nil")
 		} else {
@@ -591,7 +593,7 @@ func TestCleanOpenRTBRequestsWithFPD(t *testing.T) {
 			cfg: gdpr.NewTCF2Config(config.TCF2{}, config.AccountGDPR{}),
 		}.Builder
 
-		bidderRequests, _, err := cleanOpenRTBRequests(context.Background(), test.req, nil, bidderToSyncerKey, &metricsMock, gdpr.SignalNo, config.Privacy{}, gdprPermissionsBuilder, tcf2ConfigBuilder)
+		bidderRequests, _, err := cleanOpenRTBRequests(context.Background(), test.req, nil, bidderToSyncerKey, &metricsMock, gdpr.SignalNo, config.Privacy{}, gdprPermissionsBuilder, tcf2ConfigBuilder, nil)
 		assert.Empty(t, err, "No errors should be returned")
 		for _, bidderRequest := range bidderRequests {
 			bidderName := bidderRequest.BidderName
@@ -869,7 +871,8 @@ func TestCleanOpenRTBRequestsWithBidResponses(t *testing.T) {
 			gdpr.SignalNo,
 			config.Privacy{},
 			gdprPermissionsBuilder,
-			tcf2ConfigBuilder)
+			tcf2ConfigBuilder,
+			nil)
 		assert.Empty(t, err, "No errors should be returned")
 		assert.Len(t, actualBidderRequests, len(test.expectedBidderRequests), "result len doesn't match for testCase %s", test.description)
 		for _, actualBidderRequest := range actualBidderRequests {
@@ -1040,7 +1043,8 @@ func TestCleanOpenRTBRequestsCCPA(t *testing.T) {
 			gdpr.SignalNo,
 			privacyConfig,
 			gdprPermissionsBuilder,
-			tcf2ConfigBuilder)
+			tcf2ConfigBuilder,
+			nil)
 		result := bidderRequests[0]
 
 		assert.Nil(t, errs)
@@ -1110,7 +1114,7 @@ func TestCleanOpenRTBRequestsCCPAErrors(t *testing.T) {
 		bidderToSyncerKey := map[string]string{}
 		metrics := metrics.MetricsEngineMock{}
 
-		_, _, errs := cleanOpenRTBRequests(context.Background(), auctionReq, &reqExtStruct, bidderToSyncerKey, &metrics, gdpr.SignalNo, privacyConfig, gdprPermissionsBuilder, tcf2ConfigBuilder)
+		_, _, errs := cleanOpenRTBRequests(context.Background(), auctionReq, &reqExtStruct, bidderToSyncerKey, &metrics, gdpr.SignalNo, privacyConfig, gdprPermissionsBuilder, tcf2ConfigBuilder, nil)
 
 		assert.ElementsMatch(t, []error{test.expectError}, errs, test.description)
 	}
@@ -1162,7 +1166,7 @@ func TestCleanOpenRTBRequestsCOPPA(t *testing.T) {
 		bidderToSyncerKey := map[string]string{}
 		metrics := metrics.MetricsEngineMock{}
 
-		bidderRequests, privacyLabels, errs := cleanOpenRTBRequests(context.Background(), auctionReq, nil, bidderToSyncerKey, &metrics, gdpr.SignalNo, config.Privacy{}, gdprPermissionsBuilder, tcf2ConfigBuilder)
+		bidderRequests, privacyLabels, errs := cleanOpenRTBRequests(context.Background(), auctionReq, nil, bidderToSyncerKey, &metrics, gdpr.SignalNo, config.Privacy{}, gdprPermissionsBuilder, tcf2ConfigBuilder, nil)
 		result := bidderRequests[0]
 
 		assert.Nil(t, errs)
@@ -1257,7 +1261,7 @@ func TestCleanOpenRTBRequestsSChain(t *testing.T) {
 
 		bidderToSyncerKey := map[string]string{}
 		metrics := metrics.MetricsEngineMock{}
-		bidderRequests, _, errs := cleanOpenRTBRequests(context.Background(), auctionReq, extRequest, bidderToSyncerKey, &metrics, gdpr.SignalNo, config.Privacy{}, gdprPermissionsBuilder, tcf2ConfigBuilder)
+		bidderRequests, _, errs := cleanOpenRTBRequests(context.Background(), auctionReq, extRequest, bidderToSyncerKey, &metrics, gdpr.SignalNo, config.Privacy{}, gdprPermissionsBuilder, tcf2ConfigBuilder, nil)
 		if test.hasError == true {
 			assert.NotNil(t, errs)
 			assert.Len(t, bidderRequests, 0)
@@ -1324,7 +1328,7 @@ func TestCleanOpenRTBRequestsBidderParams(t *testing.T) {
 		bidderToSyncerKey := map[string]string{}
 		metrics := metrics.MetricsEngineMock{}
 
-		bidderRequests, _, errs := cleanOpenRTBRequests(context.Background(), auctionReq, extRequest, bidderToSyncerKey, &metrics, gdpr.SignalNo, config.Privacy{}, gdprPermissionsBuilder, tcf2ConfigBuilder)
+		bidderRequests, _, errs := cleanOpenRTBRequests(context.Background(), auctionReq, extRequest, bidderToSyncerKey, &metrics, gdpr.SignalNo, config.Privacy{}, gdprPermissionsBuilder, tcf2ConfigBuilder, nil)
 		if test.hasError == true {
 			assert.NotNil(t, errs)
 			assert.Len(t, bidderRequests, 0)
@@ -2014,7 +2018,7 @@ func TestCleanOpenRTBRequestsLMT(t *testing.T) {
 
 		bidderToSyncerKey := map[string]string{}
 		metrics := metrics.MetricsEngineMock{}
-		results, privacyLabels, errs := cleanOpenRTBRequests(context.Background(), auctionReq, nil, bidderToSyncerKey, &metrics, gdpr.SignalNo, privacyConfig, gdprPermissionsBuilder, tcf2ConfigBuilder)
+		results, privacyLabels, errs := cleanOpenRTBRequests(context.Background(), auctionReq, nil, bidderToSyncerKey, &metrics, gdpr.SignalNo, privacyConfig, gdprPermissionsBuilder, tcf2ConfigBuilder, nil)
 		result := results[0]
 
 		assert.Nil(t, errs)
@@ -2251,7 +2255,8 @@ func TestCleanOpenRTBRequestsGDPR(t *testing.T) {
 			gdprDefaultValue,
 			privacyConfig,
 			gdprPermissionsBuilder,
-			tcf2ConfigBuilder)
+			tcf2ConfigBuilder,
+			nil)
 		result := results[0]
 
 		if test.expectError {
@@ -2356,6 +2361,7 @@ func TestCleanOpenRTBRequestsGDPRBlockBidRequest(t *testing.T) {
 			privacyConfig,
 			gdprPermissionsBuilder,
 			tcf2ConfigBuilder,
+			nil,
 		)
 
 		// extract bidder name from each request in the results
@@ -2940,7 +2946,7 @@ func TestCleanOpenRTBRequestsSChainMultipleBidders(t *testing.T) {
 
 	bidderToSyncerKey := map[string]string{}
 	metrics := metrics.MetricsEngineMock{}
-	bidderRequests, _, errs := cleanOpenRTBRequests(context.Background(), auctionReq, extRequest, bidderToSyncerKey, &metrics, gdpr.SignalNo, config.Privacy{}, gdprPermissionsBuilder, tcf2ConfigBuilder)
+	bidderRequests, _, errs := cleanOpenRTBRequests(context.Background(), auctionReq, extRequest, bidderToSyncerKey, &metrics, gdpr.SignalNo, config.Privacy{}, gdprPermissionsBuilder, tcf2ConfigBuilder, nil)
 
 	assert.Nil(t, errs)
 	assert.Len(t, bidderRequests, 2, "Bid request count is not 2")
diff --git a/openrtb_ext/bidders_test.go b/openrtb_ext/bidders_test.go
index 056223e2..f95d4a36 100644
--- a/openrtb_ext/bidders_test.go
+++ b/openrtb_ext/bidders_test.go
@@ -113,6 +113,10 @@ func TestIsBidderNameReserved(t *testing.T) {
 		{"skadn", true},
 		{"skADN", true},
 		{"SKADN", true},
+		{"tid", true},
+		{"TId", true},
+		{"Tid", true},
+		{"TiD", true},
 		{"notreserved", false},
 	}
 
diff --git a/schain/schainwriter_test.go b/schain/schainwriter_test.go
index 92dc550d..26a3fd52 100644
--- a/schain/schainwriter_test.go
+++ b/schain/schainwriter_test.go
@@ -15,21 +15,25 @@ func TestSChainWriter(t *testing.T) {
 	const seller2SChain string = `"schain":{"complete":2,"nodes":[{"asi":"directseller2.com","sid":"00002","rid":"BidRequest2","hp":2}],"ver":"2.0"}`
 	const seller3SChain string = `"schain":{"complete":3,"nodes":[{"asi":"directseller3.com","sid":"00003","rid":"BidRequest3","hp":3}],"ver":"3.0"}`
 	const sellerWildCardSChain string = `"schain":{"complete":1,"nodes":[{"asi":"wildcard1.com","sid":"wildcard1","rid":"WildcardReq1","hp":1}],"ver":"1.0"}`
+	const hostNode string = `{"asi":"pbshostcompany.com","sid":"00001","rid":"BidRequest","hp":1}`
+	const seller1Node string = `{"asi":"directseller1.com","sid":"00001","rid":"BidRequest1","hp":1}`
 
 	tests := []struct {
-		description string
-		giveRequest openrtb2.BidRequest
-		giveBidder  string
-		wantRequest openrtb2.BidRequest
-		wantError   bool
+		description    string
+		giveRequest    openrtb2.BidRequest
+		giveBidder     string
+		giveHostSChain *openrtb_ext.ExtRequestPrebidSChainSChainNode
+		wantRequest    openrtb2.BidRequest
+		wantError      bool
 	}{
 		{
-			description: "nil source and nil ext.prebid.schains",
+			description: "nil source, nil ext.prebid.schains and empty host schain",
 			giveRequest: openrtb2.BidRequest{
 				Ext:    nil,
 				Source: nil,
 			},
-			giveBidder: "appnexus",
+			giveBidder:     "appnexus",
+			giveHostSChain: nil,
 			wantRequest: openrtb2.BidRequest{
 				Ext:    nil,
 				Source: nil,
@@ -152,6 +156,40 @@ func TestSChainWriter(t *testing.T) {
 			},
 			wantError: true,
 		},
+		{
+			description: "Schain in request, host schain defined, source.ext for bidder request should update with appended host schain",
+			giveRequest: openrtb2.BidRequest{
+				Ext:    json.RawMessage(`{"prebid":{"schains":[{"bidders":["testbidder"],"schain":{"complete":1,"nodes":[` + seller1Node + `],"ver":"1.0"}}]}}`),
+				Source: nil,
+			},
+			giveBidder: "testbidder",
+			giveHostSChain: &openrtb_ext.ExtRequestPrebidSChainSChainNode{
+				ASI: "pbshostcompany.com", SID: "00001", RID: "BidRequest", HP: 1,
+			},
+			wantRequest: openrtb2.BidRequest{
+				Ext: json.RawMessage(`{"prebid":{"schains":[{"bidders":["testbidder"],"schain":{"complete":1,"nodes":[` + seller1Node + `],"ver":"1.0"}}]}}`),
+				Source: &openrtb2.Source{
+					Ext: json.RawMessage(`{"schain":{"complete":1,"nodes":[` + seller1Node + `,` + hostNode + `],"ver":"1.0"}}`),
+				},
+			},
+		},
+		{
+			description: "No Schain in request, host schain defined, source.ext for bidder request should have just the host schain",
+			giveRequest: openrtb2.BidRequest{
+				Ext:    nil,
+				Source: nil,
+			},
+			giveBidder: "testbidder",
+			giveHostSChain: &openrtb_ext.ExtRequestPrebidSChainSChainNode{
+				ASI: "pbshostcompany.com", SID: "00001", RID: "BidRequest", HP: 1,
+			},
+			wantRequest: openrtb2.BidRequest{
+				Ext: nil,
+				Source: &openrtb2.Source{
+					Ext: json.RawMessage(`{"schain":{"complete":0,"nodes":[` + hostNode + `],"ver":"1.0"}}`),
+				},
+			},
+		},
 	}
 
 	for _, tt := range tests {
@@ -165,7 +203,7 @@ func TestSChainWriter(t *testing.T) {
 			}
 		}
 
-		writer, err := NewSChainWriter(reqExt)
+		writer, err := NewSChainWriter(reqExt, tt.giveHostSChain)
 
 		if tt.wantError {
 			assert.NotNil(t, err)
diff --git a/util/task/func_runner_test.go b/util/task/func_runner_test.go
new file mode 100644
index 00000000..86481e4e
--- /dev/null
+++ b/util/task/func_runner_test.go
@@ -0,0 +1,28 @@
+package task
+
+import (
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestNewTickerTaskFromFunc(t *testing.T) {
+	var runCountMutex sync.Mutex
+	runCount := 0
+
+	funcTest := func() error {
+		runCountMutex.Lock()
+		defer runCountMutex.Unlock()
+		runCount++
+		return nil
+	}
+
+	anyDuration := 1 * time.Hour // not used for this test
+	task := NewTickerTaskFromFunc(anyDuration, funcTest)
+
+	err := task.runner.Run()
+	assert.NoError(t, err)
+	assert.Equal(t, 1, runCount)
+}