diff --git a/examples/kotlin-koog-engram-cloud-demo/README.md b/examples/kotlin-koog-engram-cloud-demo/README.md index ddcf1ee..3f867a2 100644 --- a/examples/kotlin-koog-engram-cloud-demo/README.md +++ b/examples/kotlin-koog-engram-cloud-demo/README.md @@ -1,13 +1,13 @@ # Kotlin + Koog + Engram + JamJet Cloud Demo -A multi-turn chat agent built on **[Koog](https://github.com/JetBrains/koog) 0.8** (JetBrains' Kotlin agent framework) that **remembers facts across calls** via [Engram](https://github.com/jamjet-labs/jamjet/tree/main/runtime/engram-server) and is **observed end-to-end** by [JamJet Cloud](https://cloud.jamjet.dev) — drop in a single 25-line extension function and your Koog agent is shipping OTLP traces. +A multi-turn chat agent built on **[Koog](https://github.com/JetBrains/koog) 0.8** (JetBrains' Kotlin agent framework) that **remembers facts across calls** via [Engram](https://github.com/jamjet-labs/jamjet/tree/main/runtime/engram-server) and is **observed end-to-end** by [JamJet Cloud](https://cloud.jamjet.dev) — drop in a single ~25-line extension function and your Koog agent is shipping OTLP traces. ## What this demo shows - **Koog 0.8** Kotlin-native agent — `AIAgent` constructor with OpenAI executor, `singleRunStrategy`, and a `ToolRegistry` - **`koog-spring-boot-starter`** autoconfigures the OpenAI `SingleLLMPromptExecutor` from `ai.koog.openai.api-key` - **`engram-spring-boot-starter`** autoconfigures `EngramClient` so the agent's `@Tool` methods record + recall facts against a real Engram server -- **One extension function — `addJamjetCloudExporter()`** — wires Koog's built-in OpenTelemetry feature to JamJet Cloud's OTLP/JSON intake. No Micrometer, no Spring AI observation handlers, no per-vendor SDK. +- **One extension function — `addJamjetCloudExporter()`** — wires Koog's built-in OpenTelemetry feature to JamJet Cloud's OTLP/HTTP-protobuf intake. Uses the stock `OtlpHttpSpanExporter` already on Koog's classpath; no custom marshaler, no proprietary SDK. ## The headline file: `JamjetCloudExporter.kt` @@ -18,23 +18,23 @@ The entire JamJet integration is one extension on Koog's `OpenTelemetryConfig`: public fun OpenTelemetryConfig.addJamjetCloudExporter( apiKey: String? = null, apiUrl: String = "https://api.jamjet.dev", - timeout: Duration = 10.seconds, + timeout: Duration = Duration.ofSeconds(10), ) { val key = apiKey - ?: getEnvironmentVariableOrNull("JAMJET_API_KEY") + ?: System.getenv("JAMJET_API_KEY") ?: error("JAMJET_API_KEY is missing.") addSpanExporter( - OtlpJsonSpanExporter( - endpoint = "$apiUrl/v1/otlp/v1/traces", - headers = mapOf("Authorization" to "Bearer $key"), - timeout = timeout, - ) + OtlpHttpSpanExporter.builder() + .setEndpoint("$apiUrl/v1/otlp/v1/traces") + .addHeader("Authorization", "Bearer $key") + .setTimeout(timeout) + .build() ) } ``` -That's it. Mirrors the `addDatadogExporter` / `addLangfuseExporter` pattern that ships with Koog — plug it into any Koog agent's `install(OpenTelemetry) { ... }` block and JamJet receives every LLM span, tool span, and cost rollup. +That's it. Mirrors the `addDatadogExporter` / `addLangfuseExporter` pattern that ships with Koog — plug it into any Koog agent's `install(OpenTelemetry) { ... }` block and JamJet receives every LLM span, tool span, and cost rollup. The exporter is the standard `io.opentelemetry:opentelemetry-exporter-otlp` already pulled in by `agents-features-opentelemetry-jvm:0.8.0`. ## How it's wired @@ -49,10 +49,10 @@ User → POST /chat?session=alice ──→ Koog AIAgent │ └─→ OpenTelemetry feature → addJamjetCloudExporter() │ - └─→ OTLP/JSON → JamJet Cloud + └─→ OTLP/HTTP-protobuf → JamJet Cloud ``` -JamJet Cloud's OTLP/JSON intake (`POST /v1/otlp/v1/traces`) ingests Koog's spans directly — no per-framework adapter, no proprietary SDK on the agent side. +JamJet Cloud's OTLP intake (`POST /v1/otlp/v1/traces`) ingests Koog's spans directly — no per-framework adapter, no proprietary SDK on the agent side. ## Prerequisites @@ -112,11 +112,11 @@ Open [cloud.jamjet.dev/dashboard/graph](https://cloud.jamjet.dev/dashboard/graph ## Anatomy -The interesting code is ~150 LOC across 5 Kotlin files: +The interesting code is ~50 LOC across 5 Kotlin files: | File | What it does | |---|---| -| `cloud/JamjetCloudExporter.kt` | The 25-line extension function — adds JamJet to any Koog agent's `OpenTelemetry` config | +| `cloud/JamjetCloudExporter.kt` | The ~25-line extension function — adds JamJet to any Koog agent's `OpenTelemetry` config | | `MemoryTools.kt` | `ToolSet` with `@Tool`+`@LLMDescription` methods backed by autoconfigured `EngramClient` | | `MemoryAgent.kt` | Builds a Koog `AIAgent` per request: OpenAI executor + tools + `install(OpenTelemetry)` | | `ChatController.kt` | `POST /chat?session=X` — accepts `text/plain`, returns `{"session","reply"}` | @@ -137,7 +137,7 @@ To swap the chat model (e.g. to `OpenAIModels.Chat.GPT4o`), edit `MemoryAgent.kt ## How is this different from Track 1's Java/Spring AI demo? -[Track 1 (`spring-ai-engram-cloud-demo`)](../spring-ai-engram-cloud-demo) targets Spring AI users — its observability is wired through Spring AI's Micrometer Observation hooks via `jamjet-cloud-spring-boot-starter`. This Kotlin track targets Koog users — observability is wired through Koog's built-in OpenTelemetry feature via vendor-neutral OTLP/JSON. **JamJet Cloud sees both flavours of trace identically** (same `service.name`, same span shape, same cost rollups) because both end up at the OTLP intake. Pick the demo that matches your runtime. +[Track 1 (`spring-ai-engram-cloud-demo`)](../spring-ai-engram-cloud-demo) targets Spring AI users — observability is wired through Spring Boot's standard OTLP tracing autoconfig (Micrometer Observation → OTel span → OTLP exporter, all in `application.yml`). This Kotlin track targets Koog users — observability is wired through Koog's built-in OpenTelemetry feature via the same stock `OtlpHttpSpanExporter`. **JamJet Cloud sees both flavours of trace identically** (same `service.name`, same span shape, same cost rollups) because both end up at the OTLP intake. Pick the demo that matches your runtime. ## Windows notes diff --git a/examples/kotlin-koog-engram-cloud-demo/pom.xml b/examples/kotlin-koog-engram-cloud-demo/pom.xml index 94edb50..8957900 100644 --- a/examples/kotlin-koog-engram-cloud-demo/pom.xml +++ b/examples/kotlin-koog-engram-cloud-demo/pom.xml @@ -10,7 +10,7 @@ jar Kotlin + Koog + Engram + JamJet Cloud Demo - Reference demo: Koog (Kotlin agent framework) with Engram durable memory, observed by JamJet Cloud via OTLP/JSON + Reference demo: Koog (Kotlin agent framework) with Engram durable memory, observed by JamJet Cloud via stock OpenTelemetry OTLP exporter UTF-8 @@ -61,7 +61,9 @@ ${koog.version} + that JamjetCloudExporter plugs the stock OtlpHttpSpanExporter into. Pulls in + io.opentelemetry:opentelemetry-exporter-otlp:1.49 transitively, so no additional + OTel deps need to be declared here. --> ai.koog agents-features-opentelemetry-jvm diff --git a/examples/kotlin-koog-engram-cloud-demo/src/main/kotlin/dev/jamjet/demo/koogengram/MemoryAgent.kt b/examples/kotlin-koog-engram-cloud-demo/src/main/kotlin/dev/jamjet/demo/koogengram/MemoryAgent.kt index 7a35d2d..2d7b6b2 100644 --- a/examples/kotlin-koog-engram-cloud-demo/src/main/kotlin/dev/jamjet/demo/koogengram/MemoryAgent.kt +++ b/examples/kotlin-koog-engram-cloud-demo/src/main/kotlin/dev/jamjet/demo/koogengram/MemoryAgent.kt @@ -13,7 +13,7 @@ import org.springframework.stereotype.Service /** * A Koog [AIAgent] wired with Engram-backed memory tools and OpenTelemetry - * traces shipped to JamJet Cloud via OTLP/JSON. + * traces shipped to JamJet Cloud via the standard OTLP/HTTP-protobuf exporter. * * The autoconfigured `MultiLLMPromptExecutor` (provided by `koog-spring-boot-starter`) * routes to whichever LLM client beans are present — here just OpenAI, configured diff --git a/examples/kotlin-koog-engram-cloud-demo/src/main/kotlin/dev/jamjet/demo/koogengram/cloud/JamjetCloudExporter.kt b/examples/kotlin-koog-engram-cloud-demo/src/main/kotlin/dev/jamjet/demo/koogengram/cloud/JamjetCloudExporter.kt index fe7c96f..0856353 100644 --- a/examples/kotlin-koog-engram-cloud-demo/src/main/kotlin/dev/jamjet/demo/koogengram/cloud/JamjetCloudExporter.kt +++ b/examples/kotlin-koog-engram-cloud-demo/src/main/kotlin/dev/jamjet/demo/koogengram/cloud/JamjetCloudExporter.kt @@ -1,31 +1,26 @@ package dev.jamjet.demo.koogengram.cloud -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import io.opentelemetry.sdk.common.CompletableResultCode -import io.opentelemetry.sdk.trace.data.SpanData -import io.opentelemetry.sdk.trace.export.SpanExporter import ai.koog.agents.features.opentelemetry.feature.OpenTelemetryConfig -import org.slf4j.LoggerFactory -import java.net.URI -import java.net.http.HttpClient -import java.net.http.HttpRequest -import java.net.http.HttpResponse +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter import java.time.Duration -import java.util.HexFormat private const val DEFAULT_JAMJET_URL = "https://api.jamjet.dev" private val DEFAULT_TIMEOUT: Duration = Duration.ofSeconds(10) /** * Configure an OpenTelemetry span exporter that ships agent traces to - * [JamJet Cloud](https://cloud.jamjet.dev) via direct OTLP/JSON intake. + * [JamJet Cloud](https://cloud.jamjet.dev) via OTLP/HTTP-protobuf intake. * * Mirrors the DataDog / Langfuse pattern that ships with Koog * (`addDatadogExporter`, `addLangfuseExporter` in - * [ai.koog.agents.features.opentelemetry.integration]): a thin extension on - * [OpenTelemetryConfig] that registers an OTLP-shaped [SpanExporter] pointed at - * JamJet's `/v1/otlp/v1/traces` endpoint with `Authorization: Bearer `. + * `ai.koog.agents.features.opentelemetry.integration`): a thin extension on + * [OpenTelemetryConfig] that registers the standard OTel HTTP span exporter + * pointed at JamJet's `/v1/otlp/v1/traces` endpoint with + * `Authorization: Bearer `. + * + * Uses the stock `OtlpHttpSpanExporter` (already on the classpath via + * `agents-features-opentelemetry-jvm:0.8.0` → `opentelemetry-exporter-otlp:1.49`) + * so this file is wiring only — no custom marshaler, no HTTP client. * * Registered via [addSpanExporter][OpenTelemetryConfig.addSpanExporter], which * wraps the exporter in a batch span processor — the cloud HTTP round-trip @@ -69,160 +64,11 @@ public fun OpenTelemetryConfig.addJamjetCloudExporter( "Sign up at https://cloud.jamjet.dev to create a project key." ) - addSpanExporter(JamjetOtlpJsonSpanExporter("$apiUrl/v1/otlp/v1/traces", key, timeout)) -} - -/** - * Custom OTLP/JSON [SpanExporter] for JamJet Cloud's intake endpoint. - * - * The Java OTel SDK 1.37 ships only an OTLP/protobuf HTTP exporter, so we marshal - * spans to OTLP/JSON ourselves. The wire format follows the OTLP spec - * (https://opentelemetry.io/docs/specs/otlp/#json-protobuf-encoding): - * camelCase field names, hex-encoded `traceId` / `spanId`, int64 fields encoded - * as strings to preserve precision in JS clients. - */ -internal class JamjetOtlpJsonSpanExporter( - private val endpoint: String, - apiKey: String, - timeout: Duration, -) : SpanExporter { - - private val authHeader: String = "Bearer $apiKey" - private val httpClient: HttpClient = HttpClient.newBuilder() - .connectTimeout(timeout) - .build() - private val requestTimeout: Duration = timeout - // OTLP/JSON spec: omit absent fields; we already build maps with only non-null values. - private val mapper: ObjectMapper = jacksonObjectMapper() - - override fun export(spans: Collection): CompletableResultCode { - if (spans.isEmpty()) return CompletableResultCode.ofSuccess() - - val payload = OtlpJsonMarshaler.toExportRequest(spans) - val body = mapper.writeValueAsBytes(payload) - - val request = HttpRequest.newBuilder() - .uri(URI.create(endpoint)) - .timeout(requestTimeout) - .header("Content-Type", "application/json") - .header("Authorization", authHeader) - .header("User-Agent", "jamjet-koog-otlp-exporter/1.0") - .POST(HttpRequest.BodyPublishers.ofByteArray(body)) + addSpanExporter( + OtlpHttpSpanExporter.builder() + .setEndpoint("$apiUrl/v1/otlp/v1/traces") + .addHeader("Authorization", "Bearer $key") + .setTimeout(timeout) .build() - - val result = CompletableResultCode() - httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) - .whenComplete { response, error -> - when { - error != null -> { - log.warn("OTLP/JSON export to {} failed: {}", endpoint, error.toString()) - result.fail() - } - response.statusCode() in 200..299 -> result.succeed() - else -> { - log.warn( - "OTLP/JSON export to {} failed: HTTP {} body={}", - endpoint, response.statusCode(), - response.body().take(MAX_LOG_BODY_CHARS), - ) - result.fail() - } - } - } - return result - } - - override fun flush(): CompletableResultCode = CompletableResultCode.ofSuccess() - - override fun shutdown(): CompletableResultCode = CompletableResultCode.ofSuccess() - - private companion object { - private val log = LoggerFactory.getLogger(JamjetOtlpJsonSpanExporter::class.java) - private const val MAX_LOG_BODY_CHARS = 500 - } -} - -/** - * Pure functions that marshal Java OTel `SpanData` into the OTLP/JSON wire shape. - * Kept separate from the [SpanExporter] so it's easy to unit-test without HTTP. - */ -internal object OtlpJsonMarshaler { - - private val HEX = HexFormat.of() - - fun toExportRequest(spans: Collection): Map { - // Group by Resource — each unique resource becomes one resourceSpans entry. - val byResource = spans.groupBy { it.resource } - val resourceSpans = byResource.map { (resource, resourceSpans) -> - // Then group by InstrumentationScope. - val byScope = resourceSpans.groupBy { it.instrumentationScopeInfo } - mapOf( - "resource" to mapOf( - "attributes" to attributesToJson(resource.attributes.asMap()), - ), - "scopeSpans" to byScope.map { (scope, scopeSpans) -> - mapOf( - "scope" to mutableMapOf("name" to scope.name).apply { - scope.version?.let { put("version", it) } - }, - "spans" to scopeSpans.map(::spanToJson), - ) - }, - ) - } - return mapOf("resourceSpans" to resourceSpans) - } - - private fun spanToJson(span: SpanData): Map = buildMap { - put("traceId", span.traceId) - put("spanId", span.spanId) - if (span.parentSpanContext.isValid) put("parentSpanId", span.parentSpanId) - put("name", span.name) - put("kind", span.kind.ordinal + 1) // OTLP SpanKind: 1=INTERNAL, 2=SERVER, 3=CLIENT, ... - put("startTimeUnixNano", span.startEpochNanos.toString()) - put("endTimeUnixNano", span.endEpochNanos.toString()) - put("attributes", attributesToJson(span.attributes.asMap())) - if (span.events.isNotEmpty()) { - put("events", span.events.map { ev -> - mapOf( - "timeUnixNano" to ev.epochNanos.toString(), - "name" to ev.name, - "attributes" to attributesToJson(ev.attributes.asMap()), - ) - }) - } - if (span.links.isNotEmpty()) { - put("links", span.links.map { link -> - mapOf( - "traceId" to link.spanContext.traceId, - "spanId" to link.spanContext.spanId, - "attributes" to attributesToJson(link.attributes.asMap()), - ) - }) - } - put( - "status", - mutableMapOf("code" to span.status.statusCode.ordinal).apply { - span.status.description.takeIf { it.isNotEmpty() }?.let { put("message", it) } - }, - ) - } - - private fun attributesToJson(attrs: Map, Any>): List> = - attrs.map { (key, value) -> - mapOf("key" to key.key, "value" to anyValue(value)) - } - - private fun anyValue(value: Any?): Map = when (value) { - null -> mapOf("stringValue" to "") - is String -> mapOf("stringValue" to value) - is Boolean -> mapOf("boolValue" to value) - is Long -> mapOf("intValue" to value.toString()) - is Int -> mapOf("intValue" to value.toString()) - is Double -> mapOf("doubleValue" to value) - is Float -> mapOf("doubleValue" to value.toDouble()) - is ByteArray -> mapOf("stringValue" to HEX.formatHex(value)) - is List<*> -> mapOf("arrayValue" to mapOf("values" to value.map { anyValue(it) })) - else -> mapOf("stringValue" to value.toString()) - } + ) }