Skip to content

Commit ed398f0

Browse files
authored
Capture Destination Metadata (#73)
* add _metadata to payloads * switch to /b endpoint * revert auth header changes * use proper types rather than json types * remove random comment * fix all tests
1 parent 8ab7fc1 commit ed398f0

File tree

13 files changed

+291
-73
lines changed

13 files changed

+291
-73
lines changed

android/src/test/java/com/segment/analytics/kotlin/android/EventsFileTests.kt

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ package com.segment.analytics.kotlin.android
22

33
import com.segment.analytics.kotlin.android.utilities.AndroidKVS
44
import com.segment.analytics.kotlin.android.utils.MemorySharedPreferences
5-
import com.segment.analytics.kotlin.core.*
5+
import com.segment.analytics.kotlin.core.TrackEvent
6+
import com.segment.analytics.kotlin.core.emptyJsonObject
67
import com.segment.analytics.kotlin.core.utilities.EncodeDefaultsJson
78
import com.segment.analytics.kotlin.core.utilities.EventsFileManager
89
import io.mockk.every
@@ -11,14 +12,16 @@ import kotlinx.coroutines.test.runTest
1112
import kotlinx.serialization.encodeToString
1213
import kotlinx.serialization.json.buildJsonObject
1314
import kotlinx.serialization.json.put
14-
import org.junit.jupiter.api.Assertions.*
15+
import org.junit.jupiter.api.Assertions.assertEquals
16+
import org.junit.jupiter.api.Assertions.assertFalse
17+
import org.junit.jupiter.api.Assertions.assertTrue
1518
import org.junit.jupiter.api.BeforeEach
1619
import org.junit.jupiter.api.Test
1720
import org.junit.jupiter.api.TestInstance
1821
import java.io.File
1922
import java.io.FileOutputStream
2023
import java.time.Instant
21-
import java.util.*
24+
import java.util.Date
2225

2326
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
2427
class EventsFileTests {
@@ -142,7 +145,7 @@ class EventsFileTests {
142145
file.rollover()
143146
val fileUrls = file.read()
144147
assertEquals(1, fileUrls.size)
145-
val expectedContents = """ {"batch":[${eventString}],"sentAt":"$epochTimestamp"} """.trim()
148+
val expectedContents = """ {"batch":[${eventString}],"sentAt":"$epochTimestamp","writeKey":"123"} """.trim()
146149
val newFile = File(directory, "123-0")
147150
assertTrue(newFile.exists())
148151
val actualContents = newFile.readText()
@@ -169,7 +172,7 @@ class EventsFileTests {
169172
file.read().let {
170173
assertEquals(1, it.size)
171174
val expectedContents =
172-
""" {"batch":[${eventString}],"sentAt":"$epochTimestamp"} """.trim()
175+
""" {"batch":[${eventString}],"sentAt":"$epochTimestamp","writeKey":"123"} """.trim()
173176
val newFile = File(directory, "123-0")
174177
assertTrue(newFile.exists())
175178
val actualContents = newFile.readText()

android/src/test/java/com/segment/analytics/kotlin/android/StorageTests.kt

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,37 @@ package com.segment.analytics.kotlin.android
22

33
import android.content.Context
44
import android.content.SharedPreferences
5-
import com.segment.analytics.kotlin.core.*
65
import com.segment.analytics.kotlin.android.utils.MemorySharedPreferences
76
import com.segment.analytics.kotlin.android.utils.clearPersistentStorage
87
import com.segment.analytics.kotlin.android.utils.mockContext
8+
import com.segment.analytics.kotlin.core.Configuration
9+
import com.segment.analytics.kotlin.core.Settings
10+
import com.segment.analytics.kotlin.core.Storage
11+
import com.segment.analytics.kotlin.core.System
12+
import com.segment.analytics.kotlin.core.TrackEvent
13+
import com.segment.analytics.kotlin.core.UserInfo
14+
import com.segment.analytics.kotlin.core.emptyJsonObject
915
import kotlinx.coroutines.test.UnconfinedTestDispatcher
1016
import kotlinx.coroutines.test.runTest
1117
import kotlinx.serialization.decodeFromString
18+
import kotlinx.serialization.encodeToString
19+
import kotlinx.serialization.json.Json
20+
import kotlinx.serialization.json.JsonObject
21+
import kotlinx.serialization.json.buildJsonObject
22+
import kotlinx.serialization.json.jsonArray
23+
import kotlinx.serialization.json.put
24+
import org.junit.jupiter.api.Assertions.assertEquals
25+
import org.junit.jupiter.api.Assertions.assertNotNull
26+
import org.junit.jupiter.api.Assertions.assertTrue
1227
import org.junit.jupiter.api.BeforeEach
1328
import org.junit.jupiter.api.Nested
1429
import org.junit.jupiter.api.Test
1530
import org.junit.jupiter.api.TestInstance
31+
import sovran.kotlin.Action
1632
import sovran.kotlin.Store
1733
import java.io.File
18-
import sovran.kotlin.Action
19-
import java.util.*
20-
import kotlinx.serialization.encodeToString
21-
import kotlinx.serialization.json.*
22-
import org.junit.jupiter.api.Assertions.*
34+
import java.util.Date
35+
import java.util.HashMap
2336

2437
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
2538
class StorageTests {
@@ -208,9 +221,10 @@ class StorageTests {
208221
fileUrl!!.let {
209222
val contentsStr = File(it).inputStream().readBytes().toString(Charsets.UTF_8)
210223
val contentsJson: JsonObject = Json.decodeFromString(contentsStr)
211-
assertEquals(2, contentsJson.size)
224+
assertEquals(3, contentsJson.size)
212225
assertTrue(contentsJson.containsKey("batch"))
213226
assertTrue(contentsJson.containsKey("sentAt"))
227+
assertTrue(contentsJson.containsKey("writeKey"))
214228
assertEquals(1, contentsJson["batch"]?.jsonArray?.size)
215229
val eventInFile = contentsJson["batch"]?.jsonArray?.get(0)
216230
val eventInFile2 = Json.decodeFromString(
@@ -261,9 +275,10 @@ class StorageTests {
261275
fileUrl!!.let {
262276
val contentsStr = File(it).inputStream().readBytes().toString(Charsets.UTF_8)
263277
val contentsJson: JsonObject = Json.decodeFromString(contentsStr)
264-
assertEquals(2, contentsJson.size)
278+
assertEquals(3, contentsJson.size)
265279
assertTrue(contentsJson.containsKey("batch"))
266280
assertTrue(contentsJson.containsKey("sentAt"))
281+
assertTrue(contentsJson.containsKey("writeKey"))
267282
assertEquals(2, contentsJson["batch"]?.jsonArray?.size)
268283
val eventInFile = contentsJson["batch"]?.jsonArray?.get(0)
269284
val eventInFile2 = Json.decodeFromString(

core/src/main/java/com/segment/analytics/kotlin/core/Events.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import kotlinx.serialization.descriptors.PrimitiveKind
77
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
88
import kotlinx.serialization.encoding.Decoder
99
import kotlinx.serialization.encoding.Encoder
10+
import kotlinx.serialization.json.JsonArray
1011
import kotlinx.serialization.json.JsonObject
1112
import sovran.kotlin.Store
1213
import java.time.Instant
@@ -18,6 +19,7 @@ typealias Properties = JsonObject
1819
typealias Traits = JsonObject
1920

2021
val emptyJsonObject = JsonObject(emptyMap())
22+
val emptyJsonArray = JsonArray(emptyList())
2123

2224
class DateSerializer : KSerializer<Instant> {
2325
override val descriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)
@@ -31,6 +33,13 @@ class DateSerializer : KSerializer<Instant> {
3133
}
3234
}
3335

36+
@Serializable
37+
data class DestinationMetadata(
38+
var bundled: List<String>? = emptyList(),
39+
var unbundled: List<String>? = emptyList(),
40+
var bundledIds: List<String>? = emptyList(),
41+
)
42+
3443
@Serializable
3544
enum class EventType {
3645
@SerialName("track")
@@ -79,6 +88,8 @@ sealed class BaseEvent {
7988
// the userId tied to the event
8089
abstract var userId: String
8190

91+
abstract var _metadata: DestinationMetadata
92+
8293
companion object {
8394
internal const val ALL_INTEGRATIONS_KEY = "All"
8495
}
@@ -122,6 +133,7 @@ sealed class BaseEvent {
122133
context = original.context
123134
integrations = original.integrations
124135
userId = original.userId
136+
_metadata = original._metadata
125137
}
126138
@Suppress("UNCHECKED_CAST")
127139
return copy as T // This is ok because resultant type will be same as input type
@@ -140,6 +152,7 @@ sealed class BaseEvent {
140152
if (context != other.context) return false
141153
if (integrations != other.integrations) return false
142154
if (userId != other.userId) return false
155+
if (_metadata != other._metadata) return false
143156

144157
return true
145158
}
@@ -152,6 +165,7 @@ sealed class BaseEvent {
152165
result = 31 * result + context.hashCode()
153166
result = 31 * result + integrations.hashCode()
154167
result = 31 * result + userId.hashCode()
168+
result = 31 * result + _metadata.hashCode()
155169
return result
156170
}
157171
}
@@ -168,6 +182,7 @@ data class TrackEvent(
168182
override lateinit var integrations: Integrations
169183
override lateinit var context: AnalyticsContext
170184
override var userId: String = ""
185+
override var _metadata: DestinationMetadata = DestinationMetadata()
171186

172187
override lateinit var timestamp: String
173188

@@ -206,6 +221,7 @@ data class IdentifyEvent(
206221
override lateinit var context: AnalyticsContext
207222

208223
override lateinit var timestamp: String
224+
override var _metadata: DestinationMetadata = DestinationMetadata()
209225

210226
override fun equals(other: Any?): Boolean {
211227
if (this === other) return true
@@ -238,6 +254,7 @@ data class GroupEvent(
238254
override lateinit var integrations: Integrations
239255
override lateinit var context: AnalyticsContext
240256
override var userId: String = ""
257+
override var _metadata: DestinationMetadata = DestinationMetadata()
241258

242259
override lateinit var timestamp: String
243260
override fun equals(other: Any?): Boolean {
@@ -274,6 +291,7 @@ data class AliasEvent(
274291
override lateinit var context: AnalyticsContext
275292

276293
override lateinit var timestamp: String
294+
override var _metadata: DestinationMetadata = DestinationMetadata()
277295

278296
override fun equals(other: Any?): Boolean {
279297
if (this === other) return true
@@ -309,6 +327,7 @@ data class ScreenEvent(
309327
override var userId: String = ""
310328

311329
override lateinit var timestamp: String
330+
override var _metadata: DestinationMetadata = DestinationMetadata()
312331

313332
override fun equals(other: Any?): Boolean {
314333
if (this === other) return true

core/src/main/java/com/segment/analytics/kotlin/core/HTTPClient.kt

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@ import java.net.HttpURLConnection
1111
import java.net.MalformedURLException
1212
import java.net.URL
1313
import java.util.zip.GZIPOutputStream
14-
1514
class HTTPClient(private val writeKey: String) {
1615
internal val authHeader = authorizationHeader(writeKey)
1716

1817
fun settings(cdnHost: String): Connection {
1918
val connection: HttpURLConnection =
2019
openConnection("https://$cdnHost/projects/$writeKey/settings")
20+
connection.setRequestProperty("Content-Type", "application/json; charset=utf-8")
2121
val responseCode = connection.responseCode
2222
if (responseCode != HttpURLConnection.HTTP_OK) {
2323
connection.disconnect()
@@ -27,19 +27,14 @@ class HTTPClient(private val writeKey: String) {
2727
}
2828

2929
fun upload(apiHost: String): Connection {
30-
val connection: HttpURLConnection = openConnection("https://$apiHost/batch")
30+
val connection: HttpURLConnection = openConnection("https://$apiHost/b")
31+
connection.setRequestProperty("Content-Type", "text/plain")
3132
connection.setRequestProperty("Authorization", authHeader)
32-
connection.setRequestProperty("Content-Encoding", "gzip")
3333
connection.doOutput = true
3434
connection.setChunkedStreamingMode(0)
3535
return connection.createPostConnection()
3636
}
3737

38-
private fun authorizationHeader(writeKey: String): String {
39-
val auth = "$writeKey:"
40-
return "Basic ${encodeToBase64(auth)}"
41-
}
42-
4338
/**
4439
* Configures defaults for connections opened with [.upload], and [ ][.projectSettings].
4540
*/
@@ -54,14 +49,18 @@ class HTTPClient(private val writeKey: String) {
5449
connection.connectTimeout = 15_000 // 15s
5550
connection.readTimeout = 20_1000 // 20s
5651

57-
connection.setRequestProperty("Content-Type", "application/json; charset=utf-8")
5852
connection.setRequestProperty(
5953
"User-Agent",
6054
"analytics-kotlin/$LIBRARY_VERSION"
6155
)
6256
connection.doInput = true
6357
return connection
6458
}
59+
60+
private fun authorizationHeader(writeKey: String): String {
61+
val auth = "$writeKey:"
62+
return "Basic ${encodeToBase64(auth)}"
63+
}
6564
}
6665

6766
/**
@@ -98,6 +97,7 @@ internal fun HttpURLConnection.createGetConnection(): Connection {
9897

9998
internal fun HttpURLConnection.createPostConnection(): Connection {
10099
val outputStream: OutputStream
100+
setRequestProperty("Content-Encoding", "gzip")
101101
outputStream = GZIPOutputStream(this.outputStream)
102102
return object : Connection(this, null, outputStream) {
103103
@Throws(IOException::class)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.segment.analytics.kotlin.core.platform.plugins
2+
3+
import com.segment.analytics.kotlin.core.Analytics
4+
import com.segment.analytics.kotlin.core.BaseEvent
5+
import com.segment.analytics.kotlin.core.DestinationMetadata
6+
import com.segment.analytics.kotlin.core.Settings
7+
import com.segment.analytics.kotlin.core.platform.DestinationPlugin
8+
import com.segment.analytics.kotlin.core.platform.Plugin
9+
import com.segment.analytics.kotlin.core.utilities.safeJsonArray
10+
import com.segment.analytics.kotlin.core.utilities.safeJsonObject
11+
import kotlinx.serialization.json.JsonPrimitive
12+
13+
/**
14+
* DestinationMetadataPlugin adds `_metadata` information to payloads that Segment uses to
15+
* determine delivery of events to cloud/device-mode destinations
16+
*/
17+
class DestinationMetadataPlugin : Plugin {
18+
override val type: Plugin.Type = Plugin.Type.Enrichment
19+
override lateinit var analytics: Analytics
20+
private var analyticsSettings: Settings = Settings()
21+
22+
override fun update(settings: Settings, type: Plugin.UpdateType) {
23+
super.update(settings, type)
24+
analyticsSettings = settings
25+
}
26+
27+
override fun execute(event: BaseEvent): BaseEvent {
28+
// Using this over `findAll` for that teensy performance benefit
29+
val enabledDestinations = analytics.timeline.plugins[Plugin.Type.Destination]?.plugins
30+
?.map { it as DestinationPlugin }
31+
?.filter { it.enabled && it !is SegmentDestination }
32+
val metadata = DestinationMetadata().apply {
33+
// Mark all loaded destinations as bundled
34+
this.bundled = enabledDestinations?.map { it.key }
35+
36+
// All unbundledIntegrations not in `bundled` are put in `unbundled`
37+
this.unbundled = analyticsSettings.integrations["Segment.io"]?.safeJsonObject
38+
?.get("unbundledIntegrations")?.safeJsonArray?.map { (it as JsonPrimitive).content }
39+
?.filter { !(bundled?.contains(it) ?: false) }
40+
41+
// `bundledIds` for mobile is empty
42+
this.bundledIds = emptyList()
43+
}
44+
45+
val payload = event.copy<BaseEvent>().apply {
46+
this._metadata = metadata
47+
}
48+
49+
return payload
50+
}
51+
}

0 commit comments

Comments
 (0)