Skip to content

Commit 655825c

Browse files
authored
Merge pull request #42 from robusta-dev/claude/create-bidder-demo-BDi1T
Add bidder service v2.4.1 with geo targeting, bulk bids, and budget pacing
2 parents b352481 + ff80693 commit 655825c

File tree

18 files changed

+1127
-0
lines changed

18 files changed

+1127
-0
lines changed

src/bidder/Dockerfile

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
FROM golang:1.21-alpine AS builder
2+
3+
WORKDIR /app
4+
COPY go.mod ./
5+
RUN go mod download
6+
7+
COPY . .
8+
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o bidder-service .
9+
10+
FROM alpine:3.19
11+
RUN apk --no-cache add ca-certificates tzdata
12+
WORKDIR /app
13+
COPY --from=builder /app/bidder-service .
14+
15+
EXPOSE 8080
16+
HEALTHCHECK --interval=10s --timeout=3s CMD wget -qO- http://localhost:8080/health || exit 1
17+
CMD ["./bidder-service"]

src/bidder/api/bid_handler.go

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"log"
7+
"math/rand"
8+
"net/http"
9+
"time"
10+
11+
"github.com/robusta-dev/bidder-service/cache"
12+
"github.com/robusta-dev/bidder-service/config"
13+
"github.com/robusta-dev/bidder-service/metrics"
14+
"github.com/robusta-dev/bidder-service/model"
15+
)
16+
17+
type BidHandler struct {
18+
cfg *config.Config
19+
cache *cache.Handler
20+
metrics *metrics.Collector
21+
}
22+
23+
func NewBidHandler(cfg *config.Config, ch *cache.Handler, mc *metrics.Collector) *BidHandler {
24+
return &BidHandler{
25+
cfg: cfg,
26+
cache: ch,
27+
metrics: mc,
28+
}
29+
}
30+
31+
func (bh *BidHandler) HandleBid(w http.ResponseWriter, r *http.Request) {
32+
start := time.Now()
33+
defer func() {
34+
bh.metrics.RecordLatency("bid", time.Since(start))
35+
}()
36+
37+
if r.Method != http.MethodPost {
38+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
39+
return
40+
}
41+
42+
var req model.BidRequest
43+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
44+
bh.metrics.RecordError("bid", "invalid_request")
45+
http.Error(w, "Invalid request body", http.StatusBadRequest)
46+
return
47+
}
48+
49+
if err := validateBidRequest(&req); err != nil {
50+
bh.metrics.RecordError("bid", "validation_failed")
51+
http.Error(w, err.Error(), http.StatusBadRequest)
52+
return
53+
}
54+
55+
// Check cache for campaign data
56+
cacheKey := fmt.Sprintf("campaign:%s:%s:%s", req.AdSlotID, req.UserSegment, req.GeoCountry)
57+
cachedBid, found := bh.cache.Get(cacheKey)
58+
if found {
59+
bh.metrics.RecordCacheHit("bid")
60+
resp := cachedBid.(*model.BidResponse)
61+
bh.metrics.RecordBid(resp.BidCents)
62+
w.Header().Set("Content-Type", "application/json")
63+
w.Header().Set("X-Cache", "HIT")
64+
json.NewEncoder(w).Encode(resp)
65+
return
66+
}
67+
68+
bh.metrics.RecordCacheMiss("bid")
69+
70+
// Compute bid
71+
resp := bh.computeBid(&req)
72+
73+
if resp.BidCents > 0 {
74+
bh.cache.Set(cacheKey, resp)
75+
bh.metrics.RecordBid(resp.BidCents)
76+
} else {
77+
bh.metrics.RecordNoBid()
78+
}
79+
80+
w.Header().Set("Content-Type", "application/json")
81+
w.Header().Set("X-Cache", "MISS")
82+
json.NewEncoder(w).Encode(resp)
83+
}
84+
85+
// HandleBulkBid processes multiple bid requests in a single call
86+
func (bh *BidHandler) HandleBulkBid(w http.ResponseWriter, r *http.Request) {
87+
start := time.Now()
88+
defer func() {
89+
bh.metrics.RecordLatency("bulk_bid", time.Since(start))
90+
}()
91+
92+
if r.Method != http.MethodPost {
93+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
94+
return
95+
}
96+
97+
var reqs []model.BidRequest
98+
if err := json.NewDecoder(r.Body).Decode(&reqs); err != nil {
99+
bh.metrics.RecordError("bulk_bid", "invalid_request")
100+
http.Error(w, "Invalid request body", http.StatusBadRequest)
101+
return
102+
}
103+
104+
if len(reqs) > 10 {
105+
http.Error(w, "Maximum 10 bids per bulk request", http.StatusBadRequest)
106+
return
107+
}
108+
109+
responses := make([]*model.BidResponse, 0, len(reqs))
110+
for _, req := range reqs {
111+
resp := bh.computeBid(&req)
112+
if resp.BidCents > 0 {
113+
cacheKey := fmt.Sprintf("campaign:%s:%s:%s", req.AdSlotID, req.UserSegment, req.GeoCountry)
114+
bh.cache.Set(cacheKey, resp)
115+
bh.metrics.RecordBid(resp.BidCents)
116+
} else {
117+
bh.metrics.RecordNoBid()
118+
}
119+
responses = append(responses, resp)
120+
}
121+
122+
w.Header().Set("Content-Type", "application/json")
123+
json.NewEncoder(w).Encode(responses)
124+
}
125+
126+
func (bh *BidHandler) computeBid(req *model.BidRequest) *model.BidResponse {
127+
baseBid := bh.calculateBaseBid(req)
128+
129+
// Apply geo targeting modifier
130+
if bh.cfg.GeoTargeting && req.GeoCountry != "" {
131+
baseBid = applyGeoModifier(baseBid, req.GeoCountry)
132+
}
133+
134+
// Apply device modifier
135+
baseBid = applyDeviceModifier(baseBid, req.DeviceType)
136+
137+
if baseBid < bh.cfg.MinBidFloor {
138+
return &model.BidResponse{
139+
BidID: generateBidID(),
140+
BidCents: 0,
141+
NoBid: true,
142+
}
143+
}
144+
145+
if baseBid > bh.cfg.MaxBidCents {
146+
baseBid = bh.cfg.MaxBidCents
147+
}
148+
149+
return &model.BidResponse{
150+
BidID: generateBidID(),
151+
BidCents: baseBid,
152+
AdMarkup: fmt.Sprintf("<ad campaign='%s' slot='%s' />", req.CampaignID, req.AdSlotID),
153+
CreativeID: fmt.Sprintf("cr_%s_%s", req.AdSlotID, req.UserSegment),
154+
NoBid: false,
155+
}
156+
}
157+
158+
func (bh *BidHandler) calculateBaseBid(req *model.BidRequest) int {
159+
base := 100 // 100 cents = $1.00
160+
161+
switch req.UserSegment {
162+
case "premium":
163+
base = 250
164+
case "standard":
165+
base = 150
166+
case "retarget":
167+
base = 300
168+
case "lookalike":
169+
base = 200
170+
}
171+
172+
// Apply slot size modifier
173+
switch req.AdSlotSize {
174+
case "728x90":
175+
base = int(float64(base) * 0.8)
176+
case "300x250":
177+
base = int(float64(base) * 1.2)
178+
case "160x600":
179+
base = int(float64(base) * 0.9)
180+
case "320x50":
181+
base = int(float64(base) * 0.7)
182+
case "970x250":
183+
base = int(float64(base) * 1.4)
184+
}
185+
186+
return base
187+
}
188+
189+
func applyGeoModifier(bid int, country string) int {
190+
modifiers := map[string]float64{
191+
"US": 1.0,
192+
"UK": 0.95,
193+
"DE": 0.90,
194+
"FR": 0.88,
195+
"JP": 1.10,
196+
"AU": 0.92,
197+
"CA": 0.97,
198+
"BR": 0.70,
199+
}
200+
if m, ok := modifiers[country]; ok {
201+
return int(float64(bid) * m)
202+
}
203+
return int(float64(bid) * 0.75)
204+
}
205+
206+
func applyDeviceModifier(bid int, device string) int {
207+
modifiers := map[string]float64{
208+
"mobile": 1.15,
209+
"desktop": 1.0,
210+
"tablet": 0.90,
211+
"ctv": 1.30,
212+
}
213+
if m, ok := modifiers[device]; ok {
214+
return int(float64(bid) * m)
215+
}
216+
return bid
217+
}
218+
219+
func validateBidRequest(req *model.BidRequest) error {
220+
if req.AdSlotID == "" {
221+
return fmt.Errorf("ad_slot_id is required")
222+
}
223+
if req.UserSegment == "" {
224+
return fmt.Errorf("user_segment is required")
225+
}
226+
if req.RequestID == "" {
227+
return fmt.Errorf("request_id is required")
228+
}
229+
return nil
230+
}
231+
232+
func generateBidID() string {
233+
return fmt.Sprintf("bid_%d_%d", time.Now().UnixNano(), rand.Intn(10000))
234+
}
235+
236+
func init() {
237+
log.Println("Bid handler v2.4.1 initialized")
238+
}

