Skip to content
Open
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
54 changes: 54 additions & 0 deletions .github/workflows/java-spring-ai-compatibility.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: Java Spring AI compatibility

# Deterministic Spring AI 1.1.x compatibility for the traceAI Java SDK.
# Builds the candidate once on Spring AI 1.1.8 / Spring Boot 3.5.15 and runs the
# same JARs against Spring AI 1.1.0 / Spring Boot 3.5.7. No secrets, no live model.

on:
pull_request:
paths:
- 'java/pom.xml'
- 'java/traceai-spring-ai/**'
- 'java/traceai-spring-boot-starter/**'
- 'java/traceai-java-core/**'
- 'java/compatibility/spring-ai/**'
- 'java/examples/spring-ai-example/**'
- '.github/workflows/java-spring-ai-compatibility.yml'

permissions:
contents: read

jobs:
deterministic:
runs-on: ubuntu-latest
container:
image: maven@sha256:e4a7ace3dc0d645ed97f8d9ad0b0d3f0b14fa8d150138f27f116d7105a639b82
steps:
- uses: actions/checkout@v4
- name: Deterministic Spring module and starter suites (Spring AI 1.1.8)
working-directory: java
run: mvn -B -ntp -pl traceai-spring-ai,traceai-spring-boot-starter -am -Dsurefire.failIfNoSpecifiedTests=false test
- name: Package candidate JARs
working-directory: java
run: mvn -B -ntp -pl traceai-spring-ai,traceai-spring-boot-starter -am -DskipTests package
- name: Stage candidate artifacts at the repo root
run: |
mkdir -p candidate-jars
cp java/traceai-java-core/target/traceai-java-core-1.0.0.jar candidate-jars/
cp java/traceai-spring-ai/target/traceai-spring-ai-1.0.0.jar candidate-jars/
sha256sum candidate-jars/*.jar
- name: Same-binary consumer on Spring AI 1.1.8
working-directory: java/compatibility/spring-ai/common
run: mvn -B -ntp -q clean compile exec:java -Dexec.mainClass=local.th7477.CommonConsumer -Dexec.args=P -Dexec.classpathScope=compile
- name: Same-binary consumer on Spring AI 1.1.0
working-directory: java/compatibility/spring-ai/baseline-cell
run: mvn -B -ntp -q clean compile exec:java -Dexec.mainClass=local.th7477.CommonConsumer -Dexec.args=B -Dexec.classpathScope=compile
- name: 1.1.8-only embedding accessor
working-directory: java/compatibility/spring-ai/primary
run: mvn -B -ntp -q clean compile exec:java -Dexec.mainClass=local.th7477.PrimaryConsumer -Dexec.classpathScope=compile
- name: Install the candidate into the local repository
working-directory: java
run: mvn -B -ntp -pl traceai-spring-boot-starter -am -DskipTests install
- name: Example compiles against 1.1.8
working-directory: java/examples/spring-ai-example
run: mvn -B -ntp -DskipTests compile
4 changes: 2 additions & 2 deletions java/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,8 +357,8 @@ traceai:
| Module | Description | Integration |
|--------|-------------|-------------|
| `traceai-langchain4j` | LangChain4j instrumentation | `dev.langchain4j:langchain4j` |
| `traceai-spring-ai` | Spring AI instrumentation | `org.springframework.ai:spring-ai-core` |
| `traceai-spring-boot-starter` | Spring Boot auto-configuration | Spring Boot 3.x |
| `traceai-spring-ai` | Spring AI 1.1.x instrumentation | `org.springframework.ai:spring-ai-model` (provided) |
| `traceai-spring-boot-starter` | Spring Boot auto-configuration | Spring Boot 3.5.x |
| `traceai-java-semantic-kernel` | Microsoft Semantic Kernel instrumentation | `com.microsoft.semantic-kernel:semantickernel-api` |

## Configuration
Expand Down
35 changes: 35 additions & 0 deletions java/compatibility/spring-ai/MIGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Spring AI compatibility notes (TH-7477)

The `traceai-spring-ai` wrapper is compiled and tested against Spring AI 1.1.x GA. Spring AI `1.0.0-M4`
is retired: the milestone API (`Message.getContent()`, `Usage.getGenerationTokens()`, `spring-ai-core`)
was removed before GA, so an artifact built on it fails at runtime on every current release.

## Closed matrix

| Cell | Spring AI | Spring Boot | Java | What runs |
|---|---|---|---|---|
| P | 1.1.8 | 3.5.15 | 17 | compile once, deterministic suite, example, real local agent |
| B | 1.1.0 | 3.5.7 | 17 | the same built JARs, common API only |

Other versions (Spring AI 1.0 GA, 2.0, Spring Boot 3.4 or 4, newer JDKs) are not claimed.

## What changed for callers

- Message and assistant text is read with `getText()`, not `getContent()`.
- Completion usage is read with `getCompletionTokens()`, not `getGenerationTokens()`.
- Depend on `org.springframework.ai:spring-ai-model` (the wrapper declares it `provided`).
- `TracedEmbeddingModel.getEmbeddingContent(Document)` delegates directly and exists only on 1.1.8+.
Applications on 1.1.0 simply never call it; no reflection bridge is provided.

## Streaming

`stream(prompt)` starts no span and does not call the delegate until a subscriber subscribes. Each
subscription owns one span that ends exactly once. Streamed token usage is not aggregated. To bind a
parent span and TraceAI session/user attributes to a stream subscribed on another thread, use
`SpringAITracingContext.withContext(parent, attributes)` with `Flux.contextWrite`. Reusing one publisher
across tenants requires an explicit snapshot per subscription.

## Privacy

`hideInputs` and `hideInputMessages` are independent, as are the two output switches. Set both of a pair
to omit a channel completely. Metadata, session and user attributes are unaffected.
24 changes: 24 additions & 0 deletions java/compatibility/spring-ai/baseline-cell/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>local.th7477</groupId><artifactId>common-consumer</artifactId><version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.release>17</maven.compiler.release><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-ai.version>1.1.0</spring-ai.version><spring-boot.version>3.5.7</spring-boot.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-bom</artifactId><version>${spring-ai.version}</version><type>pom</type><scope>import</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-dependencies</artifactId><version>${spring-boot.version}</version><type>pom</type><scope>import</scope></dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-model</artifactId></dependency>
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-client-chat</artifactId></dependency>
<dependency><groupId>ai.traceai</groupId><artifactId>traceai-java-core</artifactId><version>1.0.0-candidate</version><scope>system</scope><systemPath>${project.basedir}/../../../../candidate-jars/traceai-java-core-1.0.0.jar</systemPath></dependency>
<dependency><groupId>ai.traceai</groupId><artifactId>traceai-spring-ai</artifactId><version>1.0.0-candidate</version><scope>system</scope><systemPath>${project.basedir}/../../../../candidate-jars/traceai-spring-ai-1.0.0.jar</systemPath></dependency>
<dependency><groupId>io.opentelemetry</groupId><artifactId>opentelemetry-sdk-testing</artifactId><version>1.49.0</version></dependency>
<dependency><groupId>com.google.code.gson</groupId><artifactId>gson</artifactId><version>2.10.1</version></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.12.1</version></plugin></plugins></build>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package local.th7477;

import ai.traceai.FITracer;
import ai.traceai.SemanticConventions;
import ai.traceai.spring.TracedChatModel;
import ai.traceai.spring.TracedEmbeddingModel;
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter;
import io.opentelemetry.sdk.trace.SdkTracerProvider;
import io.opentelemetry.sdk.trace.data.SpanData;
import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.embedding.EmbeddingRequest;

import java.security.CodeSource;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;

/** Same compiled classes, same candidate JARs, run on both Spring AI cells. Asserts common API only. */
public final class CommonConsumer {
public static void main(String[] args) throws Exception {
String cell = args.length > 0 ? args[0] : "unknown";
InMemorySpanExporter exporter = InMemorySpanExporter.create();
SdkTracerProvider provider = SdkTracerProvider.builder().addSpanProcessor(SimpleSpanProcessor.create(exporter)).build();
FITracer tracer = new FITracer(provider.get("th7477-matrix"));

ChatResponse expected = SpanTestSupport.response("matrix-answer", SpanTestSupport.usage(4, 2, 6), "matrix-model");
TracedChatModel traced = new TracedChatModel(SpanTestSupport.syncModel(p -> expected), tracer, "synthetic");
ChatResponse actual = traced.call(new Prompt(List.of(new UserMessage("matrix input " + cell))));
if (actual != expected) throw new AssertionError("call identity lost on " + cell);

SpanData span = exporter.getFinishedSpanItems().get(exporter.getFinishedSpanItems().size() - 1);
eq(span.getAttributes().get(AttributeKey.stringKey(SemanticConventions.OUTPUT_VALUE)), "matrix-answer", "output");
eq(span.getAttributes().get(AttributeKey.longKey(SemanticConventions.LLM_TOKEN_COUNT_PROMPT)), 4L, "prompt tokens");
eq(span.getAttributes().get(AttributeKey.longKey(SemanticConventions.LLM_TOKEN_COUNT_COMPLETION)), 2L, "completion tokens");
eq(span.getAttributes().get(AttributeKey.longKey(SemanticConventions.LLM_TOKEN_COUNT_TOTAL)), 6L, "total tokens");

traced.stream(new Prompt("stream " + cell)).blockLast();
long streamSpans = exporter.getFinishedSpanItems().stream().filter(s -> s.getName().contains("Stream")).count();
if (streamSpans != 1) throw new AssertionError("expected one stream span, got " + streamSpans);

TracedEmbeddingModel tracedEmbeddings = new TracedEmbeddingModel(new SpanTestSupport.FixedEmbeddingModel(), tracer, "synthetic");
List<float[]> vectors = tracedEmbeddings.embed(List.of("a", "b"));
if (vectors.size() != 2 || vectors.get(0).length != 3) throw new AssertionError("batch identity lost");
tracedEmbeddings.call(new EmbeddingRequest(List.of("a"), null));

CodeSource source = TracedChatModel.class.getProtectionDomain().getCodeSource();
String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(source.getLocation().openStream().readAllBytes()));
System.out.println("CELL=" + cell + " SHA256=" + digest + " SPANS=" + exporter.getFinishedSpanItems().size() + " COMMON_CONSUMER_PASS");
provider.close();
}

