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"); }