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