private static void eq(Object actual, Object expected, String what) {
if (actual == null || !actual.equals(expected)) throw new AssertionError(what + " expected " + expected + " but was " + actual);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package local.th7477;

import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.Embedding;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.embedding.EmbeddingRequest;
import org.springframework.ai.embedding.EmbeddingResponse;
import org.springframework.ai.embedding.EmbeddingResponseMetadata;
import reactor.core.publisher.Flux;

import java.util.List;
import java.util.function.Function;

/** Spring AI objects shared by both matrix cells. Only APIs present in 1.1.0 and 1.1.8. */
final class SpanTestSupport {
private SpanTestSupport() {}

static ChatResponse response(String text, Usage usage, String model) {
ChatResponseMetadata.Builder metadata = ChatResponseMetadata.builder();
if (usage != null) metadata.usage(usage);
if (model != null) metadata.model(model);
return new ChatResponse(List.of(new Generation(new AssistantMessage(text))), metadata.build());
}

static Usage usage(int prompt, int completion, int total) {
return new Usage() {
@Override public Integer getPromptTokens() { return prompt; }
@Override public Integer getCompletionTokens() { return completion; }
@Override public Integer getTotalTokens() { return total; }
@Override public Object getNativeUsage() { return "matrix"; }
};
}

static ChatModel syncModel(Function<Prompt, ChatResponse> call) {
return new ChatModel() {
@Override public ChatResponse call(Prompt prompt) { return call.apply(prompt); }
@Override public Flux<ChatResponse> stream(Prompt prompt) { return Flux.just(response("streamed-" + prompt.getContents(), null, null)); }
@Override public ChatOptions getDefaultOptions() { return null; }
};
}

static final class FixedEmbeddingModel implements EmbeddingModel {
private final EmbeddingResponse response = new EmbeddingResponse(
List.of(new Embedding(new float[]{1f, 2f, 3f}, 0), new Embedding(new float[]{4f, 5f, 6f}, 1)),
new EmbeddingResponseMetadata("m", null));
@Override public EmbeddingResponse call(EmbeddingRequest request) { return response; }
@Override public float[] embed(Document document) { return new float[]{1f, 2f, 3f}; }
@Override public List<float[]> embed(List<String> texts) { return List.of(new float[]{1f, 2f, 3f}, new float[]{4f, 5f, 6f}); }
}
}
24 changes: 24 additions & 0 deletions java/compatibility/spring-ai/common/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>local.th7477</groupId><artifactId>common-consumer</artifactId><version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.release>17</maven.compiler.release><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-ai.version>1.1.8</spring-ai.version><spring-boot.version>3.5.15</spring-boot.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-bom</artifactId><version>${spring-ai.version}</version><type>pom</type><scope>import</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-dependencies</artifactId><version>${spring-boot.version}</version><type>pom</type><scope>import</scope></dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-model</artifactId></dependency>
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-client-chat</artifactId></dependency>
<dependency><groupId>ai.traceai</groupId><artifactId>traceai-java-core</artifactId><version>1.0.0-candidate</version><scope>system</scope><systemPath>${project.basedir}/../../../../candidate-jars/traceai-java-core-1.0.0.jar</systemPath></dependency>
<dependency><groupId>ai.traceai</groupId><artifactId>traceai-spring-ai</artifactId><version>1.0.0-candidate</version><scope>system</scope><systemPath>${project.basedir}/../../../../candidate-jars/traceai-spring-ai-1.0.0.jar</systemPath></dependency>
<dependency><groupId>io.opentelemetry</groupId><artifactId>opentelemetry-sdk-testing</artifactId><version>1.49.0</version></dependency>
<dependency><groupId>com.google.code.gson</groupId><artifactId>gson</artifactId><version>2.10.1</version></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.12.1</version></plugin></plugins></build>
</project>
Loading
Loading