Skip to content

Commit 0f55d4d

Browse files
committed
feat(template-st): log StringTemplate compile/runtime errors via SLF4J
Fixes GH-3604 (#3604) ### Why StringTemplate’s default ErrorManager writes diagnostics to stderr, which is invisible in most Spring deployments. This change redirects those messages to SLF4J so they reach Logstash / CloudWatch / etc. ### What * Add `Slf4jStErrorListener` (STErrorListener -> SLF4J). * Create STGroup with listener inside `StTemplateRenderer#createST`. * JUnit `malformedTemplateShouldLogErrorViaSlf4j`. * Javadoc updates. ### Impact No API changes; applications automatically get structured error logs. Signed-off-by: Your Name <[email protected]>
1 parent af07517 commit 0f55d4d

File tree

3 files changed

+114
-19
lines changed

3 files changed

+114
-19
lines changed
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Copyright 2023-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.template.st;
18+
19+
import org.slf4j.Logger;
20+
import org.slf4j.LoggerFactory;
21+
import org.stringtemplate.v4.STErrorListener;
22+
import org.stringtemplate.v4.misc.STMessage;
23+
24+
/**
25+
* {@link STErrorListener} implementation that logs errors using SLF4J.
26+
*/
27+
public class Slf4jStErrorListener implements STErrorListener {
28+
29+
private static final Logger logger = LoggerFactory.getLogger(StTemplateRenderer.class);
30+
31+
@Override
32+
public void compileTimeError(STMessage msg) {
33+
logger.error("StringTemplate compile error: {}", msg);
34+
}
35+
36+
@Override
37+
public void runTimeError(STMessage msg) {
38+
logger.error("StringTemplate runtime error: {}", msg);
39+
}
40+
41+
@Override
42+
public void IOError(STMessage msg) {
43+
logger.error("StringTemplate IO error: {}", msg);
44+
}
45+
46+
@Override
47+
public void internalError(STMessage msg) {
48+
logger.error("StringTemplate internal error: {}", msg);
49+
}
50+
51+
}

spring-ai-template-st/src/main/java/org/springframework/ai/template/st/StTemplateRenderer.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
import org.slf4j.Logger;
2626
import org.slf4j.LoggerFactory;
2727
import org.stringtemplate.v4.ST;
28+
import org.stringtemplate.v4.STErrorListener;
29+
import org.stringtemplate.v4.STGroup;
2830
import org.stringtemplate.v4.compiler.Compiler;
2931
import org.stringtemplate.v4.compiler.STLexer;
3032

@@ -73,6 +75,8 @@ public class StTemplateRenderer implements TemplateRenderer {
7375

7476
private final boolean validateStFunctions;
7577

78+
private final STErrorListener stErrorListener = new Slf4jStErrorListener();
79+
7680
/**
7781
* Constructs a new {@code StTemplateRenderer} with the specified delimiter tokens,
7882
* validation mode, and function validation flag.
@@ -112,13 +116,16 @@ public String apply(String template, Map<String, Object> variables) {
112116

113117
private ST createST(String template) {
114118
try {
115-
return new ST(template, this.startDelimiterToken, this.endDelimiterToken);
119+
STGroup group = new STGroup(this.startDelimiterToken, this.endDelimiterToken);
120+
group.setListener(this.stErrorListener);
121+
return new ST(group, template);
116122
}
117123
catch (Exception ex) {
118124
throw new IllegalArgumentException("The template string is not valid.", ex);
119125
}
120126
}
121127

128+
122129
/**
123130
* Validates that all required template variables are provided in the model. Returns
124131
* the set of missing variables for further handling or logging.

spring-ai-template-st/src/test/java/org/springframework/ai/template/st/StTemplateRendererTests.java

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,23 @@
1616

1717
package org.springframework.ai.template.st;
1818

19+
import static org.assertj.core.api.Assertions.*;
20+
21+
import java.io.ByteArrayOutputStream;
22+
import java.io.PrintStream;
23+
import java.nio.charset.StandardCharsets;
1924
import java.util.HashMap;
2025
import java.util.Map;
2126

2227
import org.junit.jupiter.api.Test;
23-
28+
import org.slf4j.LoggerFactory;
2429
import org.springframework.ai.template.ValidationMode;
2530
import org.springframework.test.util.ReflectionTestUtils;
2631

27-
import static org.assertj.core.api.Assertions.assertThat;
28-
import static org.assertj.core.api.Assertions.assertThatThrownBy;
32+
import ch.qos.logback.classic.Level;
33+
import ch.qos.logback.classic.Logger;
34+
import ch.qos.logback.classic.spi.ILoggingEvent;
35+
import ch.qos.logback.core.read.ListAppender;
2936

3037
/**
3138
* Unit tests for {@link StTemplateRenderer}.
@@ -37,8 +44,8 @@ class StTemplateRendererTests {
3744
@Test
3845
void shouldNotAcceptNullValidationMode() {
3946
assertThatThrownBy(() -> StTemplateRenderer.builder().validationMode(null).build())
40-
.isInstanceOf(IllegalArgumentException.class)
41-
.hasMessageContaining("validationMode cannot be null");
47+
.isInstanceOf(IllegalArgumentException.class)
48+
.hasMessageContaining("validationMode cannot be null");
4249
}
4350

4451
@Test
@@ -80,14 +87,14 @@ void shouldNotRenderEmptyTemplate() {
8087
Map<String, Object> variables = new HashMap<>();
8188

8289
assertThatThrownBy(() -> renderer.apply("", variables)).isInstanceOf(IllegalArgumentException.class)
83-
.hasMessageContaining("template cannot be null or empty");
90+
.hasMessageContaining("template cannot be null or empty");
8491
}
8592

8693
@Test
8794
void shouldNotAcceptNullVariables() {
8895
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
8996
assertThatThrownBy(() -> renderer.apply("Hello!", null)).isInstanceOf(IllegalArgumentException.class)
90-
.hasMessageContaining("variables cannot be null");
97+
.hasMessageContaining("variables cannot be null");
9198
}
9299

93100
@Test
@@ -98,7 +105,7 @@ void shouldNotAcceptVariablesWithNullKeySet() {
98105
variables.put(null, "Spring AI");
99106

100107
assertThatThrownBy(() -> renderer.apply(template, variables)).isInstanceOf(IllegalArgumentException.class)
101-
.hasMessageContaining("variables keys cannot be null");
108+
.hasMessageContaining("variables keys cannot be null");
102109
}
103110

104111
@Test
@@ -108,7 +115,7 @@ void shouldThrowExceptionForInvalidTemplateSyntax() {
108115
variables.put("name", "Spring AI");
109116

110117
assertThatThrownBy(() -> renderer.apply("Hello {name!", variables)).isInstanceOf(IllegalArgumentException.class)
111-
.hasMessageContaining("The template string is not valid.");
118+
.hasMessageContaining("The template string is not valid.");
112119
}
113120

114121
@Test
@@ -118,9 +125,9 @@ void shouldThrowExceptionForMissingVariablesInThrowMode() {
118125
variables.put("greeting", "Hello");
119126

120127
assertThatThrownBy(() -> renderer.apply("{greeting} {name}!", variables))
121-
.isInstanceOf(IllegalStateException.class)
122-
.hasMessageContaining(
123-
"Not all variables were replaced in the template. Missing variable names are: [name]");
128+
.isInstanceOf(IllegalStateException.class)
129+
.hasMessageContaining(
130+
"Not all variables were replaced in the template. Missing variable names are: [name]");
124131
}
125132

126133
@Test
@@ -148,9 +155,9 @@ void shouldRenderWithoutValidationInNoneMode() {
148155
@Test
149156
void shouldRenderWithCustomDelimiters() {
150157
StTemplateRenderer renderer = StTemplateRenderer.builder()
151-
.startDelimiterToken('<')
152-
.endDelimiterToken('>')
153-
.build();
158+
.startDelimiterToken('<')
159+
.endDelimiterToken('>')
160+
.build();
154161
Map<String, Object> variables = new HashMap<>();
155162
variables.put("name", "Spring AI");
156163

@@ -162,9 +169,9 @@ void shouldRenderWithCustomDelimiters() {
162169
@Test
163170
void shouldHandleSpecialCharactersAsDelimiters() {
164171
StTemplateRenderer renderer = StTemplateRenderer.builder()
165-
.startDelimiterToken('$')
166-
.endDelimiterToken('$')
167-
.build();
172+
.startDelimiterToken('$')
173+
.endDelimiterToken('$')
174+
.build();
168175
Map<String, Object> variables = new HashMap<>();
169176
variables.put("name", "Spring AI");
170177

@@ -297,4 +304,34 @@ void shouldRenderTemplateWithBuiltInFunctions() {
297304
assertThat(result).isEqualTo("Hello!");
298305
}
299306

307+
@Test
308+
void malformedTemplateShouldLogErrorViaSlf4j() {
309+
Logger logger = (Logger) LoggerFactory.getLogger(StTemplateRenderer.class);
310+
ListAppender<ILoggingEvent> appender = new ListAppender<>();
311+
appender.start();
312+
logger.addAppender(appender);
313+
314+
PrintStream originalErr = System.err;
315+
ByteArrayOutputStream err = new ByteArrayOutputStream();
316+
System.setErr(new PrintStream(err));
317+
try {
318+
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
319+
Map<String, Object> variables = new HashMap<>();
320+
variables.put("name", "Spring AI");
321+
assertThatThrownBy(() -> renderer.apply("Hello {name!", variables))
322+
.isInstanceOf(IllegalArgumentException.class);
323+
}
324+
finally {
325+
System.setErr(originalErr);
326+
logger.detachAppender(appender);
327+
appender.stop();
328+
}
329+
330+
assertThat(appender.list).isNotEmpty();
331+
ILoggingEvent event = appender.list.get(0);
332+
assertThat(event.getLevel()).isEqualTo(Level.ERROR);
333+
assertThat(event.getFormattedMessage()).contains("StringTemplate compile error");
334+
assertThat(err.toString(StandardCharsets.UTF_8)).isEmpty();
335+
}
336+
300337
}

0 commit comments

Comments
 (0)