src/bidder/api/health_handler.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"runtime"
7+
"time"
8+
9+
"github.com/robusta-dev/bidder-service/config"
10+
)
11+
12+
type HealthHandler struct {
13+
cfg *config.Config
14+
startTime time.Time
15+
}
16+
17+
func NewHealthHandler(cfg *config.Config) *HealthHandler {
18+
return &HealthHandler{
19+
cfg: cfg,
20+
startTime: time.Now(),
21+
}
22+
}
23+
24+
func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) {
25+
w.Header().Set("Content-Type", "application/json")
26+
json.NewEncoder(w).Encode(map[string]interface{}{
27+
"status": "healthy",
28+
"version": h.cfg.Version,
29+
"uptime": time.Since(h.startTime).String(),
30+
})
31+
}
32+
33+
func (h *HealthHandler) Ready(w http.ResponseWriter, r *http.Request) {
34+
w.Header().Set("Content-Type", "application/json")
35+
json.NewEncoder(w).Encode(map[string]string{
36+
"status": "ready",
37+
})
38+
}
39+
40+
func (h *HealthHandler) Version(w http.ResponseWriter, r *http.Request) {
41+
w.Header().Set("Content-Type", "application/json")
42+
json.NewEncoder(w).Encode(map[string]interface{}{
43+
"version": h.cfg.Version,
44+
"go_version": runtime.Version(),
45+
"env": h.cfg.Environment,
46+
})
47+
}

