Skip to content

Commit

Permalink
Analytics.js bridge updates
Browse files Browse the repository at this point in the history
- Set network_userid to the same value set by the normal collector routes.
- Set user_id to the value from the ajs_user_id cookie, fallback to the Segment payload -> userId value when the cookie is missing.
- Set domain_userid to the value from the ajs_anonymous_id cookie.
  • Loading branch information
AlexITC committed Aug 26, 2024
1 parent a601161 commit dd54826
Show file tree
Hide file tree
Showing 4 changed files with 83 additions and 61 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ object AnalyticsJsBridge {
private val Vendor = "com.segment"
private val Version = "v1"

case class Event(eventType: EventType, anonymousUserId: Option[String], userId: Option[String])

sealed trait EventType extends Product with Serializable
object EventType {
case object Page extends EventType
Expand All @@ -38,62 +40,70 @@ object AnalyticsJsBridge {
collectorService: Service
) =
path(Vendor / Version / Segment) { segment =>
// ideally, we should use /com.segment/v1 as the path but this requires a remote adapter on the enrich side
// instead, we are reusing the snowplow event type while attaching the segment payload
//
// consider using a path-mapping config instead.
val path = "/com.snowplowanalytics.snowplow/tp2"

// identify, track, page, screen, group, alias
val eventType = segment match {
case "i" => Some(AnalyticsJsBridge.EventType.Identify)
case "t" => Some(AnalyticsJsBridge.EventType.Track)
case "p" => Some(AnalyticsJsBridge.EventType.Page)
case "s" => Some(AnalyticsJsBridge.EventType.Screen)
case "g" => Some(AnalyticsJsBridge.EventType.Group)
case "a" => Some(AnalyticsJsBridge.EventType.Alias)
case _ => None
}
if (eventType.isDefined)
post {
extractContentType { ct =>
// analytics.js is sending "text/plain" content type which is not supported by the snowplow schema
val normalizedContentType =
ContentType.parse(ct.value.toLowerCase.replace("text/plain", "application/json")).toOption

entity(as[String]) { body =>
val r = collectorService.cookie(
queryString = queryString,
body = Some(body),
path = path,
cookie = cookie,
userAgent = userAgent,
refererUri = refererUri,
hostname = hostname,
ip = ip,
request = request,
pixelExpected = false,
doNotTrack = doNotTrack,
contentType = normalizedContentType,
spAnonymous = spAnonymous,
analyticsJsEvent = eventType
)
complete(r)
}
optionalCookie("ajs_anonymous_id") { ajsAnonymousUserIdCookie =>
optionalCookie("ajs_user_id") { ajsUserIdCookie =>
val anonymousUserId = ajsAnonymousUserIdCookie.map(_.toCookie().value())
val userId = ajsUserIdCookie.map(_.toCookie().value())

// ideally, we should use /com.segment/v1 as the path but this requires a remote adapter on the enrich side
// instead, we are reusing the snowplow event type while attaching the segment payload
//
// consider using a path-mapping config instead.
val path = "/com.snowplowanalytics.snowplow/tp2"

// identify, track, page, screen, group, alias
val eventType = segment match {
case "i" => Some(AnalyticsJsBridge.EventType.Identify)
case "t" => Some(AnalyticsJsBridge.EventType.Track)
case "p" => Some(AnalyticsJsBridge.EventType.Page)
case "s" => Some(AnalyticsJsBridge.EventType.Screen)
case "g" => Some(AnalyticsJsBridge.EventType.Group)
case "a" => Some(AnalyticsJsBridge.EventType.Alias)
case _ => None
}
if (eventType.isDefined)
post {
extractContentType { ct =>
// analytics.js is sending "text/plain" content type which is not supported by the snowplow schema
val normalizedContentType =
ContentType.parse(ct.value.toLowerCase.replace("text/plain", "application/json")).toOption

entity(as[String]) { body =>
val r = collectorService.cookie(
queryString = queryString,
body = Some(body),
path = path,
cookie = cookie,
userAgent = userAgent,
refererUri = refererUri,
hostname = hostname,
ip = ip,
request = request,
pixelExpected = false,
doNotTrack = doNotTrack,
contentType = normalizedContentType,
spAnonymous = spAnonymous,
analyticsJsEvent = eventType.map(t => Event(t, anonymousUserId = anonymousUserId, userId = userId))
)
complete(r)
}
}
}
else complete(HttpResponse(StatusCodes.BadRequest))
}
else complete(HttpResponse(StatusCodes.BadRequest))
}

}

def createSnowplowPayload(body: Json, eventType: EventType): Json = {
def createSnowplowPayload(body: Json, event: Event, networkUserId: String): Json = {
import io.circe._

import java.nio.charset.StandardCharsets
import java.util.Base64

val appId = "ajs_bridge"

val eventSchema = eventType match {
val eventSchema = event.eventType match {
case EventType.Page => "iglu:com.segment/page/jsonschema/2-0-0"
case EventType.Identify => "iglu:com.segment/identify/jsonschema/1-0-0"
case EventType.Track => "iglu:com.segment/track/jsonschema/1-0-0"
Expand All @@ -118,11 +128,16 @@ object AnalyticsJsBridge {
val properties = body.hcursor.downField("properties")
val context = body.hcursor.downField("context")

val url = "url" -> properties.get[String]("url")
val page = "page" -> properties.get[String]("page")
val locale = "lang" -> context.get[String]("locale")
val timezone = "tz" -> context.get[String]("timezone")
val userId = "uid" -> body.hcursor.get[String]("userId")
val url = "url" -> properties.get[String]("url").toOption
val page = "page" -> properties.get[String]("page").toOption
val locale = "lang" -> context.get[String]("locale").toOption
val timezone = "tz" -> context.get[String]("timezone").toOption
// user_id
val userId = "uid" -> event.userId.orElse {
body.hcursor.get[String]("userId").toOption
}
// domain_userid
val domainUserId = "duid" -> event.anonymousUserId

val trackerVersion = context
.downField("library")
Expand All @@ -141,11 +156,14 @@ object AnalyticsJsBridge {
// base64-encoded event
"ue_px" -> Json.fromString(
Base64.getEncoder.encodeToString(eventPayload.noSpaces.getBytes(StandardCharsets.UTF_8))
)
),
// network_userid
"tnuid" -> Json.fromString(networkUserId)
)

// merge optional arguments
val data = List(url, page, locale, timezone, userId).map(x => x._1 -> x._2.toOption).foldLeft(initialData) {
val optionalEntries = List(url, page, locale, timezone, userId, domainUserId)
val data = optionalEntries.foldLeft(initialData) {
case (acc, (key, Some(value))) => acc.add(key, Json.fromString(value))
case (acc, _) => acc
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ trait Service {
doNotTrack: Boolean,
contentType: Option[ContentType] = None,
spAnonymous: Option[String] = None,
analyticsJsEvent: Option[AnalyticsJsBridge.EventType] = None
analyticsJsEvent: Option[AnalyticsJsBridge.Event] = None
): HttpResponse
def cookieName: Option[String]
def doNotTrackCookie: Option[DntCookieMatcher]
Expand Down Expand Up @@ -113,7 +113,7 @@ class CollectorService(
doNotTrack: Boolean,
contentType: Option[ContentType] = None,
spAnonymous: Option[String],
analyticsJsEvent: Option[AnalyticsJsBridge.EventType] = None
analyticsJsEvent: Option[AnalyticsJsBridge.Event] = None
): HttpResponse = {
val (ipAddress, partitionKey) = ipAndPartitionKey(ip, config.streams.useIpAddressAsPartitionKey)

Expand Down Expand Up @@ -249,7 +249,7 @@ class CollectorService(
networkUserId: String,
contentType: Option[String],
spAnonymous: Option[String],
analyticsJsEvent: Option[AnalyticsJsBridge.EventType] = None
analyticsJsEvent: Option[AnalyticsJsBridge.Event] = None
): CollectorPayload = {
val customBody = analyticsJsEvent match {
case Some(eventType) =>
Expand All @@ -258,7 +258,7 @@ class CollectorService(
.parser
.parse(body.getOrElse("{}"))
.getOrElse(throw new RuntimeException("The request body must be a JSON-encoded Analytic.js payload"))
val payload = AnalyticsJsBridge.createSnowplowPayload(jsonBody, eventType)
val payload = AnalyticsJsBridge.createSnowplowPayload(jsonBody, eventType, networkUserId)
Some(payload.noSpaces)

case None => body
Expand Down Expand Up @@ -310,7 +310,7 @@ class CollectorService(
pixelExpected: Boolean,
bounce: Boolean,
redirectMacroConfig: RedirectMacroConfig,
analyticsJsEvent: Option[AnalyticsJsBridge.EventType] = None
analyticsJsEvent: Option[AnalyticsJsBridge.Event] = None
): HttpResponse =
if (redirect) {
val r = buildRedirectHttpResponse(event, queryParams, redirectMacroConfig)
Expand All @@ -324,7 +324,7 @@ class CollectorService(
def buildUsualHttpResponse(
pixelExpected: Boolean,
bounce: Boolean,
analyticsJsEvent: Option[AnalyticsJsBridge.EventType] = None
analyticsJsEvent: Option[AnalyticsJsBridge.Event] = None
): HttpResponse =
(pixelExpected, bounce, analyticsJsEvent) match {
case (true, true, _) => HttpResponse(StatusCodes.Found)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ class CollectorRouteSpec extends Specification with Specs2RouteTest {
doNotTrack: Boolean,
contentType: Option[ContentType] = None,
spAnonymous: Option[String] = spAnonymous,
analyticsJsEvent: Option[AnalyticsJsBridge.EventType] = None
analyticsJsEvent: Option[AnalyticsJsBridge.Event] = None
): HttpResponse =
if (analyticsJsEvent.isDefined) {
HttpResponse(200, entity = AnalyticsJsBridge.jsonResponse.noSpaces)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,11 @@ class CollectorServiceSpec extends Specification {
service.buildUsualHttpResponse(false, true) shouldEqual HttpResponse(200, entity = "ok")
}
"send back the analytics.js supported response" in {
service.buildUsualHttpResponse(false, true, Some(AnalyticsJsBridge.EventType.Page)) shouldEqual HttpResponse(
service.buildUsualHttpResponse(
false,
true,
Some(AnalyticsJsBridge.Event(AnalyticsJsBridge.EventType.Page, None, None))
) shouldEqual HttpResponse(
200,
entity = """{"success":true}"""
)
Expand Down Expand Up @@ -970,7 +974,7 @@ class CollectorServiceSpec extends Specification {
request = HttpRequest(),
pixelExpected = false,
doNotTrack = false,
analyticsJsEvent = Some(eventType)
analyticsJsEvent = Some(AnalyticsJsBridge.Event(eventType, None, None))
)

good.storedRawEvents must have size 1
Expand Down

0 comments on commit dd54826

Please sign in to comment.