Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions examples/kotlin-koog-engram-cloud-demo/README.md
Original file line number Diff line number Diff line change
@@ -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`

Expand All @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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"}` |
Expand All @@ -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

Expand Down
6 changes: 4 additions & 2 deletions examples/kotlin-koog-engram-cloud-demo/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<packaging>jar</packaging>

<name>Kotlin + Koog + Engram + JamJet Cloud Demo</name>
<description>Reference demo: Koog (Kotlin agent framework) with Engram durable memory, observed by JamJet Cloud via OTLP/JSON</description>
<description>Reference demo: Koog (Kotlin agent framework) with Engram durable memory, observed by JamJet Cloud via stock OpenTelemetry OTLP exporter</description>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
Expand Down Expand Up @@ -61,7 +61,9 @@
<version>${koog.version}</version>
</dependency>
<!-- Koog OpenTelemetry feature: provides OpenTelemetryConfig + the addSpanExporter hook
that JamjetCloudExporter plugs a custom OTLP/JSON exporter into. -->
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. -->
<dependency>
<groupId>ai.koog</groupId>
<artifactId>agents-features-opentelemetry-jvm</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <api-key>`.
* `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 <api-key>`.
*
* 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
Expand Down Expand Up @@ -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<SpanData>): 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<SpanData>): Map<String, Any> {
// 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<String, Any>("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<String, Any?> = 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<String, Any>("code" to span.status.statusCode.ordinal).apply {
span.status.description.takeIf { it.isNotEmpty() }?.let { put("message", it) }
},
)
}

private fun attributesToJson(attrs: Map<io.opentelemetry.api.common.AttributeKey<*>, Any>): List<Map<String, Any>> =
attrs.map { (key, value) ->
mapOf("key" to key.key, "value" to anyValue(value))
}

private fun anyValue(value: Any?): Map<String, Any> = 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())
}
)
}
Loading