From 4d3561c7254c40a97c76b833ecd42ac363d369ee Mon Sep 17 00:00:00 2001 From: sdev Date: Fri, 8 May 2026 13:52:57 +0530 Subject: [PATCH] refactor(demo): replace JamJet workaround with stock Spring Boot OTLP Drops the 73-LOC JamjetCloudConfiguration and the jamjet-cloud-sdk dependency in favor of Spring Boot's standard OTLP tracing autoconfig. The workaround was needed because: 1. jamjet-cloud-spring-boot-starter:0.2.0 hard-references LangChain4j classes, forcing langchain4j-core onto a Spring-AI-only classpath. 2. JamjetObservationHandler.supportsContext was called with a null context name, so the handler never matched. Both are sidestepped by using OTLP instead of the SDK observation handler. With jamjet-cloud PR #16 (OTLP/protobuf intake) deployed to api.jamjet.dev, the demo now wires observability through: Spring AI 1.0 emits Micrometer Observations -> micrometer-tracing-bridge-otel converts to OTel spans -> opentelemetry-exporter-otlp ships OTLP/HTTP-protobuf -> JamJet's /v1/otlp/v1/traces All wiring is in application.yml: management: tracing: sampling: probability: 1.0 otlp: tracing: endpoint: ${JAMJET_API_URL:https://api.jamjet.dev}/v1/otlp/v1/traces headers: Authorization: "Bearer ${JAMJET_API_KEY}" What changed: - pom.xml: remove jamjet-cloud-sdk + micrometer-observation; add micrometer-tracing-bridge-otel + opentelemetry-exporter-otlp - application.yml: drop jamjet.cloud.* block, add management.otlp.tracing.* - Delete src/main/java/.../cloud/JamjetCloudConfiguration.java - DemoIntegrationTest: override OTLP endpoint to localhost:1 and supply a placeholder bearer token so Spring's property resolver doesn't trip on unresolved ${JAMJET_API_KEY} during tests - README + .env.example: reflect the OTLP-based wiring Verified: - mvnw clean compile clean (Java 21 release) - mvnw test -Dtest=PreflightCheckTest: 5/5 pass - mvnw spring-boot:run: app starts on :8080, all autoconfig wires up, PreflightCheck fails on Engram (expected without docker) - DemoIntegrationTest needs Docker (testcontainers); CI will run it --- .../spring-ai-engram-cloud-demo/.env.example | 4 +- .../spring-ai-engram-cloud-demo/README.md | 13 ++-- examples/spring-ai-engram-cloud-demo/pom.xml | 24 +++--- .../cloud/JamjetCloudConfiguration.java | 73 ------------------- .../src/main/resources/application.yml | 17 ++++- .../springaiengram/DemoIntegrationTest.java | 10 ++- 6 files changed, 42 insertions(+), 99 deletions(-) delete mode 100644 examples/spring-ai-engram-cloud-demo/src/main/java/dev/jamjet/demo/springaiengram/cloud/JamjetCloudConfiguration.java diff --git a/examples/spring-ai-engram-cloud-demo/.env.example b/examples/spring-ai-engram-cloud-demo/.env.example index 4603898..69fbfb3 100644 --- a/examples/spring-ai-engram-cloud-demo/.env.example +++ b/examples/spring-ai-engram-cloud-demo/.env.example @@ -2,7 +2,9 @@ OPENAI_API_KEY=sk-... # Sign up at https://cloud.jamjet.dev, create a project, and copy the API key. +# Sent as `Authorization: Bearer ${JAMJET_API_KEY}` on every OTLP trace export. JAMJET_API_KEY=jk_... -# Optional — defaults to the public hosted JamJet Cloud. +# Optional — defaults to the public hosted JamJet Cloud OTLP intake at +# https://api.jamjet.dev. Override for self-hosted deployments. # JAMJET_API_URL=https://api.jamjet.dev diff --git a/examples/spring-ai-engram-cloud-demo/README.md b/examples/spring-ai-engram-cloud-demo/README.md index ab566e1..a456e81 100644 --- a/examples/spring-ai-engram-cloud-demo/README.md +++ b/examples/spring-ai-engram-cloud-demo/README.md @@ -1,12 +1,12 @@ # Spring AI + Engram + JamJet Cloud Demo -A multi-turn chat agent 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 three Spring Boot starters, get durable memory + cloud observability for free. +A multi-turn chat agent 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) — using **stock Spring Boot OTLP tracing**, no custom configuration, no JamJet SDK. ## What this demo shows - **Spring AI 1.0** chat agent using OpenAI for inference - **`dev.jamjet:engram-spring-boot-starter`** autoconfigures `EngramClient` so the agent's `@Tool` methods can record + recall facts against a real Engram server -- **`dev.jamjet:jamjet-cloud-spring-boot-starter`** auto-instruments every chat call + tool span — no code changes +- **Standard Spring Boot OTLP tracing** — Spring AI 1.0 emits Micrometer Observations for every chat call and tool call, `micrometer-tracing-bridge-otel` converts them to OTel spans, and `opentelemetry-exporter-otlp` ships them to JamJet's `/v1/otlp/v1/traces` intake. No JamJet `@Configuration`, no JamJet observation handler, just Spring Boot's standard tracing autoconfig. - **Cross-platform run flow** — works on macOS, Linux, and Windows with the same `mvnw` + `docker compose` commands ## How it's wired @@ -23,7 +23,7 @@ User → POST /chat?session=alice ──→ Spring AI ChatClient └─→ Engram REST API (Docker) ``` -JamJet Cloud's starter watches the whole flow via Spring AI's Micrometer Observation hooks and ships traces + cost rollups to the dashboard. **Zero observability code in your demo.** +Spring AI's Micrometer Observations → OTel spans → OTLP HTTP exporter → `https://api.jamjet.dev/v1/otlp/v1/traces` with the project key as a bearer token. **All wiring is in `application.yml` — zero Java glue.** ## Prerequisites @@ -92,7 +92,7 @@ The interesting code is ~120 LOC across 4 files: | `ChatController.java` | `POST /chat?session=X` — accepts `text/plain`, returns `{"session","reply"}` | | `startup/PreflightCheck.java` | Validates env vars + polls Engram `/health` before the app accepts traffic | -The pom has three starter dependencies. Zero custom plumbing. +There's no `JamjetCloudConfiguration` or observation handler — observability is pure `application.yml`. ## Configuration @@ -101,8 +101,9 @@ The pom has three starter dependencies. Zero custom plumbing. | `engram.base-url` | `http://127.0.0.1:9090` | Where the autoconfigured `EngramClient` connects | | `spring.ai.openai.api-key` | `${OPENAI_API_KEY}` | Spring AI OpenAI key | | `spring.ai.openai.chat.options.model` | `gpt-4o-mini` | OpenAI model for chat | -| `jamjet.cloud.api-key` | `${JAMJET_API_KEY}` | JamJet Cloud project key | -| `jamjet.cloud.api-url` | `https://api.jamjet.dev` | JamJet Cloud ingest endpoint | +| `management.otlp.tracing.endpoint` | `${JAMJET_API_URL}/v1/otlp/v1/traces` | OTLP intake URL — defaults to JamJet's hosted intake | +| `management.otlp.tracing.headers.Authorization` | `Bearer ${JAMJET_API_KEY}` | Per-project bearer token | +| `management.tracing.sampling.probability` | `1.0` | Sample every chat trace (lower for prod) | To swap the chat model (e.g. to `gpt-4o`), edit `application.yml`. To use a different LLM provider for Engram's fact extraction, change `ENGRAM_LLM_PROVIDER` in `docker-compose.yml` — see [Engram's provider docs](https://github.com/jamjet-labs/jamjet/tree/main/runtime/engram-server#llm-providers). diff --git a/examples/spring-ai-engram-cloud-demo/pom.xml b/examples/spring-ai-engram-cloud-demo/pom.xml index 7047a62..9270146 100644 --- a/examples/spring-ai-engram-cloud-demo/pom.xml +++ b/examples/spring-ai-engram-cloud-demo/pom.xml @@ -17,7 +17,6 @@ 21 3.3.5 1.0.0 - 0.2.0 0.2.0 3.9.1 1.21.3 @@ -55,8 +54,8 @@ org.springframework.boot spring-boot-starter-web - + org.springframework.boot spring-boot-starter-actuator @@ -70,19 +69,18 @@ engram-spring-boot-starter ${jamjet-engram.version} - + - dev.jamjet - jamjet-cloud-sdk - ${jamjet-cloud.version} + io.micrometer + micrometer-tracing-bridge-otel - - io.micrometer - micrometer-observation + io.opentelemetry + opentelemetry-exporter-otlp diff --git a/examples/spring-ai-engram-cloud-demo/src/main/java/dev/jamjet/demo/springaiengram/cloud/JamjetCloudConfiguration.java b/examples/spring-ai-engram-cloud-demo/src/main/java/dev/jamjet/demo/springaiengram/cloud/JamjetCloudConfiguration.java deleted file mode 100644 index 0200b65..0000000 --- a/examples/spring-ai-engram-cloud-demo/src/main/java/dev/jamjet/demo/springaiengram/cloud/JamjetCloudConfiguration.java +++ /dev/null @@ -1,73 +0,0 @@ -package dev.jamjet.demo.springaiengram.cloud; - -import dev.jamjet.cloud.JamjetCloud; -import dev.jamjet.cloud.JamjetCloudConfig; -import dev.jamjet.cloud.instrumentation.spring.JamjetObservationHandler; -import io.micrometer.observation.Observation; -import io.micrometer.observation.ObservationHandler; -import io.micrometer.observation.ObservationRegistry; -import jakarta.annotation.PostConstruct; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Configuration; - -/** - * Wires JamJet Cloud observability into the demo. - * - *

We deliberately do NOT use jamjet-cloud-spring-boot-starter:0.2.0 because its - * autoconfig hard-references LangChain4j classes — would force langchain4j-core onto - * the classpath of a Spring-AI-only app. Instead we configure the SDK directly and - * register the Spring AI observation handler explicitly with the ObservationRegistry. - * - *

This pattern can be upstreamed as a "Spring AI only" autoconfig in a future - * cloud starter release. - */ -@Configuration -public class JamjetCloudConfiguration { - - private static final Logger LOG = LoggerFactory.getLogger(JamjetCloudConfiguration.class); - - private final String apiKey; - private final String apiUrl; - private final ObservationRegistry observationRegistry; - - public JamjetCloudConfiguration( - @Value("${jamjet.cloud.api-key}") String apiKey, - @Value("${jamjet.cloud.api-url:https://api.jamjet.dev}") String apiUrl, - ObservationRegistry observationRegistry) { - this.apiKey = apiKey; - this.apiUrl = apiUrl; - this.observationRegistry = observationRegistry; - } - - @PostConstruct - void initJamjetCloud() { - JamjetCloud.configure(JamjetCloudConfig.builder() - .apiKey(apiKey) - .apiUrl(apiUrl) - .agentName("spring-ai-engram-demo") - .build()); - - // Workaround for jamjet-cloud-spring-boot-starter:0.2.0: - // 1. Its autoconfig hard-references LangChain4j classes — would force langchain4j-core - // onto the classpath of a Spring-AI-only app. We bypass it and wire ourselves. - // 2. Its JamjetObservationHandler.supportsContext filters by `name.startsWith("gen_ai.client")`, - // but Micrometer calls supportsContext while the context name is still null — - // the handler would never be selected. We use a context-class filter instead so the - // decision is name-independent. - // Both are filed as upstream followups; remove this @Configuration once they ship. - JamjetObservationHandler jamjet = new JamjetObservationHandler(); - observationRegistry.observationConfig().observationHandler(new ObservationHandler<>() { - @Override - public boolean supportsContext(Observation.Context ctx) { - return ctx != null - && ctx.getClass().getName().equals("org.springframework.ai.chat.observation.ChatModelObservationContext"); - } - @Override public void onStart(Observation.Context ctx) { jamjet.onStart(ctx); } - @Override public void onStop(Observation.Context ctx) { jamjet.onStop(ctx); } - }); - - LOG.info("JamJet Cloud configured -> {} (agent=spring-ai-engram-demo)", apiUrl); - } -} diff --git a/examples/spring-ai-engram-cloud-demo/src/main/resources/application.yml b/examples/spring-ai-engram-cloud-demo/src/main/resources/application.yml index 5c6ddcc..b499fc2 100644 --- a/examples/spring-ai-engram-cloud-demo/src/main/resources/application.yml +++ b/examples/spring-ai-engram-cloud-demo/src/main/resources/application.yml @@ -16,10 +16,19 @@ spring: engram: base-url: ${ENGRAM_BASE_URL:http://127.0.0.1:9090} -jamjet: - cloud: - api-key: ${JAMJET_API_KEY} - api-url: ${JAMJET_API_URL:https://api.jamjet.dev} +# Spring Boot tracing → JamJet Cloud over OTLP/HTTP-protobuf. +# Spring AI 1.0 emits Micrometer Observations for every chat call + tool call; +# micrometer-tracing-bridge-otel turns them into OTel spans; the OTLP exporter +# ships them to /v1/otlp/v1/traces with the project key as a bearer token. +management: + tracing: + sampling: + probability: 1.0 + otlp: + tracing: + endpoint: ${JAMJET_API_URL:https://api.jamjet.dev}/v1/otlp/v1/traces + headers: + Authorization: "Bearer ${JAMJET_API_KEY}" app: engram: diff --git a/examples/spring-ai-engram-cloud-demo/src/test/java/dev/jamjet/demo/springaiengram/DemoIntegrationTest.java b/examples/spring-ai-engram-cloud-demo/src/test/java/dev/jamjet/demo/springaiengram/DemoIntegrationTest.java index 1314aec..2336627 100644 --- a/examples/spring-ai-engram-cloud-demo/src/test/java/dev/jamjet/demo/springaiengram/DemoIntegrationTest.java +++ b/examples/spring-ai-engram-cloud-demo/src/test/java/dev/jamjet/demo/springaiengram/DemoIntegrationTest.java @@ -69,8 +69,14 @@ static void overrideProps(DynamicPropertyRegistry registry) { registry.add("spring.ai.openai.api-key", () -> "sk-test"); registry.add("engram.base-url", () -> "http://" + engram.getHost() + ":" + engram.getMappedPort(9090)); - registry.add("jamjet.cloud.api-key", () -> "jk_test"); - registry.add("jamjet.cloud.api-url", () -> "http://localhost:1"); + // OTLP exporter: point at a black hole + supply a placeholder bearer + // token so Spring's property resolver doesn't trip on the unresolved + // ${JAMJET_API_KEY} reference in application.yml. The exporter retries + // in the background and won't fail the test if the endpoint is + // unreachable; this just prevents accidental traffic to prod. + registry.add("management.otlp.tracing.endpoint", () -> "http://127.0.0.1:1/v1/otlp/v1/traces"); + registry.add("management.otlp.tracing.headers.Authorization", () -> "Bearer jk_test"); + registry.add("management.tracing.sampling.probability", () -> "0.0"); registry.add("app.engram.health-url", () -> "http://" + engram.getHost() + ":" + engram.getMappedPort(9090) + "/health"); }