src/bidder/api/middleware.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package api
2+
3+
import (
4+
"log"
5+
"net/http"
6+
"time"
7+
)
8+
9+
// RequestLogger logs incoming requests with timing
10+
func RequestLogger(next http.Handler) http.Handler {
11+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
12+
start := time.Now()
13+
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
14+
15+
next.ServeHTTP(wrapped, r)
16+
17+
log.Printf("[%s] %s %s %d %v",
18+
r.RemoteAddr, r.Method, r.URL.Path,
19+
wrapped.statusCode, time.Since(start))
20+
})
21+
}
22+
23+
// RateLimiter provides basic rate limiting per IP
24+
func RateLimiter(maxQPS int) func(http.Handler) http.Handler {
25+
return func(next http.Handler) http.Handler {
26+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27+
// Simple pass-through for now — full token bucket in v2.5.0
28+
next.ServeHTTP(w, r)
29+
})
30+
}
31+
}
32+
33+
// RecoveryMiddleware catches panics and returns 500
34+
func RecoveryMiddleware(next http.Handler) http.Handler {
35+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
36+
defer func() {
37+
if err := recover(); err != nil {
38+
log.Printf("PANIC recovered: %v", err)
39+
http.Error(w, "Internal server error", http.StatusInternalServerError)
40+
}
41+
}()
42+
next.ServeHTTP(w, r)
43+
})
44+
}
45+
46+
type responseWriter struct {
47+
http.ResponseWriter
48+
statusCode int
49+
}
50+
51+
func (rw *responseWriter) WriteHeader(code int) {
52+
rw.statusCode = code
53+
rw.ResponseWriter.WriteHeader(code)
54+
}

src/bidder/api/router.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package api
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/robusta-dev/bidder-service/cache"
7+
"github.com/robusta-dev/bidder-service/config"
8+
"github.com/robusta-dev/bidder-service/metrics"
9+
)
10+
11+
// NewRouter sets up all HTTP routes with middleware chain
12+
func NewRouter(cfg *config.Config, ch *cache.Handler, mc *metrics.Collector) http.Handler {
13+
mux := http.NewServeMux()
14+
15+
bidHandler := NewBidHandler(cfg, ch, mc)
16+
healthHandler := NewHealthHandler(cfg)
17+
18+
mux.HandleFunc("/bid", bidHandler.HandleBid)
19+
mux.HandleFunc("/bid/bulk", bidHandler.HandleBulkBid)
20+
mux.HandleFunc("/health", healthHandler.Health)
21+
mux.HandleFunc("/ready", healthHandler.Ready)
22+
mux.HandleFunc("/metrics", mc.ServeHTTP)
23+
mux.HandleFunc("/version", healthHandler.Version)
24+
25+
// Apply middleware stack
26+
var handler http.Handler = mux
27+
handler = RecoveryMiddleware(handler)
28+
handler = RequestLogger(handler)
29+
handler = RateLimiter(cfg.MaxQPS)(handler)
30+
31+
return handler
32+
}

0 commit comments

Comments
 (0)