diff --git a/core/build.gradle.kts b/core/build.gradle.kts index c8b4326ab..4b94b1e01 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -64,6 +64,7 @@ kotlin { all { languageSettings.apply { optIn("dev.kdriver.cdp.InternalCdpApi") + optIn("kotlin.time.ExperimentalTime") optIn("kotlin.js.ExperimentalJsExport") } } diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/connection/DefaultConnection.kt b/core/src/commonMain/kotlin/dev/kdriver/core/connection/DefaultConnection.kt index 678fabff4..18e152be1 100644 --- a/core/src/commonMain/kotlin/dev/kdriver/core/connection/DefaultConnection.kt +++ b/core/src/commonMain/kotlin/dev/kdriver/core/connection/DefaultConnection.kt @@ -21,12 +21,10 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.jsonPrimitive import kotlin.reflect.KClass import kotlin.time.Clock -import kotlin.time.ExperimentalTime /** * Default implementation of the [Connection] interface. */ -@OptIn(ExperimentalTime::class) open class DefaultConnection( private val websocketUrl: String, private val messageListeningScope: CoroutineScope, diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/tab/DefaultTab.kt b/core/src/commonMain/kotlin/dev/kdriver/core/tab/DefaultTab.kt index e3ae57530..60719dc0c 100644 --- a/core/src/commonMain/kotlin/dev/kdriver/core/tab/DefaultTab.kt +++ b/core/src/commonMain/kotlin/dev/kdriver/core/tab/DefaultTab.kt @@ -27,7 +27,6 @@ import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.abs import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds -import kotlin.time.ExperimentalTime /** * Represents a browser tab, which is a connection to a specific target in the browser. @@ -35,7 +34,6 @@ import kotlin.time.ExperimentalTime * This class provides methods to interact with the tab, such as navigating to URLs, * managing history, evaluating JavaScript expressions, and manipulating the DOM. */ -@OptIn(ExperimentalTime::class) open class DefaultTab( websocketUrl: String, messageListeningScope: CoroutineScope, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c7e8f7795..0484c47a4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,9 +9,10 @@ ktor = "3.1.3" mockk = "1.13.12" jsoup = "1.16.2" kotlinx-coroutines = "1.10.2" -kotlinx-serialization = "1.9.0" +kotlinx-serialization = "1.8.1" kotlinx-io = "0.7.0" zstd = "1.5.7-4" +opentelemetry = "1.56.0" [plugins] multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } @@ -39,3 +40,5 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c kotlinx-io = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx-io" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } zstd = { module = "com.github.luben:zstd-jni", version.ref = "zstd" } +opentelemetry-extension-kotlin = { module = "io.opentelemetry:opentelemetry-extension-kotlin", version.ref = "opentelemetry" } +opentelemetry-sdk-testing = { module = "io.opentelemetry:opentelemetry-sdk-testing", version.ref = "opentelemetry" } diff --git a/opentelemetry/build.gradle.kts b/opentelemetry/build.gradle.kts new file mode 100644 index 000000000..6cef25544 --- /dev/null +++ b/opentelemetry/build.gradle.kts @@ -0,0 +1,77 @@ +plugins { + alias(libs.plugins.multiplatform) + alias(libs.plugins.serialization) + alias(libs.plugins.kover) + alias(libs.plugins.detekt) + alias(libs.plugins.dokka) + alias(libs.plugins.ksp) + alias(libs.plugins.maven) +} + +mavenPublishing { + publishToMavenCentral(com.vanniktech.maven.publish.SonatypeHost.CENTRAL_PORTAL) + signAllPublications() + pom { + name.set("opentelemetry") + description.set("opentelemetry integration for kdriver.") + url.set(project.ext.get("url")?.toString()) + licenses { + license { + name.set(project.ext.get("license.name")?.toString()) + url.set(project.ext.get("license.url")?.toString()) + } + } + developers { + developer { + id.set(project.ext.get("developer.id")?.toString()) + name.set(project.ext.get("developer.name")?.toString()) + email.set(project.ext.get("developer.email")?.toString()) + url.set(project.ext.get("developer.url")?.toString()) + } + } + scm { + url.set(project.ext.get("scm.url")?.toString()) + } + } +} + +kotlin { + // jvm + jvmToolchain(21) + jvm { + testRuns.named("test") { + executionTask.configure { + useJUnitPlatform() + } + } + } + + applyDefaultHierarchyTemplate() + sourceSets { + all { + languageSettings.apply { + optIn("dev.kdriver.cdp.InternalCdpApi") + optIn("kotlin.js.ExperimentalJsExport") + } + } + val commonMain by getting { + dependencies { + api(project(":core")) + implementation(libs.opentelemetry.extension.kotlin) + } + } + val jvmTest by getting { + dependencies { + implementation(kotlin("test")) + implementation(libs.tests.mockk) + implementation(libs.opentelemetry.sdk.testing) + } + } + } +} + +detekt { + buildUponDefaultConfig = true + config.setFrom("${rootProject.projectDir}/detekt.yml") + source.from(file("src/commonMain/kotlin")) +} diff --git a/opentelemetry/src/commonMain/kotlin/dev/kdriver/opentelemetry/Extensions.kt b/opentelemetry/src/commonMain/kotlin/dev/kdriver/opentelemetry/Extensions.kt new file mode 100644 index 000000000..e04d6ff39 --- /dev/null +++ b/opentelemetry/src/commonMain/kotlin/dev/kdriver/opentelemetry/Extensions.kt @@ -0,0 +1,47 @@ +package dev.kdriver.opentelemetry + +import dev.kdriver.core.browser.Browser +import dev.kdriver.core.tab.Tab +import io.opentelemetry.api.trace.Tracer + +/** + * Wraps an existing browser with OpenTelemetry instrumentation. + * + * This extension function allows you to add tracing to an existing Browser. + * All tabs opened through the wrapped browser will automatically be traced. + * + * @param tracer The OpenTelemetry tracer to use for creating spans. + * @return An instrumented Browser that wraps this browser. + * + * @sample + * ```kotlin + * val browser = createBrowser(this, config) + * val tracedBrowser = browser.withTracing(tracer) + * ``` + */ +fun Browser.withTracing( + tracer: Tracer, +): Browser { + return OpenTelemetryBrowser(this, tracer) +} + +/** + * Wraps an existing tab with OpenTelemetry instrumentation. + * + * This extension function allows you to add tracing to an existing Tab. + * All actions performed on the wrapped tab will automatically be traced. + * + * @param tracer The OpenTelemetry tracer to use for creating spans. + * @return An instrumented Tab that wraps this tab. + * + * @sample + * ```kotlin + * val tab = browser.get("https://example.com") + * val tracedTab = tab.withTracing(tracer) + * ``` + */ +fun Tab.withTracing( + tracer: Tracer, +): Tab { + return OpenTelemetryTab(this, tracer) +} diff --git a/opentelemetry/src/commonMain/kotlin/dev/kdriver/opentelemetry/OpenTelemetryBrowser.kt b/opentelemetry/src/commonMain/kotlin/dev/kdriver/opentelemetry/OpenTelemetryBrowser.kt new file mode 100644 index 000000000..d53360d6c --- /dev/null +++ b/opentelemetry/src/commonMain/kotlin/dev/kdriver/opentelemetry/OpenTelemetryBrowser.kt @@ -0,0 +1,17 @@ +package dev.kdriver.opentelemetry + +import dev.kdriver.core.browser.Browser +import dev.kdriver.core.tab.Tab +import io.opentelemetry.api.trace.Tracer + +class OpenTelemetryBrowser( + private val browser: Browser, + private val tracer: Tracer, +) : Browser by browser { + + override suspend fun get(url: String, newTab: Boolean, newWindow: Boolean): Tab { + val result = browser.get(url, newTab, newWindow) + return if (newTab !is OpenTelemetryTab) OpenTelemetryTab(result, tracer) else result + } + +} diff --git a/opentelemetry/src/commonMain/kotlin/dev/kdriver/opentelemetry/OpenTelemetryTab.kt b/opentelemetry/src/commonMain/kotlin/dev/kdriver/opentelemetry/OpenTelemetryTab.kt new file mode 100644 index 000000000..a212bd7e3 --- /dev/null +++ b/opentelemetry/src/commonMain/kotlin/dev/kdriver/opentelemetry/OpenTelemetryTab.kt @@ -0,0 +1,431 @@ +package dev.kdriver.opentelemetry + +import dev.kdriver.cdp.CommandMode +import dev.kdriver.cdp.Domain +import dev.kdriver.cdp.InternalCdpApi +import dev.kdriver.cdp.Message +import dev.kdriver.cdp.domain.* +import dev.kdriver.cdp.domain.Target +import dev.kdriver.core.dom.Element +import dev.kdriver.core.dom.NodeOrElement +import dev.kdriver.core.network.BatchRequestExpectation +import dev.kdriver.core.network.FetchInterception +import dev.kdriver.core.network.RequestExpectation +import dev.kdriver.core.tab.ReadyState +import dev.kdriver.core.tab.ScreenshotFormat +import dev.kdriver.core.tab.Tab +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.trace.Span +import io.opentelemetry.api.trace.SpanKind +import io.opentelemetry.api.trace.StatusCode +import io.opentelemetry.api.trace.Tracer +import io.opentelemetry.extension.kotlin.asContextElement +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext +import kotlinx.io.files.Path +import kotlinx.serialization.json.JsonElement +import kotlin.reflect.KClass + +class OpenTelemetryTab( + private val tab: Tab, + private val tracer: Tracer, +) : Tab { + + override suspend fun get( + url: String, + newTab: Boolean, + newWindow: Boolean, + ): Tab = tab.get(url, newTab, newWindow).also { + Span.current().addEvent( + "kdriver.tab.get", Attributes.builder() + .put("url", url) + .put("newTab", newTab) + .put("newWindow", newWindow) + .build() + ) + } + + override suspend fun back() = tab.back().also { + Span.current().addEvent("kdriver.tab.back") + } + + override suspend fun forward() = tab.forward().also { + Span.current().addEvent("kdriver.tab.forward") + } + + override suspend fun reload( + ignoreCache: Boolean, + scriptToEvaluateOnLoad: String?, + ) = tab.reload(ignoreCache, scriptToEvaluateOnLoad).also { + Span.current().addEvent( + "kdriver.tab.reload", Attributes.builder() + .put("ignoreCache", ignoreCache) + .build() + ) + } + + override suspend fun rawEvaluate( + expression: String, + awaitPromise: Boolean, + ): JsonElement? = tab.rawEvaluate(expression, awaitPromise).also { + Span.current().addEvent( + "kdriver.tab.rawEvaluate", Attributes.builder() + .put("expression", expression) + .put("awaitPromise", awaitPromise) + .build() + ) + } + + override suspend fun setUserAgent( + userAgent: String?, + acceptLanguage: String?, + platform: String?, + ) = tab.setUserAgent(userAgent, acceptLanguage, platform).also { + Span.current().addEvent( + "kdriver.tab.setUserAgent", Attributes.builder() + .put("userAgent", userAgent) + .put("acceptLanguage", acceptLanguage) + .put("platform", platform) + .build() + ) + } + + override suspend fun getWindow(): Browser.GetWindowForTargetReturn = tab.getWindow() + + override suspend fun getContent(): String = tab.getContent() + + override suspend fun activate() = tab.activate().also { + Span.current().addEvent("kdriver.tab.activate") + } + + override suspend fun bringToFront() = tab.bringToFront().also { + Span.current().addEvent("kdriver.tab.bringToFront") + } + + override suspend fun maximize() = tab.maximize().also { + Span.current().addEvent("kdriver.tab.maximize") + } + + override suspend fun minimize() = tab.minimize().also { + Span.current().addEvent("kdriver.tab.minimize") + } + + override suspend fun fullscreen() = tab.fullscreen().also { + Span.current().addEvent("kdriver.tab.fullscreen") + } + + override suspend fun medimize() = tab.medimize().also { + Span.current().addEvent("kdriver.tab.medimize") + } + + override suspend fun setWindowState( + left: Int, + top: Int, + width: Int, + height: Int, + state: String, + ) = tab.setWindowState(left, top, width, height, state).also { + Span.current().addEvent( + "kdriver.tab.setWindowState", Attributes.builder() + .put("left", left.toLong()) + .put("top", top.toLong()) + .put("width", width.toLong()) + .put("height", height.toLong()) + .put("state", state) + .build() + ) + } + + override suspend fun scrollDown(amount: Int, speed: Int) = tab.scrollDown(amount, speed).also { + Span.current().addEvent( + "kdriver.tab.scrollDown", Attributes.builder() + .put("amount", amount.toLong()) + .put("speed", speed.toLong()) + .build() + ) + } + + override suspend fun scrollUp(amount: Int, speed: Int) = tab.scrollUp(amount, speed).also { + Span.current().addEvent( + "kdriver.tab.scrollUp", Attributes.builder() + .put("amount", amount.toLong()) + .put("speed", speed.toLong()) + .build() + ) + } + + override suspend fun waitForReadyState( + until: ReadyState, + timeout: Long, + ): Boolean = tab.waitForReadyState(until, timeout).also { + Span.current().addEvent( + "kdriver.tab.waitForReadyState", Attributes.builder() + .put("until", until.name) + .put("timeout", timeout) + .build() + ) + } + + override suspend fun find( + text: String, + bestMatch: Boolean, + returnEnclosingElement: Boolean, + timeout: Long, + ): Element = tab.find(text, bestMatch, returnEnclosingElement, timeout).also { + Span.current().addEvent( + "kdriver.tab.find", Attributes.builder() + .put("text", text) + .put("bestMatch", bestMatch) + .put("returnEnclosingElement", returnEnclosingElement) + .put("timeout", timeout) + .build() + ) + } + + override suspend fun select(selector: String, timeout: Long): Element = tab.select(selector, timeout).also { + Span.current().addEvent( + "kdriver.tab.select", Attributes.builder() + .put("selector", selector) + .put("timeout", timeout) + .build() + ) + } + + override suspend fun findAll( + text: String, + timeout: Long, + ): List = tab.findAll(text, timeout).also { + Span.current().addEvent( + "kdriver.tab.findAll", Attributes.builder() + .put("text", text) + .put("timeout", timeout) + .build() + ) + } + + override suspend fun selectAll( + selector: String, + timeout: Long, + includeFrames: Boolean, + ): List = tab.selectAll(selector, timeout, includeFrames).also { + Span.current().addEvent( + "kdriver.tab.selectAll", Attributes.builder() + .put("selector", selector) + .put("timeout", timeout) + .put("includeFrames", includeFrames) + .build() + ) + } + + override suspend fun xpath( + xpath: String, + timeout: Long, + ): List = tab.xpath(xpath, timeout).also { + Span.current().addEvent( + "kdriver.tab.xpath", Attributes.builder() + .put("xpath", xpath) + .put("timeout", timeout) + .build() + ) + } + + override suspend fun querySelectorAll( + selector: String, + node: NodeOrElement?, + ): List = tab.querySelectorAll(selector, node).also { + Span.current().addEvent( + "kdriver.tab.querySelectorAll", Attributes.builder() + .put("selector", selector) + .build() + ) + } + + override suspend fun querySelector( + selector: String, + node: NodeOrElement?, + ): Element? = tab.querySelector(selector, node).also { + Span.current().addEvent( + "kdriver.tab.querySelector", Attributes.builder() + .put("selector", selector) + .build() + ) + } + + override suspend fun findElementsByText( + text: String, + tagHint: String?, + ): List = tab.findElementsByText(text, tagHint).also { + Span.current().addEvent( + "kdriver.tab.findElementsByText", Attributes.builder() + .put("text", text) + .put("tagHint", tagHint ?: "null") + .build() + ) + } + + override suspend fun findElementByText( + text: String, + bestMatch: Boolean, + returnEnclosingElement: Boolean, + ): Element? = tab.findElementByText(text, bestMatch, returnEnclosingElement).also { + Span.current().addEvent( + "kdriver.tab.findElementByText", Attributes.builder() + .put("text", text) + .put("bestMatch", bestMatch) + .put("returnEnclosingElement", returnEnclosingElement) + .build() + ) + } + + override suspend fun disableDomAgent() = tab.disableDomAgent().also { + Span.current().addEvent("kdriver.tab.disableDomAgent") + } + + override suspend fun mouseMove( + x: Double, + y: Double, + steps: Int, + flash: Boolean, + ) = tab.mouseMove(x, y, steps, flash).also { + Span.current().addEvent( + "kdriver.tab.mouseMove", Attributes.builder() + .put("x", x) + .put("y", y) + .put("steps", steps.toLong()) + .put("flash", flash) + .build() + ) + } + + override suspend fun mouseClick( + x: Double, + y: Double, + button: Input.MouseButton, + buttons: Int, + modifiers: Int, + ) = tab.mouseClick(x, y, button, buttons, modifiers).also { + Span.current().addEvent( + "kdriver.tab.mouseClick", Attributes.builder() + .put("x", x) + .put("y", y) + .put("button", button.name) + .put("buttons", buttons.toLong()) + .put("modifiers", modifiers.toLong()) + .build() + ) + } + + override suspend fun expect( + urlPattern: Regex, + block: suspend RequestExpectation.() -> T, + ): T = executeInSpan("kdriver.tab.expect", SpanKind.INTERNAL) { span -> + span.setAttribute("urlPattern", urlPattern.pattern) + tab.expect(urlPattern, block) + } + + override suspend fun expectBatch( + urlPatterns: List, + block: suspend BatchRequestExpectation.() -> T, + ): T = executeInSpan("kdriver.tab.expectBatch", SpanKind.INTERNAL) { span -> + span.setAttribute("urlPatterns", urlPatterns.joinToString(",") { it.pattern }) + tab.expectBatch(urlPatterns, block) + } + + override suspend fun intercept( + urlPattern: String, + requestStage: Fetch.RequestStage, + resourceType: Network.ResourceType, + block: suspend FetchInterception.() -> T, + ): T = executeInSpan("kdriver.tab.intercept", SpanKind.INTERNAL) { span -> + span.setAttribute("urlPattern", urlPattern) + span.setAttribute("requestStage", requestStage.name) + span.setAttribute("resourceType", resourceType.name) + tab.intercept(urlPattern, requestStage, resourceType, block) + } + + override suspend fun screenshotB64( + format: ScreenshotFormat, + fullPage: Boolean, + ): String = tab.screenshotB64(format, fullPage).also { + Span.current().addEvent( + "kdriver.tab.screenshotB64", Attributes.builder() + .put("format", format.name) + .put("fullPage", fullPage) + .build() + ) + } + + override suspend fun saveScreenshot( + filename: Path?, + format: ScreenshotFormat, + fullPage: Boolean, + ): String = tab.saveScreenshot(filename, format, fullPage).also { + Span.current().addEvent( + "kdriver.tab.saveScreenshot", Attributes.builder() + .put("filename", filename?.toString() ?: "null") + .put("format", format.name) + .put("fullPage", fullPage) + .build() + ) + } + + override suspend fun getAllLinkedSources(): List = tab.getAllLinkedSources() + + override suspend fun getAllUrls(absolute: Boolean): List = tab.getAllUrls(absolute) + + @InternalCdpApi + override suspend fun callCommand( + method: String, + parameter: JsonElement?, + mode: CommandMode, + ): JsonElement? = tab.callCommand(method, parameter, mode) + + @InternalCdpApi + override suspend fun close() = tab.close().also { + Span.current().addEvent("kdriver.tab.close") + } + + override suspend fun updateTarget() = tab.updateTarget() + + override suspend fun wait(t: Long?) = tab.wait(t) + + override suspend fun sleep(t: Long) = tab.sleep(t) + + override var targetInfo: Target.TargetInfo? = tab.targetInfo + + @InternalCdpApi + override val events: Flow = tab.events + + @InternalCdpApi + override val responses: Flow = tab.responses + + @InternalCdpApi + override val generatedDomains: MutableMap, Domain> = tab.generatedDomains + + /** + * Helper function to execute code within a span. + */ + private suspend fun executeInSpan( + spanName: String, + spanKind: SpanKind, + block: suspend (Span) -> T, + ): T { + val span = tracer.spanBuilder(spanName) + .setSpanKind(spanKind) + .startSpan() + return try { + withContext(span.asContextElement()) { + val result = block(span) + span.setStatus(StatusCode.OK) + result + } + } catch (e: Exception) { + span.recordException(e) + span.setStatus(StatusCode.ERROR, e.message ?: "Error in operation") + throw e + } finally { + span.end() + } + } + +} diff --git a/opentelemetry/src/jvmTest/kotlin/dev/kdriver/opentelemetry/OpenTelemetryBrowserTest.kt b/opentelemetry/src/jvmTest/kotlin/dev/kdriver/opentelemetry/OpenTelemetryBrowserTest.kt new file mode 100644 index 000000000..159ce1dd9 --- /dev/null +++ b/opentelemetry/src/jvmTest/kotlin/dev/kdriver/opentelemetry/OpenTelemetryBrowserTest.kt @@ -0,0 +1,91 @@ +package dev.kdriver.opentelemetry + +import dev.kdriver.core.browser.Browser +import dev.kdriver.core.tab.Tab +import io.mockk.coEvery +import io.mockk.mockk +import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.extension.RegisterExtension +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class OpenTelemetryBrowserTest { + + companion object { + @JvmField + @RegisterExtension + val otelTesting = OpenTelemetryExtension.create() + } + + @Test + fun `withTracing wraps existing browser`() = runBlocking { + // Setup + val tracer = otelTesting.openTelemetry.getTracer("test") + val browser = mockk() + + // Wrap with tracing + val tracedBrowser = browser.withTracing(tracer) + + assertNotNull(tracedBrowser) + assertTrue(tracedBrowser is OpenTelemetryBrowser) + } + + @Test + fun `get returns instrumented tab`() = runBlocking { + // Setup + val tracer = otelTesting.openTelemetry.getTracer("test") + val mockTab = mockk(relaxed = true) + val browser = mockk { + coEvery { get(any(), any(), any()) } returns mockTab + } + + val tracedBrowser = browser.withTracing(tracer) + + // Execute + val tab = tracedBrowser.get("https://example.com") + + // Verify + assertNotNull(tab) + assertTrue(tab is OpenTelemetryTab) + } + + @Test + fun `get with newTab returns instrumented tab`() = runBlocking { + // Setup + val tracer = otelTesting.openTelemetry.getTracer("test") + val mockTab = mockk(relaxed = true) + val browser = mockk { + coEvery { get(any(), any(), any()) } returns mockTab + } + + val tracedBrowser = browser.withTracing(tracer) + + // Execute + val tab = tracedBrowser.get("https://example.com", newTab = true) + + // Verify + assertNotNull(tab) + assertTrue(tab is OpenTelemetryTab) + } + + @Test + fun `get with newWindow returns instrumented tab`() = runBlocking { + // Setup + val tracer = otelTesting.openTelemetry.getTracer("test") + val mockTab = mockk(relaxed = true) + val browser = mockk { + coEvery { get(any(), any(), any()) } returns mockTab + } + + val tracedBrowser = browser.withTracing(tracer) + + // Execute + val tab = tracedBrowser.get("https://example.com", newWindow = true) + + // Verify + assertNotNull(tab) + assertTrue(tab is OpenTelemetryTab) + } +} diff --git a/opentelemetry/src/jvmTest/kotlin/dev/kdriver/opentelemetry/OpenTelemetryTabTest.kt b/opentelemetry/src/jvmTest/kotlin/dev/kdriver/opentelemetry/OpenTelemetryTabTest.kt new file mode 100644 index 000000000..a67ee5fc9 --- /dev/null +++ b/opentelemetry/src/jvmTest/kotlin/dev/kdriver/opentelemetry/OpenTelemetryTabTest.kt @@ -0,0 +1,471 @@ +package dev.kdriver.opentelemetry + +import dev.kdriver.cdp.domain.Fetch +import dev.kdriver.cdp.domain.Network +import dev.kdriver.core.network.BatchRequestExpectation +import dev.kdriver.core.network.FetchInterception +import dev.kdriver.core.network.RequestExpectation +import dev.kdriver.core.tab.Tab +import io.mockk.coEvery +import io.mockk.mockk +import io.opentelemetry.api.trace.SpanKind +import io.opentelemetry.api.trace.StatusCode +import io.opentelemetry.extension.kotlin.asContextElement +import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.junit.jupiter.api.extension.RegisterExtension +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull + +class OpenTelemetryTabTest { + + companion object { + @JvmField + @RegisterExtension + val otelTesting = OpenTelemetryExtension.create() + } + + @Test + fun `withTracing wraps existing tab`() = runBlocking { + // Setup + val tracer = otelTesting.openTelemetry.getTracer("test") + val tab = mockk(relaxed = true) + + // Wrap with tracing + val tracedTab = tab.withTracing(tracer) + + assertNotNull(tracedTab) + assert(tracedTab is OpenTelemetryTab) + } + + @Test + fun `get adds event to current span`() = runBlocking { + // Setup + val tracer = otelTesting.openTelemetry.getTracer("test") + val mockTab = mockk(relaxed = true) { + coEvery { get(any(), any(), any()) } returns this + } + val tracedTab = mockTab.withTracing(tracer) + + // Create a parent span to capture events + val parentSpan = tracer.spanBuilder("test-parent") + .setSpanKind(SpanKind.INTERNAL) + .startSpan() + + withContext(parentSpan.asContextElement()) { + try { + // Execute + tracedTab.get("https://example.com", newTab = false, newWindow = false) + + // The event should be added to the current span + parentSpan.setStatus(StatusCode.OK) + } finally { + parentSpan.end() + } + } + + // Verify the parent span was created + val spans = otelTesting.spans + val testSpan = spans.find { it.name == "test-parent" } + assertNotNull(testSpan) + + // Verify the event was added + val events = testSpan.events + val getEvent = events.find { it.name == "kdriver.tab.get" } + assertNotNull(getEvent, "get event should be added to current span") + + val attributes = getEvent.attributes.asMap() + assertEquals("https://example.com", attributes[stringKey("url")]) + assertEquals(false, attributes[boolKey("newTab")]) + assertEquals(false, attributes[boolKey("newWindow")]) + } + + @Test + fun `back adds event to current span`() = runBlocking { + // Setup + val tracer = otelTesting.openTelemetry.getTracer("test") + val mockTab = mockk(relaxed = true) { + coEvery { back() } returns Unit + } + val tracedTab = mockTab.withTracing(tracer) + + // Create a parent span + val parentSpan = tracer.spanBuilder("test-parent") + .setSpanKind(SpanKind.INTERNAL) + .startSpan() + + withContext(parentSpan.asContextElement()) { + try { + tracedTab.back() + parentSpan.setStatus(StatusCode.OK) + } finally { + parentSpan.end() + } + } + + // Verify event + val testSpan = otelTesting.spans.find { it.name == "test-parent" } + assertNotNull(testSpan) + val backEvent = testSpan.events.find { it.name == "kdriver.tab.back" } + assertNotNull(backEvent, "back event should be added to current span") + } + + @Test + fun `forward adds event to current span`() = runBlocking { + // Setup + val tracer = otelTesting.openTelemetry.getTracer("test") + val mockTab = mockk(relaxed = true) { + coEvery { forward() } returns Unit + } + val tracedTab = mockTab.withTracing(tracer) + + // Create a parent span + val parentSpan = tracer.spanBuilder("test-parent") + .setSpanKind(SpanKind.INTERNAL) + .startSpan() + + withContext(parentSpan.asContextElement()) { + try { + tracedTab.forward() + parentSpan.setStatus(StatusCode.OK) + } finally { + parentSpan.end() + } + } + + // Verify event + val testSpan = otelTesting.spans.find { it.name == "test-parent" } + assertNotNull(testSpan) + val forwardEvent = testSpan.events.find { it.name == "kdriver.tab.forward" } + assertNotNull(forwardEvent, "forward event should be added to current span") + } + + @Test + fun `reload adds event with attributes to current span`() = runBlocking { + // Setup + val tracer = otelTesting.openTelemetry.getTracer("test") + val mockTab = mockk(relaxed = true) { + coEvery { reload(any(), any()) } returns Unit + } + val tracedTab = mockTab.withTracing(tracer) + + // Create a parent span + val parentSpan = tracer.spanBuilder("test-parent") + .setSpanKind(SpanKind.INTERNAL) + .startSpan() + + withContext(parentSpan.asContextElement()) { + try { + tracedTab.reload(ignoreCache = true, scriptToEvaluateOnLoad = null) + parentSpan.setStatus(StatusCode.OK) + } finally { + parentSpan.end() + } + } + + // Verify event + val testSpan = otelTesting.spans.find { it.name == "test-parent" } + assertNotNull(testSpan) + val reloadEvent = testSpan.events.find { it.name == "kdriver.tab.reload" } + assertNotNull(reloadEvent, "reload event should be added to current span") + + val attributes = reloadEvent.attributes.asMap() + assertEquals(true, attributes[boolKey("ignoreCache")]) + } + + @Test + fun `expect creates span with correct attributes`() = runBlocking { + // Setup + val tracer = otelTesting.openTelemetry.getTracer("test") + val urlPattern = Regex("https://example\\.com/.*") + val mockTab = mockk(relaxed = true) { + coEvery { expect(any(), any String>()) } coAnswers { + val block = secondArg String>() + val mockExpectation = mockk(relaxed = true) + block(mockExpectation) + } + } + val tracedTab = mockTab.withTracing(tracer) + + // Execute + val result = tracedTab.expect(urlPattern) { "test-result" } + + // Verify + assertEquals("test-result", result) + + val spans = otelTesting.spans + val expectSpan = spans.find { it.name == "kdriver.tab.expect" } + assertNotNull(expectSpan, "expect span should be created") + assertEquals(SpanKind.INTERNAL, expectSpan.kind) + assertEquals(StatusCode.OK, expectSpan.status.statusCode) + + val attributes = expectSpan.attributes.asMap() + assertEquals("https://example\\.com/.*", attributes[stringKey("urlPattern")]) + } + + @Test + fun `expectBatch creates span with correct attributes`() = runBlocking { + // Setup + val tracer = otelTesting.openTelemetry.getTracer("test") + val urlPatterns = listOf(Regex("https://example\\.com/.*"), Regex("https://test\\.com/.*")) + val mockTab = mockk(relaxed = true) { + coEvery { expectBatch(any(), any String>()) } coAnswers { + val block = secondArg String>() + val mockExpectation = mockk(relaxed = true) + block(mockExpectation) + } + } + val tracedTab = mockTab.withTracing(tracer) + + // Execute + val result = tracedTab.expectBatch(urlPatterns) { "batch-result" } + + // Verify + assertEquals("batch-result", result) + + val spans = otelTesting.spans + val expectBatchSpan = spans.find { it.name == "kdriver.tab.expectBatch" } + assertNotNull(expectBatchSpan, "expectBatch span should be created") + assertEquals(SpanKind.INTERNAL, expectBatchSpan.kind) + assertEquals(StatusCode.OK, expectBatchSpan.status.statusCode) + + val attributes = expectBatchSpan.attributes.asMap() + assertEquals("https://example\\.com/.*,https://test\\.com/.*", attributes[stringKey("urlPatterns")]) + } + + @Test + fun `intercept creates span with correct attributes`() = runBlocking { + // Setup + val tracer = otelTesting.openTelemetry.getTracer("test") + val mockTab = mockk(relaxed = true) { + coEvery { + intercept( + any(), + any(), + any(), + any String>() + ) + } coAnswers { + val block = arg String>(3) + val mockInterception = mockk(relaxed = true) + block(mockInterception) + } + } + val tracedTab = mockTab.withTracing(tracer) + + // Execute + val result = tracedTab.intercept( + urlPattern = "https://example.com/*", + requestStage = Fetch.RequestStage.REQUEST, + resourceType = Network.ResourceType.DOCUMENT + ) { "intercept-result" } + + // Verify + assertEquals("intercept-result", result) + + val spans = otelTesting.spans + val interceptSpan = spans.find { it.name == "kdriver.tab.intercept" } + assertNotNull(interceptSpan, "intercept span should be created") + assertEquals(SpanKind.INTERNAL, interceptSpan.kind) + assertEquals(StatusCode.OK, interceptSpan.status.statusCode) + + val attributes = interceptSpan.attributes.asMap() + assertEquals("https://example.com/*", attributes[stringKey("urlPattern")]) + assertEquals("REQUEST", attributes[stringKey("requestStage")]) + assertEquals("DOCUMENT", attributes[stringKey("resourceType")]) + } + + @Test + fun `expect handles errors correctly`() = runBlocking { + // Setup + val tracer = otelTesting.openTelemetry.getTracer("test") + val urlPattern = Regex("https://example\\.com/.*") + val mockTab = mockk(relaxed = true) { + coEvery { expect(any(), any String>()) } coAnswers { + val block = secondArg String>() + val mockExpectation = mockk(relaxed = true) + block(mockExpectation) + } + } + val tracedTab = mockTab.withTracing(tracer) + + // Execute and verify exception is thrown + assertFailsWith { + tracedTab.expect(urlPattern) { + throw RuntimeException("Test error") + } + } + + // Verify error span was recorded + val spans = otelTesting.spans + val errorSpan = spans.find { + it.name == "kdriver.tab.expect" && it.status.statusCode == StatusCode.ERROR + } + + assertNotNull(errorSpan, "Error span should have been recorded") + assertEquals("Test error", errorSpan.status.description) + } + + @Test + fun `expectBatch handles errors correctly`() = runBlocking { + // Setup + val tracer = otelTesting.openTelemetry.getTracer("test") + val urlPatterns = listOf(Regex("https://example\\.com/.*")) + val mockTab = mockk(relaxed = true) { + coEvery { expectBatch(any(), any String>()) } coAnswers { + val block = secondArg String>() + val mockExpectation = mockk(relaxed = true) + block(mockExpectation) + } + } + val tracedTab = mockTab.withTracing(tracer) + + // Execute and verify exception is thrown + assertFailsWith { + tracedTab.expectBatch(urlPatterns) { + throw RuntimeException("Batch error") + } + } + + // Verify error span was recorded + val spans = otelTesting.spans + val errorSpan = spans.find { + it.name == "kdriver.tab.expectBatch" && it.status.statusCode == StatusCode.ERROR + } + + assertNotNull(errorSpan, "Error span should have been recorded") + assertEquals("Batch error", errorSpan.status.description) + } + + @Test + fun `intercept handles errors correctly`() = runBlocking { + // Setup + val tracer = otelTesting.openTelemetry.getTracer("test") + val mockTab = mockk(relaxed = true) { + coEvery { + intercept( + any(), + any(), + any(), + any String>() + ) + } coAnswers { + val block = arg String>(3) + val mockInterception = mockk(relaxed = true) + block(mockInterception) + } + } + val tracedTab = mockTab.withTracing(tracer) + + // Execute and verify exception is thrown + assertFailsWith { + tracedTab.intercept( + urlPattern = "https://example.com/*", + requestStage = Fetch.RequestStage.REQUEST, + resourceType = Network.ResourceType.DOCUMENT + ) { + throw RuntimeException("Intercept error") + } + } + + // Verify error span was recorded + val spans = otelTesting.spans + val errorSpan = spans.find { + it.name == "kdriver.tab.intercept" && it.status.statusCode == StatusCode.ERROR + } + + assertNotNull(errorSpan, "Error span should have been recorded") + assertEquals("Intercept error", errorSpan.status.description) + } + + @Test + fun `context propagation works across coroutine suspensions`() = runBlocking { + // Setup + val tracer = otelTesting.openTelemetry.getTracer("test") + val mockTab = mockk(relaxed = true) { + coEvery { expect(any(), any String>()) } coAnswers { + val block = secondArg String>() + val mockExpectation = mockk(relaxed = true) + block(mockExpectation) + } + } + val tracedTab = mockTab.withTracing(tracer) + + // Execute with multiple suspensions + tracedTab.expect(Regex("https://example\\.com/.*")) { + // Create child span to verify context is propagated + val childSpan = tracer.spanBuilder("child-operation") + .setSpanKind(SpanKind.INTERNAL) + .startSpan() + + withContext(childSpan.asContextElement()) { + try { + // Multiple suspension points + delay(10) + delay(10) + childSpan.setStatus(StatusCode.OK) + } finally { + childSpan.end() + } + } + + "result" + } + + // Verify span structure + val spans = otelTesting.spans + val parentSpan = spans.find { it.name == "kdriver.tab.expect" } + val childSpan = spans.find { it.name == "child-operation" } + + assertNotNull(parentSpan, "Parent span should be created") + assertNotNull(childSpan, "Child span should be created") + + // Verify they're in the same trace + assertEquals(parentSpan.traceId, childSpan.traceId, "Spans should share same trace ID") + assertEquals(parentSpan.spanId, childSpan.parentSpanId, "Child should have parent as parent") + } + + @Test + fun `mouseClick adds event with correct attributes`() = runBlocking { + // Setup + val tracer = otelTesting.openTelemetry.getTracer("test") + val mockTab = mockk(relaxed = true) { + coEvery { mouseClick(any(), any(), any(), any(), any()) } returns Unit + } + val tracedTab = mockTab.withTracing(tracer) + + // Create a parent span + val parentSpan = tracer.spanBuilder("test-parent") + .setSpanKind(SpanKind.INTERNAL) + .startSpan() + + withContext(parentSpan.asContextElement()) { + try { + tracedTab.mouseClick(100.0, 200.0, dev.kdriver.cdp.domain.Input.MouseButton.LEFT, 1, 0) + parentSpan.setStatus(StatusCode.OK) + } finally { + parentSpan.end() + } + } + + // Verify event + val testSpan = otelTesting.spans.find { it.name == "test-parent" } + assertNotNull(testSpan) + val clickEvent = testSpan.events.find { it.name == "kdriver.tab.mouseClick" } + assertNotNull(clickEvent, "mouseClick event should be added to current span") + + val attributes = clickEvent.attributes.asMap() + assertEquals(100.0, attributes[doubleKey("x")]) + assertEquals(200.0, attributes[doubleKey("y")]) + assertEquals("LEFT", attributes[stringKey("button")]) + } + + // Helper functions to create OpenTelemetry AttributeKeys + private fun stringKey(name: String) = io.opentelemetry.api.common.AttributeKey.stringKey(name) + private fun boolKey(name: String) = io.opentelemetry.api.common.AttributeKey.booleanKey(name) + private fun doubleKey(name: String) = io.opentelemetry.api.common.AttributeKey.doubleKey(name) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 0cca6f1ff..8d346e427 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,3 +13,4 @@ rootProject.name = "kdriver" includeBuild("cdp-generate") include(":cdp") include(":core") +include(":opentelemetry")