diff --git a/src/main/java/org/prebid/server/analytics/reporter/agma/AgmaAnalyticsReporter.java b/src/main/java/org/prebid/server/analytics/reporter/agma/AgmaAnalyticsReporter.java new file mode 100644 index 00000000000..dc9143966f3 --- /dev/null +++ b/src/main/java/org/prebid/server/analytics/reporter/agma/AgmaAnalyticsReporter.java @@ -0,0 +1,264 @@ +package org.prebid.server.analytics.reporter.agma; + +import com.iab.openrtb.request.App; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Publisher; +import com.iab.openrtb.request.Site; +import com.iab.openrtb.request.User; +import com.iabtcf.decoder.TCString; +import com.iabtcf.utils.IntIterable; +import io.netty.handler.codec.http.HttpHeaderValues; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.vertx.core.AsyncResult; +import io.vertx.core.Future; +import io.vertx.core.MultiMap; +import io.vertx.core.Promise; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpMethod; +import org.apache.commons.lang3.tuple.Pair; +import org.prebid.server.analytics.AnalyticsReporter; +import org.prebid.server.analytics.model.AmpEvent; +import org.prebid.server.analytics.model.AuctionEvent; +import org.prebid.server.analytics.model.VideoEvent; +import org.prebid.server.analytics.reporter.agma.model.AgmaAnalyticsProperties; +import org.prebid.server.analytics.reporter.agma.model.AgmaEvent; +import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.auction.model.TimeoutContext; +import org.prebid.server.exception.PreBidException; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.log.Logger; +import org.prebid.server.log.LoggerFactory; +import org.prebid.server.privacy.gdpr.model.TcfContext; +import org.prebid.server.privacy.gdpr.vendorlist.proto.PurposeCode; +import org.prebid.server.privacy.model.PrivacyContext; +import org.prebid.server.proto.openrtb.ext.request.ExtUser; +import org.prebid.server.util.HttpUtil; +import org.prebid.server.version.PrebidVersionProvider; +import org.prebid.server.vertx.Initializable; +import org.prebid.server.vertx.httpclient.HttpClient; +import org.prebid.server.vertx.httpclient.model.HttpClientResponse; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.zip.GZIPOutputStream; + +public class AgmaAnalyticsReporter implements AnalyticsReporter, Initializable { + + private static final Logger logger = LoggerFactory.getLogger(AgmaAnalyticsReporter.class); + + private final String url; + private final boolean compressToGzip; + private final long httpTimeoutMs; + + private final EventBuffer buffer; + + private final Map accounts; + + private final Vertx vertx; + private final JacksonMapper jacksonMapper; + private final HttpClient httpClient; + private final Clock clock; + private final MultiMap headers; + + public AgmaAnalyticsReporter(AgmaAnalyticsProperties agmaAnalyticsProperties, + PrebidVersionProvider prebidVersionProvider, + JacksonMapper jacksonMapper, + Clock clock, + HttpClient httpClient, + Vertx vertx) { + + this.accounts = agmaAnalyticsProperties.getAccounts(); + + this.url = HttpUtil.validateUrl(agmaAnalyticsProperties.getUrl()); + this.httpTimeoutMs = agmaAnalyticsProperties.getHttpTimeoutMs(); + this.compressToGzip = agmaAnalyticsProperties.isGzip(); + + this.buffer = new EventBuffer<>( + agmaAnalyticsProperties.getMaxEventsCount(), + agmaAnalyticsProperties.getBufferSize()); + + this.jacksonMapper = Objects.requireNonNull(jacksonMapper); + this.httpClient = Objects.requireNonNull(httpClient); + this.vertx = Objects.requireNonNull(vertx); + this.clock = Objects.requireNonNull(clock); + this.headers = makeHeaders(Objects.requireNonNull(prebidVersionProvider)); + } + + @Override + public void initialize(Promise initializePromise) { + vertx.setPeriodic(1000L, ignored -> sendEvents(buffer.pollAll())); + initializePromise.complete(); + } + + @Override + public Future processEvent(T event) { + final Pair contextAndType = switch (event) { + case AuctionEvent auctionEvent -> Pair.of(auctionEvent.getAuctionContext(), "auction"); + case AmpEvent ampEvent -> Pair.of(ampEvent.getAuctionContext(), "amp"); + case VideoEvent videoEvent -> Pair.of(videoEvent.getAuctionContext(), "video"); + case null, default -> null; + }; + + if (contextAndType == null) { + return Future.succeededFuture(); + } + + final AuctionContext auctionContext = contextAndType.getLeft(); + final BidRequest bidRequest = auctionContext.getBidRequest(); + final TimeoutContext timeoutContext = auctionContext.getTimeoutContext(); + final PrivacyContext privacyContext = auctionContext.getPrivacyContext(); + + if (!allowedToSendEvent(bidRequest, privacyContext)) { + return Future.succeededFuture(); + } + + final String accountCode = Optional.ofNullable(bidRequest) + .map(AgmaAnalyticsReporter::getPublisherId) + .map(accounts::get) + .orElse(null); + + if (accountCode == null) { + return Future.succeededFuture(); + } + + final AgmaEvent agmaEvent = AgmaEvent.builder() + .eventType(contextAndType.getRight()) + .accountCode(accountCode) + .requestId(bidRequest.getId()) + .app(bidRequest.getApp()) + .site(bidRequest.getSite()) + .device(bidRequest.getDevice()) + .user(bidRequest.getUser()) + .startTime(ZonedDateTime.ofInstant( + Instant.ofEpochMilli(timeoutContext.getStartTime()), clock.getZone())) + .build(); + + final String eventString = jacksonMapper.encodeToString(agmaEvent); + buffer.put(eventString, eventString.length()); + final List toFlush = buffer.pollToFlush(); + if (!toFlush.isEmpty()) { + sendEvents(toFlush); + } + + return Future.succeededFuture(); + } + + private boolean allowedToSendEvent(BidRequest bidRequest, PrivacyContext privacyContext) { + final TCString consent = Optional.ofNullable(privacyContext) + .map(PrivacyContext::getTcfContext) + .map(TcfContext::getConsent) + .or(() -> Optional.ofNullable(bidRequest.getUser()) + .map(User::getExt) + .map(ExtUser::getConsent) + .map(AgmaAnalyticsReporter::decodeConsent)) + .orElse(null); + + if (consent == null) { + return false; + } + + final IntIterable purposesConsent = consent.getPurposesConsent(); + final IntIterable vendorConsent = consent.getVendorConsent(); + + final boolean isPurposeAllowed = purposesConsent.contains(PurposeCode.NINE.code()); + final boolean isVendorAllowed = vendorConsent.contains(vendorId()); + return isPurposeAllowed && isVendorAllowed; + } + + private static TCString decodeConsent(String consent) { + try { + return TCString.decode(consent); + } catch (IllegalArgumentException e) { + return null; + } + } + + private static String getPublisherId(BidRequest bidRequest) { + final Site site = bidRequest.getSite(); + final App app = bidRequest.getApp(); + + final String publisherId = Optional.ofNullable(site).map(Site::getPublisher).map(Publisher::getId) + .or(() -> Optional.ofNullable(app).map(App::getPublisher).map(Publisher::getId)) + .orElse(null); + final String appSiteId = Optional.ofNullable(site).map(Site::getId) + .or(() -> Optional.ofNullable(app).map(App::getId)) + .or(() -> Optional.ofNullable(app).map(App::getBundle)) + .orElse(null); + + if (publisherId == null && appSiteId == null) { + return null; + } + + return publisherId; + } + + private void sendEvents(List events) { + final String payload = preparePayload(events); + final Future responseFuture = compressToGzip + ? httpClient.request(HttpMethod.POST, url, headers, gzip(payload), httpTimeoutMs) + : httpClient.request(HttpMethod.POST, url, headers, payload, httpTimeoutMs); + + responseFuture.onComplete(this::handleReportResponse); + } + + private static String preparePayload(List events) { + return "[" + String.join(",", events) + "]"; + } + + private static byte[] gzip(String value) { + try (ByteArrayOutputStream obj = new ByteArrayOutputStream(); + GZIPOutputStream gzip = new GZIPOutputStream(obj)) { + + gzip.write(value.getBytes(StandardCharsets.UTF_8)); + gzip.finish(); + + return obj.toByteArray(); + } catch (IOException e) { + throw new PreBidException("[agmaAnalytics] failed to compress, skip the events : " + e.getMessage()); + } + } + + private void handleReportResponse(AsyncResult result) { + if (result.failed()) { + logger.error("[agmaAnalytics] Failed to send events to endpoint {} with a reason: {}", + url, result.cause().getMessage()); + } else { + final HttpClientResponse httpClientResponse = result.result(); + final int statusCode = httpClientResponse.getStatusCode(); + if (statusCode != HttpResponseStatus.OK.code()) { + logger.error("[agmaAnalytics] Wrong code received {} instead of 200", statusCode); + } + } + } + + private MultiMap makeHeaders(PrebidVersionProvider versionProvider) { + final MultiMap headers = MultiMap.caseInsensitiveMultiMap() + .add(HttpHeaders.CONTENT_TYPE, HttpHeaderValues.APPLICATION_JSON) + .add(HttpUtil.X_PREBID_HEADER, versionProvider.getNameVersionRecord()); + + if (compressToGzip) { + headers.add(HttpHeaders.CONTENT_ENCODING, HttpHeaderValues.GZIP); + } + + return headers; + } + + @Override + public int vendorId() { + return 1122; + } + + @Override + public String name() { + return "agmaAnalytics"; + } +} diff --git a/src/main/java/org/prebid/server/analytics/reporter/agma/EventBuffer.java b/src/main/java/org/prebid/server/analytics/reporter/agma/EventBuffer.java new file mode 100644 index 00000000000..d291fa0ba1c --- /dev/null +++ b/src/main/java/org/prebid/server/analytics/reporter/agma/EventBuffer.java @@ -0,0 +1,59 @@ +package org.prebid.server.analytics.reporter.agma; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +public class EventBuffer { + + private final Lock lock = new ReentrantLock(true); + + private List events = new ArrayList<>(); + + private long byteSize = 0; + + private final long maxEvents; + + private final long maxBytes; + + public EventBuffer(long maxEvents, long maxBytes) { + this.maxEvents = maxEvents; + this.maxBytes = maxBytes; + } + + public void put(T event, long eventSize) { + lock.lock(); + events.addLast(event); + byteSize += eventSize; + lock.unlock(); + } + + public List pollToFlush() { + List toFlush = Collections.emptyList(); + + lock.lock(); + if (events.size() >= maxEvents || byteSize >= maxBytes) { + toFlush = events; + reset(); + } + lock.unlock(); + + return toFlush; + } + + public List pollAll() { + lock.lock(); + final List polled = events; + reset(); + lock.unlock(); + + return polled; + } + + private void reset() { + byteSize = 0; + events = new ArrayList<>(); + } +} diff --git a/src/main/java/org/prebid/server/analytics/reporter/agma/model/AgmaAccountAnalyticsProperties.java b/src/main/java/org/prebid/server/analytics/reporter/agma/model/AgmaAccountAnalyticsProperties.java new file mode 100644 index 00000000000..1d5a994dbe9 --- /dev/null +++ b/src/main/java/org/prebid/server/analytics/reporter/agma/model/AgmaAccountAnalyticsProperties.java @@ -0,0 +1,15 @@ +package org.prebid.server.analytics.reporter.agma.model; + +import lombok.Builder; +import lombok.Value; + +@Builder +@Value +public class AgmaAccountAnalyticsProperties { + + String code; + + String publisherId; + + String siteAppId; +} diff --git a/src/main/java/org/prebid/server/analytics/reporter/agma/model/AgmaAnalyticsProperties.java b/src/main/java/org/prebid/server/analytics/reporter/agma/model/AgmaAnalyticsProperties.java new file mode 100644 index 00000000000..c1d5ed0c4ed --- /dev/null +++ b/src/main/java/org/prebid/server/analytics/reporter/agma/model/AgmaAnalyticsProperties.java @@ -0,0 +1,26 @@ +package org.prebid.server.analytics.reporter.agma.model; + +import lombok.Builder; +import lombok.Value; + +import java.util.Map; + +@Builder +@Value +public class AgmaAnalyticsProperties { + + String url; + + boolean gzip; + + Integer bufferSize; + + Integer maxEventsCount; + + Long bufferTimeoutMs; + + Long httpTimeoutMs; + + Map accounts; + +} diff --git a/src/main/java/org/prebid/server/analytics/reporter/agma/model/AgmaEvent.java b/src/main/java/org/prebid/server/analytics/reporter/agma/model/AgmaEvent.java new file mode 100644 index 00000000000..51e385744bf --- /dev/null +++ b/src/main/java/org/prebid/server/analytics/reporter/agma/model/AgmaEvent.java @@ -0,0 +1,38 @@ +package org.prebid.server.analytics.reporter.agma.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.iab.openrtb.request.App; +import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.Site; +import com.iab.openrtb.request.User; +import lombok.Builder; +import lombok.Value; + +import java.time.ZonedDateTime; + +@Value +@Builder +public class AgmaEvent { + + @JsonProperty("type") + String eventType; + + @JsonProperty("id") + String requestId; + + @JsonProperty("code") + String accountCode; + + Site site; + + App app; + + Device device; + + User user; + + //format 2023-02-01T00:00:00Z + @JsonProperty("created_at") + ZonedDateTime startTime; + +} diff --git a/src/main/java/org/prebid/server/spring/config/AnalyticsConfiguration.java b/src/main/java/org/prebid/server/spring/config/AnalyticsConfiguration.java index 688cb0c5efb..ea8b68d0ebc 100644 --- a/src/main/java/org/prebid/server/spring/config/AnalyticsConfiguration.java +++ b/src/main/java/org/prebid/server/spring/config/AnalyticsConfiguration.java @@ -4,8 +4,11 @@ import lombok.Data; import lombok.NoArgsConstructor; import org.apache.commons.collections4.ListUtils; +import org.apache.commons.lang3.BooleanUtils; import org.prebid.server.analytics.AnalyticsReporter; import org.prebid.server.analytics.reporter.AnalyticsReporterDelegator; +import org.prebid.server.analytics.reporter.agma.AgmaAnalyticsReporter; +import org.prebid.server.analytics.reporter.agma.model.AgmaAnalyticsProperties; import org.prebid.server.analytics.reporter.greenbids.GreenbidsAnalyticsReporter; import org.prebid.server.analytics.reporter.greenbids.model.GreenbidsAnalyticsProperties; import org.prebid.server.analytics.reporter.log.LogAnalyticsReporter; @@ -25,9 +28,12 @@ import org.springframework.context.annotation.Configuration; import org.springframework.validation.annotation.Validated; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import java.time.Clock; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; @Configuration public class AnalyticsConfiguration { @@ -56,6 +62,106 @@ LogAnalyticsReporter logAnalyticsReporter(JacksonMapper mapper) { return new LogAnalyticsReporter(mapper); } + @Configuration + @ConditionalOnProperty(prefix = "analytics.agma", name = "enabled", havingValue = "true") + public static class AgmaAnalyticsConfiguration { + + @Bean + AgmaAnalyticsReporter agmaAnalyticsReporter(AgmaAnalyticsConfigurationProperties properties, + JacksonMapper jacksonMapper, + HttpClient httpClient, + Clock clock, + PrebidVersionProvider prebidVersionProvider, + Vertx vertx) { + + return new AgmaAnalyticsReporter( + properties.toComponentProperties(), + prebidVersionProvider, + jacksonMapper, + clock, + httpClient, + vertx); + } + + @Bean + @ConfigurationProperties(prefix = "analytics.agma") + AgmaAnalyticsConfigurationProperties agmaAnalyticsConfigurationProperties() { + return new AgmaAnalyticsConfigurationProperties(); + } + + @Validated + @NoArgsConstructor + @Data + private static class AgmaAnalyticsConfigurationProperties { + + @NotNull + private AgmaAnalyticsHttpEndpointProperties endpoint; + + @NotNull + private AgmaAnalyticsBufferProperties buffers; + + @NotEmpty(message = "Please configure at least one account for Agma Analytics") + private List accounts; + + public AgmaAnalyticsProperties toComponentProperties() { + final Map accountsByPublisherId = accounts.stream() + .collect(Collectors.toMap( + AgmaAnalyticsAccountProperties::getPublisherId, + AgmaAnalyticsAccountProperties::getCode)); + + return AgmaAnalyticsProperties.builder() + .url(endpoint.getUrl()) + .gzip(BooleanUtils.isTrue(endpoint.getGzip())) + .bufferSize(buffers.getSizeBytes()) + .maxEventsCount(buffers.getCount()) + .bufferTimeoutMs(buffers.getTimeoutMs()) + .httpTimeoutMs(endpoint.getTimeoutMs()) + .accounts(accountsByPublisherId) + .build(); + } + + @Validated + @NoArgsConstructor + @Data + private static class AgmaAnalyticsHttpEndpointProperties { + + @NotNull + private String url; + + @NotNull + private Long timeoutMs; + + private Boolean gzip; + } + + @NoArgsConstructor + @Data + private static class AgmaAnalyticsBufferProperties { + + @NotNull + private Integer sizeBytes; + + @NotNull + private Integer count; + + @NotNull + private Long timeoutMs; + } + + @NoArgsConstructor + @Data + private static class AgmaAnalyticsAccountProperties { + + private String code; + + @NotNull + private String publisherId; + + private String siteAppId; + } + } + } + @Configuration @ConditionalOnProperty(prefix = "analytics.greenbids", name = "enabled", havingValue = "true") public static class GreenbidsAnalyticsConfiguration { diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 357a7a5220d..c9e1eb589e3 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -298,5 +298,17 @@ analytics: analytics-server: http://localhost:8090 exploratory-sampling-split: 0.9 timeout-ms: 10000 + agma: + enabled: false + accounts: + - code: code + publisher-id: pub + buffers: + size-bytes: 100000 + timeout-ms: 5000 + count: 4 + endpoint: + url: http:/url.com + timeout-ms: 5000 price-floors: enabled: false diff --git a/src/test/java/org/prebid/server/analytics/reporter/agma/AgmaAnalyticsReporterTest.java b/src/test/java/org/prebid/server/analytics/reporter/agma/AgmaAnalyticsReporterTest.java new file mode 100644 index 00000000000..2427942605e --- /dev/null +++ b/src/test/java/org/prebid/server/analytics/reporter/agma/AgmaAnalyticsReporterTest.java @@ -0,0 +1,479 @@ +package org.prebid.server.analytics.reporter.agma; + +import com.iab.openrtb.request.App; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.Publisher; +import com.iab.openrtb.request.Site; +import com.iab.openrtb.request.User; +import com.iabtcf.decoder.TCString; +import io.vertx.core.Future; +import io.vertx.core.MultiMap; +import io.vertx.core.Vertx; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.prebid.server.VertxTest; +import org.prebid.server.analytics.model.AmpEvent; +import org.prebid.server.analytics.model.AuctionEvent; +import org.prebid.server.analytics.model.NotificationEvent; +import org.prebid.server.analytics.model.VideoEvent; +import org.prebid.server.analytics.reporter.agma.model.AgmaAnalyticsProperties; +import org.prebid.server.analytics.reporter.agma.model.AgmaEvent; +import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.auction.model.TimeoutContext; +import org.prebid.server.privacy.gdpr.model.TcfContext; +import org.prebid.server.privacy.model.PrivacyContext; +import org.prebid.server.proto.openrtb.ext.request.ExtUser; +import org.prebid.server.version.PrebidVersionProvider; +import org.prebid.server.vertx.httpclient.HttpClient; +import org.prebid.server.vertx.httpclient.model.HttpClientResponse; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Map; +import java.util.zip.GZIPOutputStream; + +import static io.vertx.core.http.HttpMethod.POST; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; +import static org.mockito.AdditionalMatchers.aryEq; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +@ExtendWith(MockitoExtension.class) +public class AgmaAnalyticsReporterTest extends VertxTest { + + private static final String VALID_CONSENT = + "CQEXy8AQEXy8APoABABGBFEAAACAAAAAAAAAIxQAQIxAAAAA.QIxQAQIxAAAA.IAAA"; + private static final String CONSENT_WITHOUT_PURPOSE_9 = + "CQEXy8AQEXy8APoABABGBFEAAACAAAAAAAAAIxQAQIxAAAAA.QIxQAQIxAAAA.IAAA"; + private static final String CONSENT_WITHOUT_VENDOR = + "CQEXy8AQEXy8APoABABGBFEAAACAAAAAAAAAIwwAQIwgAAAA.QJJQAQJJAAAA.IAAA"; + + private static final TCString PARSED_VALID_CONSENT = TCString.decode(VALID_CONSENT); + + @Mock(strictness = Mock.Strictness.LENIENT) + private Vertx vertx; + + @Mock(strictness = Mock.Strictness.LENIENT) + private HttpClient httpClient; + + @Mock + private PrebidVersionProvider versionProvider; + + @Captor + private ArgumentCaptor headersCaptor; + + private Clock clock; + + private AgmaAnalyticsReporter target; + + @BeforeEach + public void setUp() { + final AgmaAnalyticsProperties properties = AgmaAnalyticsProperties.builder() + .url("http://endpoint.com") + .gzip(false) + .bufferSize(100000) + .bufferTimeoutMs(10000L) + .maxEventsCount(0) + .httpTimeoutMs(1000L) + .accounts(Map.of( + "publisherId", "accountCode", + "unknown_publisherId", "anotherCode")) + .build(); + + clock = Clock.fixed(Instant.parse("2024-09-03T10:00:00Z"), ZoneId.of("UTC+05:00")); + + given(versionProvider.getNameVersionRecord()).willReturn("pbs_version"); + given(vertx.setTimer(anyLong(), any())).willReturn(1L, 2L); + given(httpClient.request(eq(POST), anyString(), any(), anyString(), anyLong())).willReturn( + Future.succeededFuture(HttpClientResponse.of(200, MultiMap.caseInsensitiveMultiMap(), ""))); + given(httpClient.request(eq(POST), anyString(), any(), any(byte[].class), anyLong())).willReturn( + Future.succeededFuture(HttpClientResponse.of(200, MultiMap.caseInsensitiveMultiMap(), ""))); + + target = new AgmaAnalyticsReporter(properties, versionProvider, jacksonMapper, clock, httpClient, vertx); + } + + @Test + public void processEventShouldSendEventWhenEventIsAuctionEvent() { + // given + final Site givenSite = Site.builder().publisher(Publisher.builder().id("publisherId").build()).build(); + final App givenApp = App.builder().build(); + final Device givenDevice = Device.builder().build(); + final User givenUser = User.builder().build(); + + final AuctionEvent auctionEvent = AuctionEvent.builder() + .auctionContext(AuctionContext.builder() + .privacyContext(PrivacyContext.of( + null, TcfContext.builder().consent(PARSED_VALID_CONSENT).build())) + .timeoutContext(TimeoutContext.of(clock.millis(), null, 1)) + .bidRequest(BidRequest.builder() + .id("requestId") + .site(givenSite) + .app(givenApp) + .device(givenDevice) + .user(givenUser) + .build()) + .build()) + .build(); + + // when + final Future result = target.processEvent(auctionEvent); + + // then + final AgmaEvent expectedEvent = AgmaEvent.builder() + .eventType("auction") + .accountCode("accountCode") + .requestId("requestId") + .app(givenApp) + .site(givenSite) + .device(givenDevice) + .user(givenUser) + .startTime(ZonedDateTime.parse("2024-09-03T15:00:00+05:00")) + .build(); + + final String expectedEventPayload = "[" + jacksonMapper.encodeToString(expectedEvent) + "]"; + + verify(httpClient).request( + eq(POST), + eq("http://endpoint.com"), + headersCaptor.capture(), + eq(expectedEventPayload), + eq(1000L)); + + assertThat(headersCaptor.getValue()) + .extracting(Map.Entry::getKey, Map.Entry::getValue) + .containsOnly( + tuple("content-type", "application/json"), + tuple("x-prebid", "pbs_version")); + + assertThat(result.succeeded()).isTrue(); + } + + @Test + public void processEventShouldSendEventWhenEventIsVideoEvent() { + // given + final Site givenSite = Site.builder().publisher(Publisher.builder().id("publisherId").build()).build(); + final App givenApp = App.builder().build(); + final Device givenDevice = Device.builder().build(); + final User givenUser = User.builder().build(); + + final VideoEvent videoEvent = VideoEvent.builder() + .auctionContext(AuctionContext.builder() + .privacyContext(PrivacyContext.of( + null, TcfContext.builder().consent(PARSED_VALID_CONSENT).build())) + .timeoutContext(TimeoutContext.of(clock.millis(), null, 1)) + .bidRequest(BidRequest.builder() + .id("requestId") + .site(givenSite) + .app(givenApp) + .device(givenDevice) + .user(givenUser) + .build()) + .build()) + .build(); + + // when + final Future result = target.processEvent(videoEvent); + + // then + final AgmaEvent expectedEvent = AgmaEvent.builder() + .eventType("video") + .accountCode("accountCode") + .requestId("requestId") + .app(givenApp) + .site(givenSite) + .device(givenDevice) + .user(givenUser) + .startTime(ZonedDateTime.parse("2024-09-03T15:00:00+05:00")) + .build(); + + final String expectedEventPayload = "[" + jacksonMapper.encodeToString(expectedEvent) + "]"; + + verify(httpClient).request( + eq(POST), + eq("http://endpoint.com"), + headersCaptor.capture(), + eq(expectedEventPayload), + eq(1000L)); + + assertThat(headersCaptor.getValue()) + .extracting(Map.Entry::getKey, Map.Entry::getValue) + .containsOnly( + tuple("content-type", "application/json"), + tuple("x-prebid", "pbs_version")); + + assertThat(result.succeeded()).isTrue(); + } + + @Test + public void processEventShouldSendEventWhenEventIsAmpEvent() { + // given + final Site givenSite = Site.builder().publisher(Publisher.builder().id("publisherId").build()).build(); + final App givenApp = App.builder().build(); + final Device givenDevice = Device.builder().build(); + final User givenUser = User.builder().build(); + + final AmpEvent ampEvent = AmpEvent.builder() + .auctionContext(AuctionContext.builder() + .privacyContext(PrivacyContext.of( + null, TcfContext.builder().consent(PARSED_VALID_CONSENT).build())) + .timeoutContext(TimeoutContext.of(clock.millis(), null, 1)) + .bidRequest(BidRequest.builder() + .id("requestId") + .site(givenSite) + .app(givenApp) + .device(givenDevice) + .user(givenUser) + .build()) + .build()) + .build(); + + // when + final Future result = target.processEvent(ampEvent); + + // then + final AgmaEvent expectedEvent = AgmaEvent.builder() + .eventType("amp") + .accountCode("accountCode") + .requestId("requestId") + .app(givenApp) + .site(givenSite) + .device(givenDevice) + .user(givenUser) + .startTime(ZonedDateTime.parse("2024-09-03T15:00:00+05:00")) + .build(); + + final String expectedEventPayload = "[" + jacksonMapper.encodeToString(expectedEvent) + "]"; + + verify(httpClient).request( + eq(POST), + eq("http://endpoint.com"), + headersCaptor.capture(), + eq(expectedEventPayload), + eq(1000L)); + + assertThat(headersCaptor.getValue()) + .extracting(Map.Entry::getKey, Map.Entry::getValue) + .containsOnly( + tuple("content-type", "application/json"), + tuple("x-prebid", "pbs_version")); + + assertThat(result.succeeded()).isTrue(); + } + + @Test + public void processEventShouldNotSendAnythingWhenEventIsNotAuctionAmpOrVideo() { + // given + final NotificationEvent notificationEvent = NotificationEvent.builder().build(); + + // when + final Future result = target.processEvent(notificationEvent); + + // then + assertThat(result.succeeded()).isTrue(); + verifyNoInteractions(httpClient); + } + + @Test + public void processEventShouldSendEventWhenConsentIsValidButWasParsedFromUserExt() { + // given + final Site givenSite = Site.builder().publisher(Publisher.builder().id("publisherId").build()).build(); + final App givenApp = App.builder().build(); + final Device givenDevice = Device.builder().build(); + final User givenUser = User.builder().ext(ExtUser.builder().consent(VALID_CONSENT).build()).build(); + + final AuctionEvent auctionEvent = AuctionEvent.builder() + .auctionContext(AuctionContext.builder() + .timeoutContext(TimeoutContext.of(clock.millis(), null, 1)) + .bidRequest(BidRequest.builder() + .id("requestId") + .site(givenSite) + .app(givenApp) + .device(givenDevice) + .user(givenUser) + .build()) + .build()) + .build(); + + // when + final Future result = target.processEvent(auctionEvent); + + // then + final AgmaEvent expectedEvent = AgmaEvent.builder() + .eventType("auction") + .accountCode("accountCode") + .requestId("requestId") + .app(givenApp) + .site(givenSite) + .device(givenDevice) + .user(givenUser) + .startTime(ZonedDateTime.parse("2024-09-03T15:00:00+05:00")) + .build(); + + final String expectedEventPayload = "[" + jacksonMapper.encodeToString(expectedEvent) + "]"; + + verify(httpClient).request( + eq(POST), + eq("http://endpoint.com"), + any(), + eq(expectedEventPayload), + eq(1000L)); + + assertThat(result.succeeded()).isTrue(); + } + + @Test + public void processEventShouldNotSendAnythingWhenVendorIsNotAllowed() { + // given + final User givenUser = User.builder().ext(ExtUser.builder().consent(CONSENT_WITHOUT_VENDOR).build()).build(); + final AuctionEvent auctionEvent = AuctionEvent.builder() + .auctionContext(AuctionContext.builder() + .bidRequest(BidRequest.builder().user(givenUser).build()) + .build()) + .build(); + + // when + final Future result = target.processEvent(auctionEvent); + + // then + verifyNoInteractions(httpClient); + assertThat(result.succeeded()).isTrue(); + } + + @Test + public void processEventShouldNotSendAnythingWhenPurposeIsNotAllowed() { + // given + final User givenUser = User.builder().ext(ExtUser.builder().consent(CONSENT_WITHOUT_PURPOSE_9).build()).build(); + final AuctionEvent auctionEvent = AuctionEvent.builder() + .auctionContext(AuctionContext.builder() + .bidRequest(BidRequest.builder().user(givenUser).build()) + .build()) + .build(); + + // when + final Future result = target.processEvent(auctionEvent); + + // then + verifyNoInteractions(httpClient); + assertThat(result.succeeded()).isTrue(); + } + + @Test + public void processEventShouldNotSendAnythingWhenAccountsDoesNotHaveConfiguredPublisher() { + // given + final AgmaAnalyticsProperties properties = AgmaAnalyticsProperties.builder() + .url("http://endpoint.com") + .gzip(false) + .bufferSize(100000) + .bufferTimeoutMs(10000L) + .maxEventsCount(0) + .httpTimeoutMs(1000L) + .accounts(Map.of("unknown_publisherId", "anotherCode")) + .build(); + + target = new AgmaAnalyticsReporter(properties, versionProvider, jacksonMapper, clock, httpClient, vertx); + + // given + final Site givenSite = Site.builder().publisher(Publisher.builder().id("publisherId").build()).build(); + + final AmpEvent ampEvent = AmpEvent.builder() + .auctionContext(AuctionContext.builder() + .privacyContext(PrivacyContext.of( + null, TcfContext.builder().consent(PARSED_VALID_CONSENT).build())) + .bidRequest(BidRequest.builder().site(givenSite).build()) + .build()) + .build(); + + // when + final Future result = target.processEvent(ampEvent); + + // then + verifyNoInteractions(httpClient); + assertThat(result.succeeded()).isTrue(); + } + + @Test + public void processEventShouldSendEncodingGzipHeaderAndCompressedPayload() { + // given + final AgmaAnalyticsProperties properties = AgmaAnalyticsProperties.builder() + .url("http://endpoint.com") + .gzip(true) + .bufferSize(100000) + .bufferTimeoutMs(10000L) + .maxEventsCount(0) + .httpTimeoutMs(1000L) + .accounts(Map.of("publisherId", "accountCode")) + .build(); + + target = new AgmaAnalyticsReporter(properties, versionProvider, jacksonMapper, clock, httpClient, vertx); + + final Site givenSite = Site.builder().publisher(Publisher.builder().id("publisherId").build()).build(); + + final AuctionEvent auctionEvent = AuctionEvent.builder() + .auctionContext(AuctionContext.builder() + .privacyContext(PrivacyContext.of( + null, TcfContext.builder().consent(PARSED_VALID_CONSENT).build())) + .timeoutContext(TimeoutContext.of(clock.millis(), null, 1)) + .bidRequest(BidRequest.builder().site(givenSite).build()) + .build()) + .build(); + + // when + final Future result = target.processEvent(auctionEvent); + + // then + final AgmaEvent expectedEvent = AgmaEvent.builder() + .eventType("auction") + .accountCode("accountCode") + .site(givenSite) + .startTime(ZonedDateTime.parse("2024-09-03T15:00:00+05:00")) + .build(); + + final String expectedEventPayload = "[" + jacksonMapper.encodeToString(expectedEvent) + "]"; + + verify(httpClient).request( + eq(POST), + eq("http://endpoint.com"), + headersCaptor.capture(), + aryEq(gzip(expectedEventPayload)), + eq(1000L)); + + assertThat(headersCaptor.getValue()) + .extracting(Map.Entry::getKey, Map.Entry::getValue) + .containsOnly( + tuple("content-type", "application/json"), + tuple("content-encoding", "gzip"), + tuple("x-prebid", "pbs_version")); + + assertThat(result.succeeded()).isTrue(); + } + + private static byte[] gzip(String value) { + try (ByteArrayOutputStream obj = new ByteArrayOutputStream(); + GZIPOutputStream gzip = new GZIPOutputStream(obj)) { + + gzip.write(value.getBytes(StandardCharsets.UTF_8)); + gzip.finish(); + + return obj.toByteArray(); + } catch (IOException e) { + return new byte[]{}; + } + } +} diff --git a/src/test/java/org/prebid/server/analytics/reporter/agma/EventBufferTest.java b/src/test/java/org/prebid/server/analytics/reporter/agma/EventBufferTest.java new file mode 100644 index 00000000000..c58222f703b --- /dev/null +++ b/src/test/java/org/prebid/server/analytics/reporter/agma/EventBufferTest.java @@ -0,0 +1,48 @@ +package org.prebid.server.analytics.reporter.agma; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class EventBufferTest { + + @Test + public void pollToFlushShouldReturnEventsToFlushWhenMaxEventsExceeded() { + // given + final EventBuffer target = new EventBuffer<>(1, 999); + target.put("test", 4); + + // when and then + assertThat(target.pollToFlush()).containsExactly("test"); + } + + @Test + public void pollToFlushShouldReturnEventsToFlushWhenMaxBytesExceeded() { + // given + final EventBuffer target = new EventBuffer<>(999, 1); + target.put("test", 4); + + // when and then + assertThat(target.pollToFlush()).containsExactly("test"); + } + + @Test + public void pollToFlushShouldNotReturnAnyEventsWhenLimitsAreNotExceeded() { + // given + final EventBuffer target = new EventBuffer<>(999, 999); + target.put("test", 4); + + // when and then + assertThat(target.pollToFlush()).isEmpty(); + } + + @Test + public void pollAllShouldReturnAllEvents() { + // given + final EventBuffer target = new EventBuffer<>(999, 999); + target.put("test", 4); + + // when and then + assertThat(target.pollAll()).containsExactly("test"); + } +}