Skip to content

Adds experimental auto-instrumentation for Spring AI 2.x. - #4622

Draft
hegerchr wants to merge 10 commits into
elastic:mainfrom
hegerchr:spring-ai-instrumentation
Draft

hegerchr wants to merge 10 commits into
elastic:mainfrom
hegerchr:spring-ai-instrumentation

Conversation

@hegerchr

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds experimental auto-instrumentation for Spring AI 2.x.

The new plugin creates spans for:

  • Synchronous and streaming chat model calls
  • Embedding model calls
  • Vector store operations, including similarity searches
  • Tool callback execution

Chat spans capture GenAI attributes such as provider, model, request parameters, response metadata, finish reasons, and token usage. Streaming spans remain open until the response publisher terminates, so their duration represents the overall response time.

For streaming chat calls, the instrumentation also records time to first chunk as:

  • Span attribute: gen_ai.response.time_to_first_chunk
  • OpenTelemetry histogram: gen_ai.client.inference.time_to_first_chunk, in seconds

Streaming response chunks are aggregated so completed assistant content is captured correctly.

Content capture is disabled by default. The new dynamic capture_ai_content configuration option enables capture of:

  • Prompt and response messages
  • Assistant tool calls and tool responses
  • Embedding inputs
  • Vector store documents and queries
  • Tool arguments and results

The option is documented as introduced in Java agent 1.58.0. Captured values are subject to long_field_max_length. The documentation also notes the security and performance implications of capturing AI content.

The agent supports changing this option dynamically. Configuring it through Kibana central configuration requires corresponding Kibana changes and will be supported in a future Kibana version.

The instrumentation is experimental and disabled by default. It can be enabled using:

elastic.apm.enable_experimental_instrumentations=true

The shared class-loader matcher requires a Spring AI class introduced in 2.0, preventing unsupported Spring AI 1.x versions from being instrumented. Compatibility tests verify that Spring AI 1.0.8 and 1.1.7 are rejected and Spring AI 2.0.0 is accepted.

Testing performed:

  • Spring AI plugin test suite: 13 tests passed
  • Full agent assembly completed successfully
  • Packaged agent startup smoke-tested with Java 17
  • End-to-end test with start-local

Checklist

@github-actions

Copy link
Copy Markdown

🤖 GitHub comments

Just comment with:

  • run docs-build : Re-trigger the docs validation. (use unformatted text in the comment!)

@github-actions

github-actions Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown

Elastic Docs Style Checker (Vale)

Summary: 6 suggestions found

💡 Suggestions (6): Optional style improvements. Apply when helpful.
File Line Rule Message
docs/reference/config-core.md 332 Elastic.WordChoice Consider using 'deactivated, deselected, hidden, turned off, unavailable' instead of 'disabled', unless the term is in the UI.
docs/reference/config-core.md 332 Elastic.WordChoice Consider using 'deactivated, deselected, hidden, turned off, unavailable' instead of 'disabled', unless the term is in the UI.
docs/reference/config-core.md 356 Elastic.WordChoice Consider using 'deactivated, deselected, hidden, turned off, unavailable' instead of 'disabled', unless the term is in the UI.
docs/reference/supported-technologies.md 136 Elastic.Semicolons Use semicolons sparingly. Consider splitting the sentence or using a comma or conjunction.
docs/reference/supported-technologies.md 136 Elastic.WordChoice Consider using 'deactivated, deselected, hidden, turned off, unavailable' instead of 'disabled', unless the term is in the UI.
docs/reference/supported-technologies.md 136 Elastic.Semicolons Use semicolons sparingly. Consider splitting the sentence or using a comma or conjunction.

The Vale linter checks documentation changes against the Elastic Docs style guide. To use Vale locally or report issues, refer to Elastic style guide for Vale.

.withSubtype("internal")
.withAction(operation)
.withOtelAttribute("gen_ai.operation.name", operation)
.withOtelAttribute("gen_ai.system", provider)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.withOtelAttribute("gen_ai.system", provider)
.withOtelAttribute("gen_ai.provider.name", provider)

should be 'gen_ai.provider.name' to fit the current semconv

return values == null || values.isEmpty() ? null : values.toString();
}

private static String providerFor(Object model) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even if that may work short term, this is a very fragile heuristic, can we find something better and more reliable here ?

If we can't reliably provide it because it's abstracted by spring, it means it's probably better to not capture it, it might also be visible through the lower-level instrumentation like HTTP where equivalent information can be captured.

target.append("null");
return;
}
value = truncate(value, coreConfig.getLongFieldMaxLength());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this makes truncating a single string value, however this is called more than once on a single field to reconstruct the whole json, so the effective value can be larger that the one configured.

Comment on lines +502 to +507
if (normalized.contains("postgres") || normalized.contains("pg_vector")) return "postgresql";
if (normalized.contains("mongo")) return "mongodb";
if (normalized.contains("elastic")) return "elasticsearch";
if (normalized.contains("redis")) return "redis";
if (normalized.contains("cassandra")) return "cassandra";
if (normalized.contains("cosmos")) return "cosmosdb";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similar to provider detection on class name, this looks quite brittle heuristic, if we are unable to capture things reliably it's better to avoid doing so.

return value.substring(0, value.offsetByCodePoints(0, maxCodePoints));
}

private static String serializeMessages(List<? extends Message> messages) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really have to reconstruct the whole json message here ? At the spring-ai level there should be no json involved at all, so here we re-create an artificial body of what the underlying implementation should send on the wire, is my understanding correct ?

@Advice.OnMethodEnter(suppress = Throwable.class, inline = false)
public static Object onEnter(@Advice.This ToolCallback callback,
@Advice.Argument(0) String toolInput) {
Span<?> span = SpringAiSpanHelper.startToolSpan(callback, toolInput);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we ever get nested spans due to wrapping, using CallDepth might help to avoid nested calls here and only instrument the outer call.

@Override
public ElementMatcher.Junction<ClassLoader> getClassLoaderMatcher() {
return super.getClassLoaderMatcher()
.and(co.elastic.apm.agent.sdk.bytebuddy.CustomElementMatchers.classLoaderCanLoadClass(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.and(co.elastic.apm.agent.sdk.bytebuddy.CustomElementMatchers.classLoaderCanLoadClass(
.and(CustomElementMatchers.classLoaderCanLoadClass(

This branch was successfully deployed

1 active deployment
docs-preview — a62669ea Deployed Nov 12, 2026 by github-actions[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants