diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index ee397a7..0e558fc 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,57 @@ That is the entire integration. No schema changes, no new infrastructure. | `jamjet-runtime-plugins` | ClassLoader-isolated plugin system with ServiceLoader and hot-reload | | `jamjet-runtime-server` | Standalone Javalin REST server — same API surface as the Rust runtime | | `jamjet-spring-boot-starter` | Spring Boot auto-configuration: embeds the runtime as Spring beans | +| `jamjet-cloud-sdk` | Drop-in observability for Spring AI / LangChain4j (mirrors Python `jamjet.cloud`) | +| `jamjet-cloud-spring-boot-starter` | Spring Boot auto-config for the Cloud SDK | + +--- + +## JamJet Cloud SDK + +Drop-in observability for Spring AI and LangChain4j agents. Mirrors the Python `jamjet.cloud` SDK. + +### Spring Boot + +```xml + + dev.jamjet + jamjet-cloud-spring-boot-starter + 0.2.0 + +``` + +```yaml +# application.yml +jamjet: + cloud: + api-key: ${JJ_API_KEY} + project: my-app +``` + +That's it. Every Spring AI `ChatClient` / `ChatModel` call and every LangChain4j `ChatLanguageModel` call is captured automatically. + +### Plain Java (LangChain4j without Spring) + +```xml + + dev.jamjet + jamjet-cloud-sdk + 0.2.0 + +``` + +```java +JamjetCloud.configure(JamjetCloudConfig.builder() + .apiKey(System.getenv("JJ_API_KEY")) + .project("my-app") + .build()); + +var model = OpenAiChatModel.builder() + .apiKey(System.getenv("OPENAI_API_KEY")) + .modelName("gpt-4o-mini") + .listeners(List.of(new JamjetChatModelListener())) + .build(); +``` --- diff --git a/jamjet-cloud-sdk/pom.xml b/jamjet-cloud-sdk/pom.xml index da671a7..099e0f1 100644 --- a/jamjet-cloud-sdk/pom.xml +++ b/jamjet-cloud-sdk/pom.xml @@ -7,7 +7,7 @@ dev.jamjet jamjet-runtime-java-parent - 0.1.1 + 0.2.0 jamjet-cloud-sdk @@ -35,11 +35,50 @@ slf4j-api + + + io.micrometer + micrometer-observation + 1.13.6 + true + + + + + dev.langchain4j + langchain4j-core + 0.36.2 + true + + org.junit.jupiter junit-jupiter test + + org.assertj + assertj-core + test + + + dev.langchain4j + langchain4j-open-ai + 0.36.2 + test + + + org.wiremock + wiremock-standalone + 3.9.2 + test + + + org.awaitility + awaitility + 4.2.2 + test + diff --git a/jamjet-cloud-sdk/src/main/java/dev/jamjet/cloud/FailureModeClassifier.java b/jamjet-cloud-sdk/src/main/java/dev/jamjet/cloud/FailureModeClassifier.java new file mode 100644 index 0000000..90872e3 --- /dev/null +++ b/jamjet-cloud-sdk/src/main/java/dev/jamjet/cloud/FailureModeClassifier.java @@ -0,0 +1,43 @@ +package dev.jamjet.cloud; + +import java.net.SocketTimeoutException; +import java.net.http.HttpTimeoutException; +import java.time.Duration; +import java.util.regex.Pattern; + +/** + * Maps a Throwable to a string failure_mode that matches the cloud's + * events_failure_mode_check constraint and the Python SDK enum. + * + *

Cross-SDK contract: same string values for rate_limit, auth, timeout, + * bad_request, server_error, unknown. + */ +public final class FailureModeClassifier { + + private static final Pattern STATUS_4XX_5XX = Pattern.compile("(?i)\\b(?:HTTP\\s+|status(?:\\s+code)?\\s*:?\\s*)?([45]\\d{2})\\b"); + + private FailureModeClassifier() {} + + public static String classify(Throwable t) { + return classify(t, null); + } + + public static String classify(Throwable t, Duration ignored) { + if (t == null) return "unknown"; + if (t instanceof SocketTimeoutException || t instanceof HttpTimeoutException) { + return "timeout"; + } + String msg = t.getMessage(); + if (msg != null) { + var m = STATUS_4XX_5XX.matcher(msg); + if (m.find()) { + int status = Integer.parseInt(m.group(1)); + if (status == 429) return "rate_limit"; + if (status == 401 || status == 403) return "auth"; + if (status >= 400 && status < 500) return "bad_request"; + if (status >= 500) return "server_error"; + } + } + return "unknown"; + } +} diff --git a/jamjet-cloud-sdk/src/main/java/dev/jamjet/cloud/JamjetCloud.java b/jamjet-cloud-sdk/src/main/java/dev/jamjet/cloud/JamjetCloud.java index e17c9a1..0d1b91f 100644 --- a/jamjet-cloud-sdk/src/main/java/dev/jamjet/cloud/JamjetCloud.java +++ b/jamjet-cloud-sdk/src/main/java/dev/jamjet/cloud/JamjetCloud.java @@ -110,7 +110,8 @@ static CloudHttpClient httpClient() { return httpClient; } - static JamjetCloudConfig config() { + /** Public for instrumentation modules outside this package. May return null before configure(). */ + public static JamjetCloudConfig config() { return config; } diff --git a/jamjet-cloud-sdk/src/main/java/dev/jamjet/cloud/Span.java b/jamjet-cloud-sdk/src/main/java/dev/jamjet/cloud/Span.java index aebadc4..e599799 100644 --- a/jamjet-cloud-sdk/src/main/java/dev/jamjet/cloud/Span.java +++ b/jamjet-cloud-sdk/src/main/java/dev/jamjet/cloud/Span.java @@ -29,6 +29,7 @@ public final class Span { private Double costUsd; private String status = "pending"; private String failureMode; + private java.util.Map payload; Span(String traceId, String spanId, String kind, String name, int sequence, String agentName, String agentCardUri) { @@ -48,6 +49,10 @@ public final class Span { public Span outputTokens(long v) { this.outputTokens = v; return this; } public Span costUsd(double v) { this.costUsd = v; return this; } + public Span payload(java.util.Map v) { this.payload = v; return this; } + + public java.util.Map payload() { return payload; } + /** Mark the span complete with status="ok" and emit it. */ public void finish() { finish("ok"); @@ -71,7 +76,7 @@ public void fail(String mode) { finish("error"); } - Map toEventMap() { + public Map toEventMap() { Map m = new LinkedHashMap<>(); m.put("type", "span"); m.put("trace_id", traceId); @@ -89,6 +94,7 @@ Map toEventMap() { if (agentName != null) m.put("agent_name", agentName); if (agentCardUri != null) m.put("agent_card_uri", agentCardUri); if (failureMode != null) m.put("failure_mode", failureMode); + if (payload != null) m.put("payload", payload); return m; } diff --git a/jamjet-cloud-sdk/src/main/java/dev/jamjet/cloud/instrumentation/langchain4j/JamjetChatModelListener.java b/jamjet-cloud-sdk/src/main/java/dev/jamjet/cloud/instrumentation/langchain4j/JamjetChatModelListener.java new file mode 100644 index 0000000..98640e2 --- /dev/null +++ b/jamjet-cloud-sdk/src/main/java/dev/jamjet/cloud/instrumentation/langchain4j/JamjetChatModelListener.java @@ -0,0 +1,94 @@ +package dev.jamjet.cloud.instrumentation.langchain4j; + +import dev.jamjet.cloud.FailureModeClassifier; +import dev.jamjet.cloud.JamjetCloud; +import dev.jamjet.cloud.JamjetCloudConfig; +import dev.jamjet.cloud.Span; +import dev.langchain4j.model.chat.listener.ChatModelErrorContext; +import dev.langchain4j.model.chat.listener.ChatModelListener; +import dev.langchain4j.model.chat.listener.ChatModelRequestContext; +import dev.langchain4j.model.chat.listener.ChatModelResponseContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Bridges LangChain4j {@code ChatModelListener} callbacks to the JamJet + * Cloud event stream. Mirrors {@code JamjetObservationHandler} for the + * Spring AI path — same kind ("llm_call"), same payload shape. + * + *

Honors {@link JamjetCloudConfig#captureIo()}: when false, prompt and + * response payloads are omitted (server-side redaction is the authoritative + * backstop on Team+ tier). + */ +public final class JamjetChatModelListener implements ChatModelListener { + + private static final Logger LOG = LoggerFactory.getLogger(JamjetChatModelListener.class); + private static final String SCOPE_KEY = "jamjet.span"; + + @Override + public void onRequest(ChatModelRequestContext rc) { + try { + String model = rc.request().model(); + String name = "langchain4j." + (model != null ? model : "chat"); + Span span = JamjetCloud.newSpan("llm_call", name); + if (model != null) span.model(model); + if (captureIo()) { + Map p = new LinkedHashMap<>(); + p.put("messages", rc.request().messages()); + span.payload(p); + } + rc.attributes().put(SCOPE_KEY, span); + } catch (Throwable t) { + LOG.warn("JamjetChatModelListener.onRequest failed", t); + } + } + + @Override + public void onResponse(ChatModelResponseContext rc) { + Span span = (Span) rc.attributes().get(SCOPE_KEY); + if (span == null) return; + try { + var usage = rc.response().tokenUsage(); + if (usage != null) { + if (usage.inputTokenCount() != null) span.inputTokens(usage.inputTokenCount()); + if (usage.outputTokenCount() != null) span.outputTokens(usage.outputTokenCount()); + } + if (captureIo()) { + @SuppressWarnings("unchecked") + Map p = (Map) span.payload(); + if (p == null) { + p = new LinkedHashMap<>(); + span.payload(p); + } + if (rc.response().aiMessage() != null) { + p.put("response", rc.response().aiMessage().text()); + } + } + span.finish("ok"); + } catch (Throwable t) { + LOG.warn("JamjetChatModelListener.onResponse failed", t); + try { span.finish("error"); } catch (Throwable ignored) {} + } + } + + @Override + public void onError(ChatModelErrorContext rc) { + Span span = (Span) rc.attributes().get(SCOPE_KEY); + if (span == null) return; + try { + span.payload(null); + span.fail(FailureModeClassifier.classify(rc.error())); + } catch (Throwable t) { + LOG.warn("JamjetChatModelListener.onError failed", t); + try { span.finish("error"); } catch (Throwable ignored) {} + } + } + + private static boolean captureIo() { + JamjetCloudConfig cfg = JamjetCloud.config(); + return cfg != null && cfg.captureIo(); + } +} diff --git a/jamjet-cloud-sdk/src/main/java/dev/jamjet/cloud/instrumentation/spring/JamjetObservationHandler.java b/jamjet-cloud-sdk/src/main/java/dev/jamjet/cloud/instrumentation/spring/JamjetObservationHandler.java new file mode 100644 index 0000000..45a52b2 --- /dev/null +++ b/jamjet-cloud-sdk/src/main/java/dev/jamjet/cloud/instrumentation/spring/JamjetObservationHandler.java @@ -0,0 +1,86 @@ +package dev.jamjet.cloud.instrumentation.spring; + +import dev.jamjet.cloud.FailureModeClassifier; +import dev.jamjet.cloud.JamjetCloud; +import dev.jamjet.cloud.Span; +import io.micrometer.common.KeyValue; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Bridges Spring AI Micrometer observations to the JamJet Cloud event stream. + * + *

Filters on observations whose name starts with {@code gen_ai.client}. + * Spring AI emits these for every {@code ChatModel} and {@code ChatClient} + * call (Spring AI 1.0+). Reads model + token counts from Micrometer + * KeyValues, opens a Span on start, finishes it on stop. + */ +public final class JamjetObservationHandler implements ObservationHandler { + + private static final Logger LOG = LoggerFactory.getLogger(JamjetObservationHandler.class); + private static final String SCOPE_KEY = "jamjet.span"; + + @Override + public boolean supportsContext(Observation.Context ctx) { + return ctx != null && ctx.getName() != null && ctx.getName().startsWith("gen_ai.client"); + } + + @Override + public void onStart(Observation.Context ctx) { + try { + String model = readKey(ctx, "gen_ai.request.model"); + String provider = readKeyOr(ctx, "gen_ai.system", "unknown"); + String name = (model != null) ? provider + "." + model : provider + ".chat"; + Span span = JamjetCloud.newSpan("llm_call", name); + ctx.put(SCOPE_KEY, span); + } catch (Throwable t) { + LOG.warn("JamjetObservationHandler.onStart failed", t); + } + } + + @Override + public void onStop(Observation.Context ctx) { + Span span = (Span) ctx.get(SCOPE_KEY); + if (span == null) return; + try { + String model = readKey(ctx, "gen_ai.request.model"); + if (model != null) span.model(model); + Long in = readLong(ctx, "gen_ai.usage.input_tokens"); + Long out = readLong(ctx, "gen_ai.usage.output_tokens"); + if (in != null) span.inputTokens(in); + if (out != null) span.outputTokens(out); + + if (ctx.getError() != null) { + span.fail(FailureModeClassifier.classify(ctx.getError())); + } else { + span.finish("ok"); + } + } catch (Throwable t) { + LOG.warn("JamjetObservationHandler.onStop failed", t); + try { span.finish("error"); } catch (Throwable ignored) {} + } + } + + private static String readKey(Observation.Context ctx, String key) { + for (KeyValue kv : ctx.getLowCardinalityKeyValues()) { + if (key.equals(kv.getKey())) return kv.getValue(); + } + for (KeyValue kv : ctx.getHighCardinalityKeyValues()) { + if (key.equals(kv.getKey())) return kv.getValue(); + } + return null; + } + + private static String readKeyOr(Observation.Context ctx, String key, String fallback) { + String v = readKey(ctx, key); + return v != null ? v : fallback; + } + + private static Long readLong(Observation.Context ctx, String key) { + String v = readKey(ctx, key); + if (v == null) return null; + try { return Long.parseLong(v); } catch (NumberFormatException e) { return null; } + } +} diff --git a/jamjet-cloud-sdk/src/test/java/dev/jamjet/cloud/FailureModeClassifierTest.java b/jamjet-cloud-sdk/src/test/java/dev/jamjet/cloud/FailureModeClassifierTest.java new file mode 100644 index 0000000..a7d3922 --- /dev/null +++ b/jamjet-cloud-sdk/src/test/java/dev/jamjet/cloud/FailureModeClassifierTest.java @@ -0,0 +1,40 @@ +package dev.jamjet.cloud; + +import org.junit.jupiter.api.Test; + +import java.net.SocketTimeoutException; +import java.net.http.HttpTimeoutException; +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class FailureModeClassifierTest { + + @Test + void classifiesTimeouts() { + assertEquals("timeout", FailureModeClassifier.classify(new SocketTimeoutException("read timed out"))); + assertEquals("timeout", FailureModeClassifier.classify(HttpTimeoutException.class.cast( + new HttpTimeoutException("request timed out")))); + } + + @Test + void classifiesByMessageHttpStatus() { + assertEquals("rate_limit", FailureModeClassifier.classify(new RuntimeException("HTTP 429 Too Many Requests"))); + assertEquals("auth", FailureModeClassifier.classify(new RuntimeException("status 401 unauthorized"))); + assertEquals("auth", FailureModeClassifier.classify(new RuntimeException("403 forbidden"))); + assertEquals("bad_request", FailureModeClassifier.classify(new RuntimeException("status code: 400"))); + assertEquals("server_error", FailureModeClassifier.classify(new RuntimeException("HTTP 503"))); + } + + @Test + void unknownByDefault() { + assertEquals("unknown", FailureModeClassifier.classify(new IllegalStateException("boom"))); + assertEquals("unknown", FailureModeClassifier.classify(null)); + } + + @Test + void durationToleranceUsed() { + assertEquals("timeout", FailureModeClassifier.classify(new SocketTimeoutException(), + Duration.ofSeconds(30))); + } +} diff --git a/jamjet-cloud-sdk/src/test/java/dev/jamjet/cloud/JamjetCloudTest.java b/jamjet-cloud-sdk/src/test/java/dev/jamjet/cloud/JamjetCloudTest.java index 169ae39..7340973 100644 --- a/jamjet-cloud-sdk/src/test/java/dev/jamjet/cloud/JamjetCloudTest.java +++ b/jamjet-cloud-sdk/src/test/java/dev/jamjet/cloud/JamjetCloudTest.java @@ -61,4 +61,18 @@ void newSpanRequiresConfigure() { .build()); assertTrue(true); } + + @Test + void payloadSerializesIntoEventMap() { + JamjetCloud.configure(JamjetCloudConfig.builder() + .apiKey("jj_test") + .apiUrl("http://127.0.0.1:1") + .project("test") + .build()); + Span s = JamjetCloud.newSpan("llm_call", "openai.gpt-4o"); + s.payload(java.util.Map.of("messages", java.util.List.of("hi"))); + java.util.Map event = s.toEventMap(); + assertEquals(java.util.List.of("hi"), ((java.util.Map) event.get("payload")).get("messages")); + s.finish(); + } } diff --git a/jamjet-cloud-sdk/src/test/java/dev/jamjet/cloud/instrumentation/langchain4j/JamjetChatModelListenerTest.java b/jamjet-cloud-sdk/src/test/java/dev/jamjet/cloud/instrumentation/langchain4j/JamjetChatModelListenerTest.java new file mode 100644 index 0000000..2b103e3 --- /dev/null +++ b/jamjet-cloud-sdk/src/test/java/dev/jamjet/cloud/instrumentation/langchain4j/JamjetChatModelListenerTest.java @@ -0,0 +1,108 @@ +package dev.jamjet.cloud.instrumentation.langchain4j; + +import dev.jamjet.cloud.JamjetCloud; +import dev.jamjet.cloud.JamjetCloudConfig; +import dev.jamjet.cloud.Span; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.chat.listener.ChatModelErrorContext; +import dev.langchain4j.model.chat.listener.ChatModelRequest; +import dev.langchain4j.model.chat.listener.ChatModelRequestContext; +import dev.langchain4j.model.chat.listener.ChatModelResponse; +import dev.langchain4j.model.chat.listener.ChatModelResponseContext; +import dev.langchain4j.model.output.TokenUsage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class JamjetChatModelListenerTest { + + @BeforeEach + void configure() { + JamjetCloud.configure(JamjetCloudConfig.builder() + .apiKey("jj_test") + .apiUrl("http://127.0.0.1:1") + .project("test") + .captureIo(true) + .build()); + } + + @Test + void onResponsePopulatesSpan() { + var listener = new JamjetChatModelListener(); + Map attrs = new HashMap<>(); + + var req = ChatModelRequest.builder() + .model("gpt-4o-mini") + .messages(List.of(UserMessage.from("hi"))) + .build(); + listener.onRequest(new ChatModelRequestContext(req, attrs)); + + Span span = (Span) attrs.get("jamjet.span"); + assertThat(span).isNotNull(); + + var resp = ChatModelResponse.builder() + .model("gpt-4o-mini") + .tokenUsage(new TokenUsage(120, 80)) + .aiMessage(AiMessage.from("hello back")) + .build(); + listener.onResponse(new ChatModelResponseContext(resp, req, attrs)); + + var event = span.toEventMap(); + assertThat(event.get("kind")).isEqualTo("llm_call"); + assertThat(event.get("name")).isEqualTo("langchain4j.gpt-4o-mini"); + assertThat(event.get("input_tokens")).isEqualTo(120L); + assertThat(event.get("output_tokens")).isEqualTo(80L); + assertThat(event.get("status")).isEqualTo("ok"); + @SuppressWarnings("unchecked") + Map payload = (Map) event.get("payload"); + assertThat(payload).containsKey("response"); + } + + @Test + void redactOmitsPayload() { + JamjetCloud.configure(JamjetCloudConfig.builder() + .apiKey("jj_test") + .apiUrl("http://127.0.0.1:1") + .project("test") + .captureIo(false) + .build()); + + var listener = new JamjetChatModelListener(); + Map attrs = new HashMap<>(); + var req = ChatModelRequest.builder() + .model("gpt-4o-mini") + .messages(List.of(UserMessage.from("secret"))) + .build(); + listener.onRequest(new ChatModelRequestContext(req, attrs)); + Span span = (Span) attrs.get("jamjet.span"); + var resp = ChatModelResponse.builder() + .model("gpt-4o-mini") + .tokenUsage(new TokenUsage(10, 10)) + .aiMessage(AiMessage.from("private")) + .build(); + listener.onResponse(new ChatModelResponseContext(resp, req, attrs)); + + var event = span.toEventMap(); + assertThat(event.get("payload")).isNull(); + } + + @Test + void onErrorTagsFailureMode() { + var listener = new JamjetChatModelListener(); + Map attrs = new HashMap<>(); + var req = ChatModelRequest.builder().model("gpt-4o").messages(List.of(UserMessage.from("x"))).build(); + listener.onRequest(new ChatModelRequestContext(req, attrs)); + Span span = (Span) attrs.get("jamjet.span"); + listener.onError(new ChatModelErrorContext(new RuntimeException("HTTP 401 Unauthorized"), req, null, attrs)); + var event = span.toEventMap(); + assertThat(event.get("status")).isEqualTo("error"); + assertThat(event.get("failure_mode")).isEqualTo("auth"); + assertThat(event.get("payload")).isNull(); + } +} diff --git a/jamjet-cloud-sdk/src/test/java/dev/jamjet/cloud/instrumentation/langchain4j/LangChain4jIntegrationTest.java b/jamjet-cloud-sdk/src/test/java/dev/jamjet/cloud/instrumentation/langchain4j/LangChain4jIntegrationTest.java new file mode 100644 index 0000000..4a00f28 --- /dev/null +++ b/jamjet-cloud-sdk/src/test/java/dev/jamjet/cloud/instrumentation/langchain4j/LangChain4jIntegrationTest.java @@ -0,0 +1,75 @@ +package dev.jamjet.cloud.instrumentation.langchain4j; + +import com.github.tomakehurst.wiremock.WireMockServer; +import dev.jamjet.cloud.JamjetCloud; +import dev.jamjet.cloud.JamjetCloudConfig; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.openai.OpenAiChatModel; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.List; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; +import static org.awaitility.Awaitility.await; + +class LangChain4jIntegrationTest { + + static WireMockServer openai = new WireMockServer(options().dynamicPort()); + static WireMockServer cloud = new WireMockServer(options().dynamicPort()); + + @BeforeAll + static void up() { + openai.start(); + cloud.start(); + openai.stubFor(post(urlEqualTo("/v1/chat/completions")) + .willReturn(okJson(""" + { + "id": "chatcmpl-1", + "object": "chat.completion", + "created": 1700000000, + "model": "gpt-4o-mini", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7} + } + """))); + cloud.stubFor(post(urlEqualTo("/v1/events/ingest")) + .willReturn(okJson("{\"accepted\": 1, \"trace_ids\": [\"tr_x\"]}"))); + + JamjetCloud.configure(JamjetCloudConfig.builder() + .apiKey("jj_test") + .apiUrl(cloud.baseUrl()) + .project("test") + .flushIntervalMs(200) + .build()); + } + + @AfterAll + static void down() { + openai.stop(); + cloud.stop(); + } + + @Test + void manualWiringEmitsSpan() { + var model = OpenAiChatModel.builder() + .apiKey("sk-test") + .baseUrl(openai.baseUrl() + "/v1") + .modelName("gpt-4o-mini") + .listeners(List.of(new JamjetChatModelListener())) + .build(); + + model.generate(List.of(UserMessage.from("hi"))); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + cloud.verify(postRequestedFor(urlEqualTo("/v1/events/ingest")) + .withRequestBody(matchingJsonPath("$.events[0].kind", equalTo("llm_call"))) + .withRequestBody(matchingJsonPath("$.events[0].name", equalTo("langchain4j.gpt-4o-mini"))) + .withRequestBody(matchingJsonPath("$.events[0].input_tokens", equalTo("5"))) + .withRequestBody(matchingJsonPath("$.events[0].output_tokens", equalTo("2")))) + ); + } +} diff --git a/jamjet-cloud-sdk/src/test/java/dev/jamjet/cloud/instrumentation/spring/JamjetObservationHandlerTest.java b/jamjet-cloud-sdk/src/test/java/dev/jamjet/cloud/instrumentation/spring/JamjetObservationHandlerTest.java new file mode 100644 index 0000000..329e421 --- /dev/null +++ b/jamjet-cloud-sdk/src/test/java/dev/jamjet/cloud/instrumentation/spring/JamjetObservationHandlerTest.java @@ -0,0 +1,75 @@ +package dev.jamjet.cloud.instrumentation.spring; + +import dev.jamjet.cloud.JamjetCloud; +import dev.jamjet.cloud.JamjetCloudConfig; +import io.micrometer.common.KeyValues; +import io.micrometer.observation.Observation; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class JamjetObservationHandlerTest { + + @BeforeEach + void configure() { + JamjetCloud.configure(JamjetCloudConfig.builder() + .apiKey("jj_test") + .apiUrl("http://127.0.0.1:1") + .project("test") + .build()); + } + + @Test + void supportsContextOnlyMatchesGenAiClient() { + var handler = new JamjetObservationHandler(); + assertThat(handler.supportsContext(ctx("gen_ai.client.operation"))).isTrue(); + assertThat(handler.supportsContext(ctx("http.client.requests"))).isFalse(); + assertThat(handler.supportsContext(ctx(null))).isFalse(); + } + + @Test + void onStopPopulatesSpanFromKeyValues() { + var handler = new JamjetObservationHandler(); + Observation.Context c = ctx("gen_ai.client.operation"); + c.addLowCardinalityKeyValues(KeyValues.of( + "gen_ai.system", "openai", + "gen_ai.request.model", "gpt-4o-mini")); + c.addHighCardinalityKeyValues(KeyValues.of( + "gen_ai.usage.input_tokens", "120", + "gen_ai.usage.output_tokens", "80")); + + handler.onStart(c); + handler.onStop(c); + + var span = (dev.jamjet.cloud.Span) c.get("jamjet.span"); + var event = span.toEventMap(); + assertThat(event.get("kind")).isEqualTo("llm_call"); + assertThat(event.get("name")).isEqualTo("openai.gpt-4o-mini"); + assertThat(event.get("model")).isEqualTo("gpt-4o-mini"); + assertThat(event.get("input_tokens")).isEqualTo(120L); + assertThat(event.get("output_tokens")).isEqualTo(80L); + assertThat(event.get("status")).isEqualTo("ok"); + } + + @Test + void onStopRecordsErrorWithFailureMode() { + var handler = new JamjetObservationHandler(); + Observation.Context c = ctx("gen_ai.client.operation"); + c.addLowCardinalityKeyValues(KeyValues.of("gen_ai.request.model", "gpt-4o")); + handler.onStart(c); + c.setError(new RuntimeException("HTTP 429 Too Many Requests")); + handler.onStop(c); + + var span = (dev.jamjet.cloud.Span) c.get("jamjet.span"); + var event = span.toEventMap(); + assertThat(event.get("status")).isEqualTo("error"); + assertThat(event.get("failure_mode")).isEqualTo("rate_limit"); + } + + private static Observation.Context ctx(String name) { + Observation.Context c = new Observation.Context(); + if (name != null) c.setName(name); + return c; + } +} diff --git a/jamjet-cloud-spring-boot-starter/pom.xml b/jamjet-cloud-spring-boot-starter/pom.xml new file mode 100644 index 0000000..ced0da5 --- /dev/null +++ b/jamjet-cloud-spring-boot-starter/pom.xml @@ -0,0 +1,74 @@ + + + 4.0.0 + + + dev.jamjet + jamjet-runtime-java-parent + 0.2.0 + + + jamjet-cloud-spring-boot-starter + JamJet Cloud Spring Boot Starter + Spring Boot auto-configuration for the JamJet Cloud SDK — drop-in Spring AI / LangChain4j observability + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + + + dev.jamjet + jamjet-cloud-sdk + ${project.version} + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + io.micrometer + micrometer-observation + 1.13.6 + true + + + dev.langchain4j + langchain4j-core + 0.36.2 + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.assertj + assertj-core + test + + + diff --git a/jamjet-cloud-spring-boot-starter/src/main/java/dev/jamjet/cloud/spring/ChatModelListenerPostProcessor.java b/jamjet-cloud-spring-boot-starter/src/main/java/dev/jamjet/cloud/spring/ChatModelListenerPostProcessor.java new file mode 100644 index 0000000..a73262a --- /dev/null +++ b/jamjet-cloud-spring-boot-starter/src/main/java/dev/jamjet/cloud/spring/ChatModelListenerPostProcessor.java @@ -0,0 +1,70 @@ +package dev.jamjet.cloud.spring; + +import dev.langchain4j.model.chat.ChatLanguageModel; +import dev.langchain4j.model.chat.listener.ChatModelListener; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.config.BeanPostProcessor; + +import java.lang.reflect.Field; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Reflectively appends a {@link ChatModelListener} to every + * {@link ChatLanguageModel} bean at startup. Logs WARN once per provider + * class when a model doesn't expose a listeners list. + */ +public final class ChatModelListenerPostProcessor implements BeanPostProcessor { + + private static final Logger LOG = LoggerFactory.getLogger(ChatModelListenerPostProcessor.class); + private final ChatModelListener listener; + private final Set> warned = new HashSet<>(); + + public ChatModelListenerPostProcessor(ChatModelListener listener) { + this.listener = listener; + } + + @Override + @SuppressWarnings("unchecked") + public Object postProcessAfterInitialization(Object bean, String beanName) { + if (!(bean instanceof ChatLanguageModel)) return bean; + Field field = findListenersField(bean.getClass()); + if (field == null) { + warnOnce(bean.getClass()); + return bean; + } + try { + field.setAccessible(true); + Object current = field.get(bean); + if (current instanceof List) { + ((List) current).add(listener); + } else { + warnOnce(bean.getClass()); + } + } catch (Throwable t) { + LOG.warn("Could not append JamjetChatModelListener to {}: {}", + bean.getClass().getName(), t.toString()); + } + return bean; + } + + private static Field findListenersField(Class type) { + for (Class c = type; c != null && c != Object.class; c = c.getSuperclass()) { + for (Field f : c.getDeclaredFields()) { + if (f.getName().equals("listeners") && List.class.isAssignableFrom(f.getType())) { + return f; + } + } + } + return null; + } + + private void warnOnce(Class type) { + if (warned.add(type)) { + LOG.warn("{} does not expose a listeners list; spans for this model will not be captured. " + + "Wire JamjetChatModelListener manually via the model builder.", type.getName()); + } + } +} diff --git a/jamjet-cloud-spring-boot-starter/src/main/java/dev/jamjet/cloud/spring/JamjetCloudAutoConfiguration.java b/jamjet-cloud-spring-boot-starter/src/main/java/dev/jamjet/cloud/spring/JamjetCloudAutoConfiguration.java new file mode 100644 index 0000000..1ccb940 --- /dev/null +++ b/jamjet-cloud-spring-boot-starter/src/main/java/dev/jamjet/cloud/spring/JamjetCloudAutoConfiguration.java @@ -0,0 +1,57 @@ +package dev.jamjet.cloud.spring; + +import dev.jamjet.cloud.JamjetCloud; +import dev.jamjet.cloud.JamjetCloudConfig; +import dev.jamjet.cloud.instrumentation.langchain4j.JamjetChatModelListener; +import dev.jamjet.cloud.instrumentation.spring.JamjetObservationHandler; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +@ConditionalOnProperty(prefix = "jamjet.cloud", name = "api-key") +@EnableConfigurationProperties(JamjetCloudProperties.class) +public class JamjetCloudAutoConfiguration { + + @Bean + JamjetCloudInitialized jamjetCloudInitialized(JamjetCloudProperties p) { + var builder = JamjetCloudConfig.builder() + .apiKey(p.getApiKey()) + .apiUrl(p.getApiUrl()) + .project(p.getProject()) + .captureIo(p.isCaptureIo()) + .autoPatch(p.isAutoPatch()) + .flushIntervalMs(p.getBatch().getIntervalMs()) + .flushSize(p.getBatch().getSize()); + if (p.getAgent().getName() != null) { + builder.agentName(p.getAgent().getName()); + } + JamjetCloud.configure(builder.build()); + return new JamjetCloudInitialized(); + } + + @Bean + @ConditionalOnClass(io.micrometer.observation.ObservationHandler.class) + @ConditionalOnProperty(prefix = "jamjet.cloud", name = "auto-patch", matchIfMissing = true) + JamjetObservationHandler jamjetObservationHandler(JamjetCloudInitialized init) { + return new JamjetObservationHandler(); + } + + @Bean + @ConditionalOnClass(name = "dev.langchain4j.model.chat.listener.ChatModelListener") + @ConditionalOnProperty(prefix = "jamjet.cloud", name = "auto-patch", matchIfMissing = true) + JamjetChatModelListener jamjetChatModelListener(JamjetCloudInitialized init) { + return new JamjetChatModelListener(); + } + + @Bean + @ConditionalOnClass(name = "dev.langchain4j.model.chat.listener.ChatModelListener") + @ConditionalOnProperty(prefix = "jamjet.cloud", name = "auto-patch", matchIfMissing = true) + ChatModelListenerPostProcessor chatModelListenerPostProcessor(JamjetChatModelListener listener) { + return new ChatModelListenerPostProcessor(listener); + } + + public static final class JamjetCloudInitialized {} +} diff --git a/jamjet-cloud-spring-boot-starter/src/main/java/dev/jamjet/cloud/spring/JamjetCloudProperties.java b/jamjet-cloud-spring-boot-starter/src/main/java/dev/jamjet/cloud/spring/JamjetCloudProperties.java new file mode 100644 index 0000000..148b1bd --- /dev/null +++ b/jamjet-cloud-spring-boot-starter/src/main/java/dev/jamjet/cloud/spring/JamjetCloudProperties.java @@ -0,0 +1,50 @@ +package dev.jamjet.cloud.spring; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "jamjet.cloud") +public class JamjetCloudProperties { + + private String apiKey; + private String apiUrl = "https://api.jamjet.dev"; + private String project = "default"; + private boolean enabled = true; + private boolean captureIo = false; + private boolean autoPatch = true; + + private final Agent agent = new Agent(); + private final Batch batch = new Batch(); + + public String getApiKey() { return apiKey; } + public void setApiKey(String apiKey) { this.apiKey = apiKey; } + public String getApiUrl() { return apiUrl; } + public void setApiUrl(String apiUrl) { this.apiUrl = apiUrl; } + public String getProject() { return project; } + public void setProject(String project) { this.project = project; } + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public boolean isCaptureIo() { return captureIo; } + public void setCaptureIo(boolean captureIo) { this.captureIo = captureIo; } + public boolean isAutoPatch() { return autoPatch; } + public void setAutoPatch(boolean autoPatch) { this.autoPatch = autoPatch; } + public Agent getAgent() { return agent; } + public Batch getBatch() { return batch; } + + public static class Agent { + private String name; + private String cardUri; + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getCardUri() { return cardUri; } + public void setCardUri(String cardUri) { this.cardUri = cardUri; } + } + + public static class Batch { + private long intervalMs = 5_000L; + private int size = 50; + public long getIntervalMs() { return intervalMs; } + public void setIntervalMs(long intervalMs) { this.intervalMs = intervalMs; } + public int getSize() { return size; } + public void setSize(int size) { this.size = size; } + } +} diff --git a/jamjet-cloud-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/jamjet-cloud-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..b241b30 --- /dev/null +++ b/jamjet-cloud-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +dev.jamjet.cloud.spring.JamjetCloudAutoConfiguration diff --git a/jamjet-cloud-spring-boot-starter/src/test/java/dev/jamjet/cloud/spring/ChatModelListenerPostProcessorTest.java b/jamjet-cloud-spring-boot-starter/src/test/java/dev/jamjet/cloud/spring/ChatModelListenerPostProcessorTest.java new file mode 100644 index 0000000..f0b72d2 --- /dev/null +++ b/jamjet-cloud-spring-boot-starter/src/test/java/dev/jamjet/cloud/spring/ChatModelListenerPostProcessorTest.java @@ -0,0 +1,54 @@ +package dev.jamjet.cloud.spring; + +import dev.jamjet.cloud.instrumentation.langchain4j.JamjetChatModelListener; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.model.chat.ChatLanguageModel; +import dev.langchain4j.model.chat.listener.ChatModelListener; +import dev.langchain4j.model.output.Response; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ChatModelListenerPostProcessorTest { + + static class ModelWithListenersField implements ChatLanguageModel { + final List listeners = new ArrayList<>(); + @Override public Response generate(List messages) { + return Response.from(AiMessage.from("ok")); + } + } + + static class ModelWithoutListeners implements ChatLanguageModel { + @Override public Response generate(List messages) { + return Response.from(AiMessage.from("ok")); + } + } + + @Test + void appendsListenerToModelWithListenersField() { + var bpp = new ChatModelListenerPostProcessor(new JamjetChatModelListener()); + var model = new ModelWithListenersField(); + Object out = bpp.postProcessAfterInitialization(model, "model"); + assertThat(out).isSameAs(model); + assertThat(model.listeners).hasSize(1).first().isInstanceOf(JamjetChatModelListener.class); + } + + @Test + void noOpForModelWithoutListenersField() { + var bpp = new ChatModelListenerPostProcessor(new JamjetChatModelListener()); + var model = new ModelWithoutListeners(); + Object out = bpp.postProcessAfterInitialization(model, "model"); + assertThat(out).isSameAs(model); + } + + @Test + void leavesNonChatModelBeansAlone() { + var bpp = new ChatModelListenerPostProcessor(new JamjetChatModelListener()); + Object random = "hello"; + assertThat(bpp.postProcessAfterInitialization(random, "x")).isSameAs(random); + } +} diff --git a/jamjet-cloud-spring-boot-starter/src/test/java/dev/jamjet/cloud/spring/JamjetCloudAutoConfigurationTest.java b/jamjet-cloud-spring-boot-starter/src/test/java/dev/jamjet/cloud/spring/JamjetCloudAutoConfigurationTest.java new file mode 100644 index 0000000..068c0e0 --- /dev/null +++ b/jamjet-cloud-spring-boot-starter/src/test/java/dev/jamjet/cloud/spring/JamjetCloudAutoConfigurationTest.java @@ -0,0 +1,61 @@ +package dev.jamjet.cloud.spring; + +import dev.jamjet.cloud.JamjetCloud; +import dev.jamjet.cloud.instrumentation.langchain4j.JamjetChatModelListener; +import dev.jamjet.cloud.instrumentation.spring.JamjetObservationHandler; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +class JamjetCloudAutoConfigurationTest { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(JamjetCloudAutoConfiguration.class)); + + @Test + void skipsAutoConfigWhenApiKeyMissing() { + runner.run(ctx -> { + assertThat(ctx).doesNotHaveBean(JamjetObservationHandler.class); + assertThat(ctx).doesNotHaveBean(JamjetChatModelListener.class); + }); + } + + @Test + void registersBeansWhenApiKeyPresent() { + runner.withPropertyValues("jamjet.cloud.api-key=jj_test", + "jamjet.cloud.api-url=http://127.0.0.1:1") + .run(ctx -> { + assertThat(ctx).hasSingleBean(JamjetObservationHandler.class); + assertThat(ctx).hasSingleBean(JamjetChatModelListener.class); + assertThat(JamjetCloud.config()).isNotNull(); + assertThat(JamjetCloud.config().apiKey()).isEqualTo("jj_test"); + }); + } + + @Test + void disableViaAutoPatchProperty() { + runner.withPropertyValues("jamjet.cloud.api-key=jj_test", + "jamjet.cloud.api-url=http://127.0.0.1:1", + "jamjet.cloud.auto-patch=false") + .run(ctx -> { + assertThat(ctx).doesNotHaveBean(JamjetObservationHandler.class); + assertThat(ctx).doesNotHaveBean(JamjetChatModelListener.class); + }); + } + + @Test + void contextStartsWithoutLlmLibsOnClasspath() { + runner.withPropertyValues("jamjet.cloud.api-key=jj_test", + "jamjet.cloud.api-url=http://127.0.0.1:1") + .withClassLoader(new org.springframework.boot.test.context.FilteredClassLoader( + io.micrometer.observation.ObservationHandler.class, + dev.langchain4j.model.chat.listener.ChatModelListener.class)) + .run(ctx -> { + assertThat(JamjetCloud.config()).isNotNull(); + assertThat(ctx).doesNotHaveBean(JamjetObservationHandler.class); + assertThat(ctx).doesNotHaveBean(JamjetChatModelListener.class); + }); + } +} diff --git a/jamjet-runtime-core/pom.xml b/jamjet-runtime-core/pom.xml index 0216547..215004c 100644 --- a/jamjet-runtime-core/pom.xml +++ b/jamjet-runtime-core/pom.xml @@ -7,7 +7,7 @@ dev.jamjet jamjet-runtime-java-parent - 0.1.1 + 0.2.0 jamjet-runtime-core diff --git a/jamjet-runtime-instrument/pom.xml b/jamjet-runtime-instrument/pom.xml index 9643564..03e4d85 100644 --- a/jamjet-runtime-instrument/pom.xml +++ b/jamjet-runtime-instrument/pom.xml @@ -7,7 +7,7 @@ dev.jamjet jamjet-runtime-java-parent - 0.1.1 + 0.2.0 jamjet-runtime-instrument diff --git a/jamjet-runtime-plugins/pom.xml b/jamjet-runtime-plugins/pom.xml index aa1ee43..fbf2605 100644 --- a/jamjet-runtime-plugins/pom.xml +++ b/jamjet-runtime-plugins/pom.xml @@ -7,7 +7,7 @@ dev.jamjet jamjet-runtime-java-parent - 0.1.1 + 0.2.0 jamjet-runtime-plugins diff --git a/jamjet-runtime-protocols/pom.xml b/jamjet-runtime-protocols/pom.xml index c987be4..bb58695 100644 --- a/jamjet-runtime-protocols/pom.xml +++ b/jamjet-runtime-protocols/pom.xml @@ -7,7 +7,7 @@ dev.jamjet jamjet-runtime-java-parent - 0.1.1 + 0.2.0 jamjet-runtime-protocols diff --git a/jamjet-runtime-server/pom.xml b/jamjet-runtime-server/pom.xml index bee2dad..46622d6 100644 --- a/jamjet-runtime-server/pom.xml +++ b/jamjet-runtime-server/pom.xml @@ -7,7 +7,7 @@ dev.jamjet jamjet-runtime-java-parent - 0.1.1 + 0.2.0 jamjet-runtime-server diff --git a/jamjet-spring-boot-starter/pom.xml b/jamjet-spring-boot-starter/pom.xml index 71e89da..a609f99 100644 --- a/jamjet-spring-boot-starter/pom.xml +++ b/jamjet-spring-boot-starter/pom.xml @@ -7,7 +7,7 @@ dev.jamjet jamjet-runtime-java-parent - 0.1.1 + 0.2.0 jamjet-runtime-spring-boot-starter diff --git a/pom.xml b/pom.xml index db6c157..5ea266d 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ dev.jamjet jamjet-runtime-java-parent - 0.1.1 + 0.2.0 pom JamJet Runtime Java @@ -49,6 +49,7 @@ jamjet-runtime-server jamjet-spring-boot-starter jamjet-cloud-sdk + jamjet-cloud-spring-boot-starter @@ -165,28 +166,38 @@ - - org.apache.maven.plugins - maven-gpg-plugin - 3.2.4 - - - sign-artifacts - verify - sign - - - - - org.sonatype.central - central-publishing-maven-plugin - 0.6.0 - true - - central - true - - + + + + release + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.4 + + + sign-artifacts + verify + sign + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.6.0 + true + + central + true + + + + + +