diff --git a/extra/modules/live-intent-omni-channel-identity/src/main/java/org/prebid/server/hooks/modules/liveintent/omni/channel/identity/v1/hooks/LiveIntentOmniChannelIdentityProcessedAuctionRequestHook.java b/extra/modules/live-intent-omni-channel-identity/src/main/java/org/prebid/server/hooks/modules/liveintent/omni/channel/identity/v1/hooks/LiveIntentOmniChannelIdentityProcessedAuctionRequestHook.java index 5c3e43f9952..7b10860222e 100644 --- a/extra/modules/live-intent-omni-channel-identity/src/main/java/org/prebid/server/hooks/modules/liveintent/omni/channel/identity/v1/hooks/LiveIntentOmniChannelIdentityProcessedAuctionRequestHook.java +++ b/extra/modules/live-intent-omni-channel-identity/src/main/java/org/prebid/server/hooks/modules/liveintent/omni/channel/identity/v1/hooks/LiveIntentOmniChannelIdentityProcessedAuctionRequestHook.java @@ -7,6 +7,9 @@ import io.vertx.core.MultiMap; import org.apache.commons.collections4.ListUtils; import org.prebid.server.hooks.execution.v1.InvocationResultImpl; +import org.prebid.server.hooks.execution.v1.analytics.ActivityImpl; +import org.prebid.server.hooks.execution.v1.analytics.ResultImpl; +import org.prebid.server.hooks.execution.v1.analytics.TagsImpl; import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl; import org.prebid.server.hooks.modules.liveintent.omni.channel.identity.model.IdResResponse; import org.prebid.server.hooks.modules.liveintent.omni.channel.identity.model.config.LiveIntentOmniChannelProperties; @@ -95,6 +98,15 @@ private InvocationResultImpl update(IdResResponse resolut .status(InvocationStatus.success) .action(InvocationAction.update) .payloadUpdate(payload -> updatedPayload(payload, resolutionResult.getEids())) + .analyticsTags(TagsImpl.of(List.of( + ActivityImpl.of( + "liveintent-enriched", "success", + List.of( + ResultImpl.of( + "", + mapper.mapper().createObjectNode() + .put("treatmentRate", config.getTreatmentRate()), + null)))))) .build(); } diff --git a/src/main/java/org/prebid/server/analytics/reporter/liveintent/LiveIntentAnalyticsReporter.java b/src/main/java/org/prebid/server/analytics/reporter/liveintent/LiveIntentAnalyticsReporter.java new file mode 100644 index 00000000000..d404a12397e --- /dev/null +++ b/src/main/java/org/prebid/server/analytics/reporter/liveintent/LiveIntentAnalyticsReporter.java @@ -0,0 +1,195 @@ +package org.prebid.server.analytics.reporter.liveintent; + +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.response.Bid; +import com.iab.openrtb.response.BidResponse; +import com.iab.openrtb.response.SeatBid; +import io.vertx.core.Future; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.http.client.utils.URIBuilder; +import org.prebid.server.analytics.AnalyticsReporter; +import org.prebid.server.analytics.model.AuctionEvent; +import org.prebid.server.analytics.model.NotificationEvent; +import org.prebid.server.analytics.reporter.liveintent.model.LiveIntentAnalyticsProperties; +import org.prebid.server.analytics.reporter.liveintent.model.PbsjBid; +import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.exception.PreBidException; +import org.prebid.server.hooks.execution.model.ExecutionStatus; +import org.prebid.server.hooks.execution.model.GroupExecutionOutcome; +import org.prebid.server.hooks.execution.model.HookExecutionContext; +import org.prebid.server.hooks.execution.model.HookExecutionOutcome; +import org.prebid.server.hooks.execution.model.Stage; +import org.prebid.server.hooks.execution.model.StageExecutionOutcome; +import org.prebid.server.hooks.v1.analytics.Activity; +import org.prebid.server.hooks.v1.analytics.Tags; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.log.Logger; +import org.prebid.server.log.LoggerFactory; +import org.prebid.server.proto.openrtb.ext.request.ExtRequest; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebid; +import org.prebid.server.vertx.httpclient.HttpClient; + +import java.net.URISyntaxException; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public class LiveIntentAnalyticsReporter implements AnalyticsReporter { + + private static final Logger logger = LoggerFactory.getLogger(LiveIntentAnalyticsReporter.class); + + private static final String LIVEINTENT_HOOK_ID = "liveintent-omni-channel-identity-enrichment-hook"; + + private final HttpClient httpClient; + private final LiveIntentAnalyticsProperties properties; + private final JacksonMapper jacksonMapper; + + public LiveIntentAnalyticsReporter( + LiveIntentAnalyticsProperties properties, + HttpClient httpClient, + JacksonMapper jacksonMapper) { + + this.httpClient = Objects.requireNonNull(httpClient); + this.properties = Objects.requireNonNull(properties); + this.jacksonMapper = Objects.requireNonNull(jacksonMapper); + } + + @Override + public Future processEvent(T event) { + if (event instanceof AuctionEvent auctionEvent) { + return processAuctionEvent(auctionEvent.getAuctionContext()); + } else if (event instanceof NotificationEvent notificationEvent) { + return processNotificationEvent(notificationEvent); + } + + return Future.succeededFuture(); + } + + private Future processAuctionEvent(AuctionContext auctionContext) { + if (auctionContext.getBidRequest() == null) { + return Future.failedFuture(new PreBidException("Bid request should not be empty")); + } + + if (auctionContext.getBidResponse() == null) { + return Future.succeededFuture(); + } + + final BidRequest bidRequest = auctionContext.getBidRequest(); + final BidResponse bidResponse = auctionContext.getBidResponse(); + + final List activities = getActivities(auctionContext); + final boolean isEnriched = isEnriched(activities); + final Float treatmentRate = getTreatmentRate(activities); + final Long timestamp = Optional.ofNullable(bidRequest.getExt()) + .map(ExtRequest::getPrebid) + .map(ExtRequestPrebid::getAuctiontimestamp) + .orElse(0L); + + final List pbsjBids = CollectionUtils.emptyIfNull(bidResponse.getSeatbid()).stream() + .map(SeatBid::getBid) + .flatMap(Collection::stream) + .map(bid -> buildPbsjBid(bidRequest, bidResponse, bid, isEnriched, treatmentRate, timestamp)) + .filter(Optional::isPresent) + .map(Optional::get) + .toList(); + + try { + return httpClient.post( + new URIBuilder(properties.getAnalyticsEndpoint()) + .setPath("/analytic-events/pbsj-bids") + .build() + .toString(), + jacksonMapper.encodeToString(pbsjBids), + properties.getTimeoutMs()) + .mapEmpty(); + } catch (Exception e) { + logger.error("Error processing event: {}", e.getMessage()); + return Future.failedFuture(e); + } + } + + private List getActivities(AuctionContext auctionContext) { + return Optional.ofNullable(auctionContext) + .map(AuctionContext::getHookExecutionContext) + .map(HookExecutionContext::getStageOutcomes) + .map(stages -> stages.get(Stage.processed_auction_request)) + .stream() + .flatMap(Collection::stream) + .filter(stageExecutionOutcome -> "auction-request".equals(stageExecutionOutcome.getEntity())) + .map(StageExecutionOutcome::getGroups) + .flatMap(Collection::stream) + .map(GroupExecutionOutcome::getHooks) + .flatMap(Collection::stream) + .filter(hook -> LIVEINTENT_HOOK_ID.equals(hook.getHookId().getModuleCode()) + && hook.getStatus() == ExecutionStatus.success) + .map(HookExecutionOutcome::getAnalyticsTags) + .filter(Objects::nonNull) + .map(Tags::activities) + .filter(Objects::nonNull) + .flatMap(Collection::stream) + .filter(Objects::nonNull) + .toList(); + } + + private boolean isEnriched(List activity) { + return activity.stream().anyMatch(a -> "liveintent-enriched".equals(a.name())); + } + + private Float getTreatmentRate(List activity) { + return activity.stream() + .flatMap(a -> a.results().stream()) + .filter(a -> a.values().has("treatmentRate")) + .findFirst() + .map(a -> a.values().get("treatmentRate").floatValue()) + .orElse(null); + } + + private Optional buildPbsjBid( + BidRequest bidRequest, + BidResponse bidResponse, + Bid bid, + boolean isEnriched, + Float treatmentRate, + Long timestamp) { + + return bidRequest.getImp().stream() + .filter(impItem -> impItem.getId().equals(bid.getImpid())) + .map(imp -> PbsjBid.builder() + .bidId(bid.getId()) + .price(bid.getPrice()) + .adUnitId(imp.getTagid()) + .enriched(isEnriched) + .currency(bidResponse.getCur()) + .treatmentRate(treatmentRate) + .timestamp(timestamp) + .partnerId(properties.getPartnerId()) + .build()) + .findFirst(); + } + + private Future processNotificationEvent(NotificationEvent notificationEvent) { + try { + final String url = new URIBuilder(properties.getAnalyticsEndpoint()) + .setPath("/analytic-events/pbsj-winning-bid") + .setParameter("b", notificationEvent.getBidder()) + .setParameter("bidId", notificationEvent.getBidId()) + .build() + .toString(); + return httpClient.get(url, properties.getTimeoutMs()).mapEmpty(); + } catch (URISyntaxException e) { + logger.error("Error composing url for notification event: {}", e.getMessage()); + return Future.failedFuture(e); + } + } + + @Override + public int vendorId() { + return 0; + } + + @Override + public String name() { + return "liveintentAnalytics"; + } +} diff --git a/src/main/java/org/prebid/server/analytics/reporter/liveintent/model/LiveIntentAnalyticsProperties.java b/src/main/java/org/prebid/server/analytics/reporter/liveintent/model/LiveIntentAnalyticsProperties.java new file mode 100644 index 00000000000..fea06b81047 --- /dev/null +++ b/src/main/java/org/prebid/server/analytics/reporter/liveintent/model/LiveIntentAnalyticsProperties.java @@ -0,0 +1,15 @@ +package org.prebid.server.analytics.reporter.liveintent.model; + +import lombok.Builder; +import lombok.Value; + +@Builder(toBuilder = true) +@Value +public class LiveIntentAnalyticsProperties { + + String partnerId; + + String analyticsEndpoint; + + long timeoutMs; +} diff --git a/src/main/java/org/prebid/server/analytics/reporter/liveintent/model/PbsjBid.java b/src/main/java/org/prebid/server/analytics/reporter/liveintent/model/PbsjBid.java new file mode 100644 index 00000000000..00de59f9e3b --- /dev/null +++ b/src/main/java/org/prebid/server/analytics/reporter/liveintent/model/PbsjBid.java @@ -0,0 +1,27 @@ +package org.prebid.server.analytics.reporter.liveintent.model; + +import lombok.Builder; +import lombok.Value; + +import java.math.BigDecimal; + +@Builder(toBuilder = true) +@Value +public class PbsjBid { + + String bidId; + + boolean enriched; + + BigDecimal price; + + String adUnitId; + + String currency; + + Float treatmentRate; + + Long timestamp; + + String partnerId; +} 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 2e0b2d1a296..c27cbd41078 100644 --- a/src/main/java/org/prebid/server/spring/config/AnalyticsConfiguration.java +++ b/src/main/java/org/prebid/server/spring/config/AnalyticsConfiguration.java @@ -12,6 +12,8 @@ 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.liveintent.LiveIntentAnalyticsReporter; +import org.prebid.server.analytics.reporter.liveintent.model.LiveIntentAnalyticsProperties; import org.prebid.server.analytics.reporter.log.LogAnalyticsReporter; import org.prebid.server.analytics.reporter.pubstack.PubstackAnalyticsReporter; import org.prebid.server.analytics.reporter.pubstack.model.PubstackAnalyticsProperties; @@ -302,4 +304,47 @@ private static class PubstackBufferProperties { Long reportTtlMs; } } + + @Configuration + @ConditionalOnProperty(prefix = "analytics.liveintent", name = "enabled", havingValue = "true") + public static class LiveIntentAnalyticsConfiguration { + + @Bean + LiveIntentAnalyticsReporter liveIntentAnalyticsReporter( + LiveIntentAnalyticsConfigurationProperties properties, + HttpClient httpClient, + JacksonMapper jacksonMapper) { + + return new LiveIntentAnalyticsReporter( + properties.toComponentProperties(), + httpClient, + jacksonMapper); + } + + @Bean + @ConfigurationProperties(prefix = "analytics.liveintent") + LiveIntentAnalyticsConfigurationProperties liveIntentAnalyticsConfigurationProperties() { + return new LiveIntentAnalyticsConfigurationProperties(); + } + + @Validated + @NoArgsConstructor + @Data + private static class LiveIntentAnalyticsConfigurationProperties { + + String partnerId; + + String analyticsEndpoint; + + long timeoutMs; + + public LiveIntentAnalyticsProperties toComponentProperties() { + return LiveIntentAnalyticsProperties.builder() + .partnerId(this.partnerId) + .analyticsEndpoint(this.analyticsEndpoint) + .timeoutMs(this.timeoutMs) + .build(); + } + } + } } diff --git a/src/test/java/org/prebid/server/analytics/reporter/liveintent/LiveintentAnalyticsReporterTest.java b/src/test/java/org/prebid/server/analytics/reporter/liveintent/LiveintentAnalyticsReporterTest.java new file mode 100644 index 00000000000..1d044a60821 --- /dev/null +++ b/src/test/java/org/prebid/server/analytics/reporter/liveintent/LiveintentAnalyticsReporterTest.java @@ -0,0 +1,197 @@ +package org.prebid.server.analytics.reporter.liveintent; + +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.response.Bid; +import com.iab.openrtb.response.BidResponse; +import com.iab.openrtb.response.SeatBid; +import io.vertx.core.Future; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.junit.jupiter.api.Test; +import org.mockito.junit.jupiter.MockitoExtension; +import org.prebid.server.VertxTest; +import org.prebid.server.analytics.model.AuctionEvent; +import org.prebid.server.analytics.model.NotificationEvent; +import org.prebid.server.analytics.reporter.liveintent.model.LiveIntentAnalyticsProperties; +import org.prebid.server.analytics.reporter.liveintent.model.PbsjBid; +import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.hooks.execution.model.ExecutionStatus; +import org.prebid.server.hooks.execution.model.GroupExecutionOutcome; +import org.prebid.server.hooks.execution.model.HookExecutionContext; +import org.prebid.server.hooks.execution.model.HookExecutionOutcome; +import org.prebid.server.hooks.execution.model.HookId; +import org.prebid.server.hooks.execution.model.Stage; +import org.prebid.server.hooks.execution.model.StageExecutionOutcome; +import org.prebid.server.hooks.execution.v1.analytics.ActivityImpl; +import org.prebid.server.hooks.execution.v1.analytics.TagsImpl; +import org.prebid.server.hooks.execution.v1.analytics.AppliedToImpl; +import org.prebid.server.hooks.execution.v1.analytics.ResultImpl; +import org.prebid.server.json.ObjectMapperProvider; +import org.prebid.server.model.Endpoint; +import org.prebid.server.util.ListUtil; +import org.prebid.server.vertx.httpclient.HttpClient; +import org.prebid.server.vertx.httpclient.model.HttpClientResponse; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.math.BigDecimal; +import java.util.EnumMap; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class LiveintentAnalyticsReporterTest extends VertxTest { + + @Mock + private HttpClient httpClient; + + @Captor + private ArgumentCaptor jsonCaptor; + + private LiveIntentAnalyticsReporter target; + + private LiveIntentAnalyticsProperties properties; + + private static final TypeReference> PBJS_COLLECTION_TYPE = new TypeReference<>() { + }; + + @BeforeEach + public void setUp() { + + properties = LiveIntentAnalyticsProperties.builder().analyticsEndpoint("https://localhost:8080") + .partnerId("pbsj").timeoutMs(1000L).build(); + + target = new LiveIntentAnalyticsReporter(properties, httpClient, jacksonMapper); + } + + @Test + public void shouldProcessNotificationEvent() { + // given + final HttpClientResponse mockResponse = mock(HttpClientResponse.class); + when(httpClient.get(anyString(), anyLong())).thenReturn(Future.succeededFuture(mockResponse)); + + // when + target.processEvent(NotificationEvent.builder().bidId("123").bidder("foo").build()); + + // then + // Verify that the HTTP client was called with the expected parameters + verify(httpClient).get( + eq(properties.getAnalyticsEndpoint() + "/analytic-events/pbsj-winning-bid" + "?b=foo&bidId=123"), + eq(properties.getTimeoutMs())); + } + + @Test + public void shouldSendAllBidsToLiveIntent() { + // given + final HttpClientResponse mockResponse = mock(HttpClientResponse.class); + when(httpClient.post(anyString(), anyString(), anyLong())).thenReturn(Future.succeededFuture(mockResponse)); + + // when + target.processEvent(buildEvent(true)); + + // then + verify(httpClient).post(eq(properties.getAnalyticsEndpoint() + "/analytic-events/pbsj-bids"), + jsonCaptor.capture(), eq(properties.getTimeoutMs())); + + final String capturedJson = jsonCaptor.getValue(); + final List pbsjBids = jacksonMapper.decodeValue(capturedJson, PBJS_COLLECTION_TYPE); + assertThat(pbsjBids) + .isEqualTo(List.of(PbsjBid.builder().bidId("bid-id").price(BigDecimal.ONE).adUnitId("ad-unit-id") + .enriched(true).currency("USD").treatmentRate(0.5f).timestamp(0L).partnerId("pbsj").build())); + } + + @Test + public void shouldSendAllBidsToLiveIntentNotEnriched() { + // given + final HttpClientResponse mockResponse = mock(HttpClientResponse.class); + when(httpClient.post(anyString(), anyString(), anyLong())).thenReturn(Future.succeededFuture(mockResponse)); + + // when + target.processEvent(buildEvent(false)); + + // then + verify(httpClient).post(eq(properties.getAnalyticsEndpoint() + "/analytic-events/pbsj-bids"), + jsonCaptor.capture(), eq(properties.getTimeoutMs())); + + final String capturedJson = jsonCaptor.getValue(); + final List pbsjBids = jacksonMapper.decodeValue(capturedJson, PBJS_COLLECTION_TYPE); + assertThat(pbsjBids) + .isEqualTo(List.of(PbsjBid.builder().bidId("bid-id").price(BigDecimal.ONE).adUnitId("ad-unit-id") + .enriched(false).currency("USD").treatmentRate(0.5f).timestamp(0L).partnerId("pbsj").build())); + } + + @Test + public void shouldSendAllBidsToLiveIntentNoTreatmentRate() { + // given + final HttpClientResponse mockResponse = mock(HttpClientResponse.class); + when(httpClient.post(anyString(), anyString(), anyLong())).thenReturn(Future.succeededFuture(mockResponse)); + + // when + target.processEvent(buildEvent(false, false)); + + // then + verify(httpClient).post(eq(properties.getAnalyticsEndpoint() + "/analytic-events/pbsj-bids"), + jsonCaptor.capture(), eq(properties.getTimeoutMs())); + + final String capturedJson = jsonCaptor.getValue(); + final List pbsjBids = jacksonMapper.decodeValue(capturedJson, PBJS_COLLECTION_TYPE); + assertThat(pbsjBids) + .isEqualTo(List.of(PbsjBid.builder().bidId("bid-id").price(BigDecimal.ONE).adUnitId("ad-unit-id") + .enriched(false).currency("USD").timestamp(0L).treatmentRate(null).partnerId("pbsj").build())); + } + + private AuctionEvent buildEvent(Boolean isEnriched) { + return buildEvent(isEnriched, true); + } + + private AuctionEvent buildEvent(Boolean isEnriched, Boolean withTags) { + final HookId hookId = HookId.of("liveintent-omni-channel-identity-enrichment-hook", + "liveintent-omni-channel-identity-enrichment-hook"); + + final ObjectNode treatmentRateNode = ObjectMapperProvider.mapper().createObjectNode().put("treatmentRate", + 0.5f); + + final AppliedToImpl appliedTo = AppliedToImpl.builder().bidIds(List.of("bid-id")).impIds(List.of("imp-id")) + .bidders(List.of("pbsj")).request(true).response(true).build(); + + final ResultImpl result = ResultImpl.of("treatmentRate", treatmentRateNode, appliedTo); + + final ActivityImpl enrichmentRate = ActivityImpl.of("liveintent-treatment-rate", "0.5", List.of(result)); + + final List enriched = isEnriched + ? List.of(ActivityImpl.of("liveintent-enriched", "success", List.of())) + : List.of(); + + final HookExecutionOutcome hookExecutionOutcome = HookExecutionOutcome.builder().hookId(hookId) + .executionTime(100L).status(ExecutionStatus.success) + .analyticsTags(TagsImpl.of(withTags ? ListUtil.union(List.of(enrichmentRate), enriched) : List.of())) + .action(null).build(); + + final StageExecutionOutcome stageExecutionOutcome = StageExecutionOutcome.of("auction-request", + List.of(GroupExecutionOutcome.of(List.of(hookExecutionOutcome)))); + + final EnumMap> stageOutcomes = new EnumMap<>(Stage.class); + stageOutcomes.put(Stage.processed_auction_request, List.of(stageExecutionOutcome)); + return AuctionEvent.builder().auctionContext(AuctionContext.builder() + .bidRequest(BidRequest.builder().id("request-id") + .imp(List.of(Imp.builder().id("imp-id").tagid("ad-unit-id").build())).build()) + .bidResponse(BidResponse.builder().bidid("bid-id").cur("USD") + .seatbid(List.of(SeatBid.builder() + .bid(List.of(Bid.builder().id("bid-id").impid("imp-id").price(BigDecimal.ONE).build())) + .build())) + .build()) + .hookExecutionContext(HookExecutionContext.of(Endpoint.openrtb2_auction, stageOutcomes)).build()) + .build(); + } +}