Skip to content

Commit 735b705

Browse files
cecile75bm1549Cecile Terpinmcculls
authored
Add the possibility to output the logs of the Java tracer in JSON (#8083)
* POC - log in JSON format * initial commit * Adapt JSON keys to DD LOG UI * add some tests * use new JSON writer component * implementation is enough * Register that the 'datadog.json' package will be provided from the boot-class-path (necessary so we can bypass restricted lookups, such as from GraalVM) * simplify Stacktrace --------- Co-authored-by: Brian Marks <[email protected]> Co-authored-by: Cecile Terpin <“[email protected]”> Co-authored-by: Stuart McCulloch <[email protected]>
1 parent 9d387c7 commit 735b705

File tree

10 files changed

+201
-25
lines changed

10 files changed

+201
-25
lines changed

dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/Agent.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ public class Agent {
6868

6969
private static final String SIMPLE_LOGGER_SHOW_DATE_TIME_PROPERTY =
7070
"datadog.slf4j.simpleLogger.showDateTime";
71+
private static final String SIMPLE_LOGGER_JSON_ENABLED_PROPERTY =
72+
"datadog.slf4j.simpleLogger.jsonEnabled";
73+
private static final String SIMPLE_LOGGER_DATE_TIME_FORMAT_JSON_DEFAULT =
74+
"yyyy-MM-dd'T'HH:mm:ss.SSSZ";
7175
private static final String SIMPLE_LOGGER_DATE_TIME_FORMAT_PROPERTY =
7276
"datadog.slf4j.simpleLogger.dateTimeFormat";
7377
private static final String SIMPLE_LOGGER_DATE_TIME_FORMAT_DEFAULT =
@@ -1147,8 +1151,14 @@ private static synchronized void startDebuggerAgent(
11471151

11481152
private static void configureLogger() {
11491153
setSystemPropertyDefault(SIMPLE_LOGGER_SHOW_DATE_TIME_PROPERTY, "true");
1150-
setSystemPropertyDefault(
1151-
SIMPLE_LOGGER_DATE_TIME_FORMAT_PROPERTY, SIMPLE_LOGGER_DATE_TIME_FORMAT_DEFAULT);
1154+
setSystemPropertyDefault(SIMPLE_LOGGER_JSON_ENABLED_PROPERTY, "false");
1155+
if (System.getProperty(SIMPLE_LOGGER_JSON_ENABLED_PROPERTY).equalsIgnoreCase("true")) {
1156+
setSystemPropertyDefault(
1157+
SIMPLE_LOGGER_DATE_TIME_FORMAT_PROPERTY, SIMPLE_LOGGER_DATE_TIME_FORMAT_JSON_DEFAULT);
1158+
} else {
1159+
setSystemPropertyDefault(
1160+
SIMPLE_LOGGER_DATE_TIME_FORMAT_PROPERTY, SIMPLE_LOGGER_DATE_TIME_FORMAT_DEFAULT);
1161+
}
11521162

11531163
String logLevel;
11541164
if (isDebugMode()) {

dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/Constants.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public final class Constants {
1515
*/
1616
public static final String[] BOOTSTRAP_PACKAGE_PREFIXES = {
1717
"datadog.slf4j",
18+
"datadog.json",
1819
"datadog.context",
1920
"datadog.appsec.api",
2021
"datadog.trace.api",

dd-java-agent/agent-logging/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,5 @@ dependencies {
2323
// This is fine since this project is shadowed into the agent-jar by dd-java-agent:agent-bootstrap
2424
api libs.slf4j
2525
api project(':internal-api')
26+
implementation project(':components:json')
2627
}

dd-java-agent/agent-logging/src/main/java/datadog/trace/logging/simplelogger/SLCompatHelper.java

Lines changed: 92 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package datadog.trace.logging.simplelogger;
22

3+
import datadog.json.JsonWriter;
34
import datadog.trace.logging.LogLevel;
45
import datadog.trace.logging.LoggerHelper;
56
import org.slf4j.Marker;
@@ -34,7 +35,11 @@ public void log(LogLevel level, Marker marker, String message, Throwable t) {
3435
if (settings.showDateTime) {
3536
timeMillis = System.currentTimeMillis();
3637
}
37-
log(level, marker, SLCompatFactory.START_TIME, timeMillis, message, t);
38+
if (settings.jsonEnabled) {
39+
logJson(level, marker, SLCompatFactory.START_TIME, timeMillis, message, t);
40+
} else {
41+
log(level, marker, SLCompatFactory.START_TIME, timeMillis, message, t);
42+
}
3843
}
3944

4045
void log(
@@ -88,32 +93,109 @@ void log(
8893
}
8994
buf.append(' ');
9095

91-
if (logName.length() > 0) {
96+
if (!logName.isEmpty()) {
9297
buf.append(logName).append(" - ");
9398
}
9499

95100
buf.append(message);
96101

97-
if (settings.embedException) {
102+
if (settings.embedException && t != null) {
98103
embedException(buf, t);
99104
}
100105

101-
settings.printStream.println(buf.toString());
106+
settings.printStream.println(buf);
102107
if (!settings.embedException && t != null) {
103108
t.printStackTrace(settings.printStream);
104109
}
105110
}
106111

107112
private void embedException(StringBuilder buf, Throwable t) {
113+
buf.append(" [exception:");
114+
buf.append(t.toString());
115+
buf.append(".");
116+
for (StackTraceElement element : t.getStackTrace()) {
117+
buf.append(" at ");
118+
buf.append(element.toString());
119+
}
120+
buf.append("]");
121+
}
122+
123+
void logJson(
124+
LogLevel level,
125+
Marker marker,
126+
long startTimeMillis,
127+
long timeMillis,
128+
String message,
129+
Throwable t) {
130+
String threadName = null;
131+
if (settings.showThreadName) {
132+
threadName = Thread.currentThread().getName();
133+
}
134+
logJson(level, marker, startTimeMillis, timeMillis, threadName, message, t);
135+
}
136+
137+
void logJson(
138+
LogLevel level,
139+
Marker marker,
140+
long startTimeMillis,
141+
long timeMillis,
142+
String threadName,
143+
String message,
144+
Throwable t) {
145+
146+
JsonWriter writer = new JsonWriter();
147+
writer.beginObject();
148+
writer.name("origin").value("dd.trace");
149+
150+
if (timeMillis >= 0 && settings.showDateTime) {
151+
writer.name("date");
152+
StringBuilder buf = new StringBuilder(32);
153+
settings.dateTimeFormatter.appendFormattedDate(buf, timeMillis, startTimeMillis);
154+
writer.value(buf.toString());
155+
}
156+
157+
if (settings.showThreadName && threadName != null) {
158+
writer.name("logger.thread_name").value(threadName);
159+
}
160+
161+
writer.name("level");
162+
163+
if (settings.warnLevelString != null && level == LogLevel.WARN) {
164+
writer.value(wrappedValueWithBracketsIfRequested(settings.warnLevelString));
165+
} else if (marker != null) {
166+
writer.value(wrappedValueWithBracketsIfRequested(marker.getName()));
167+
} else {
168+
writer.value(wrappedValueWithBracketsIfRequested(level.name()));
169+
}
170+
171+
if (!logName.isEmpty()) {
172+
writer.name("logger.name").value(logName);
173+
}
174+
writer.name("message").value(message);
175+
108176
if (t != null) {
109-
buf.append(" [exception:");
110-
buf.append(t.toString());
111-
buf.append('.');
177+
embedExceptionJson(writer, t);
178+
}
179+
writer.endObject();
180+
settings.printStream.println(writer);
181+
}
182+
183+
private String wrappedValueWithBracketsIfRequested(String value) {
184+
return settings.levelInBrackets ? '[' + value + ']' : value;
185+
}
186+
187+
private void embedExceptionJson(JsonWriter writer, Throwable t) {
188+
writer.name("exception");
189+
writer.beginObject();
190+
writer.name("message").value(t.getMessage());
191+
if (t.getStackTrace().length > 0) {
192+
writer.name("stackTrace");
193+
writer.beginArray();
112194
for (StackTraceElement element : t.getStackTrace()) {
113-
buf.append(" at ");
114-
buf.append(element.toString());
195+
writer.value(element.toString());
115196
}
116-
buf.append(']');
197+
writer.endArray();
117198
}
199+
writer.endObject();
118200
}
119201
}

dd-java-agent/agent-logging/src/main/java/datadog/trace/logging/simplelogger/SLCompatSettings.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ public static final class Names {
3333
public static final String SHOW_THREAD_NAME = "showThreadName";
3434
public static final String DATE_TIME_FORMAT = "dateTimeFormat";
3535
public static final String SHOW_DATE_TIME = "showDateTime";
36+
public static final String JSON_ENABLED = "jsonEnabled";
3637
public static final String DEFAULT_LOG_LEVEL = "defaultLogLevel";
3738
public static final String EMBED_EXCEPTION = "embedException";
3839
public static final String CONFIGURATION_FILE = "configurationFile";
@@ -53,6 +54,7 @@ public static final class Keys {
5354
public static final String SHOW_THREAD_NAME = PREFIX + Names.SHOW_THREAD_NAME;
5455
public static final String DATE_TIME_FORMAT = PREFIX + Names.DATE_TIME_FORMAT;
5556
public static final String SHOW_DATE_TIME = PREFIX + Names.SHOW_DATE_TIME;
57+
public static final String JSON_ENABLED = PREFIX + Names.JSON_ENABLED;
5658
public static final String DEFAULT_LOG_LEVEL = PREFIX + Names.DEFAULT_LOG_LEVEL;
5759
public static final String EMBED_EXCEPTION = PREFIX + Names.EMBED_EXCEPTION;
5860

@@ -70,6 +72,7 @@ public static final class Defaults {
7072
public static final boolean SHOW_THREAD_NAME = true;
7173
public static final String DATE_TIME_FORMAT = null;
7274
public static final boolean SHOW_DATE_TIME = false;
75+
public static final boolean JSON_ENABLED = false;
7376
public static final String DEFAULT_LOG_LEVEL = "INFO";
7477
public static final boolean EMBED_EXCEPTION = false;
7578

@@ -271,6 +274,7 @@ static boolean getBoolean(
271274
final boolean showThreadName;
272275
final DTFormatter dateTimeFormatter;
273276
final boolean showDateTime;
277+
final boolean jsonEnabled;
274278
final LogLevel defaultLogLevel;
275279
final boolean embedException;
276280

@@ -304,6 +308,7 @@ public SLCompatSettings(
304308
getString(
305309
properties, fileProperties, Keys.DATE_TIME_FORMAT, Defaults.DATE_TIME_FORMAT)),
306310
getBoolean(properties, fileProperties, Keys.SHOW_DATE_TIME, Defaults.SHOW_DATE_TIME),
311+
getBoolean(properties, fileProperties, Keys.JSON_ENABLED, Defaults.JSON_ENABLED),
307312
LogLevel.fromString(
308313
getString(
309314
properties, fileProperties, Keys.DEFAULT_LOG_LEVEL, Defaults.DEFAULT_LOG_LEVEL)),
@@ -321,6 +326,7 @@ public SLCompatSettings(
321326
boolean showThreadName,
322327
DTFormatter dateTimeFormatter,
323328
boolean showDateTime,
329+
boolean jsonEnabled,
324330
LogLevel defaultLogLevel,
325331
boolean embedException) {
326332
this.properties = properties;
@@ -333,6 +339,7 @@ public SLCompatSettings(
333339
this.showThreadName = showThreadName;
334340
this.dateTimeFormatter = dateTimeFormatter;
335341
this.showDateTime = showDateTime;
342+
this.jsonEnabled = jsonEnabled;
336343
this.defaultLogLevel = defaultLogLevel;
337344
this.embedException = embedException;
338345
}
@@ -375,6 +382,7 @@ public Map<String, Object> getSettingsDescription() {
375382
settingsDescription.put(Names.SHOW_SHORT_LOG_NAME, showShortLogName);
376383
settingsDescription.put(Names.SHOW_THREAD_NAME, showThreadName);
377384
settingsDescription.put(Names.SHOW_DATE_TIME, showDateTime);
385+
settingsDescription.put(Names.JSON_ENABLED, jsonEnabled);
378386
String dateTimeFormat =
379387
getString(properties, fileProperties, Keys.DATE_TIME_FORMAT, Defaults.DATE_TIME_FORMAT);
380388
settingsDescription.put(

dd-java-agent/agent-logging/src/test/groovy/datadog/trace/logging/ddlogger/DDLoggerTest.groovy

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,7 @@ class DDLoggerTest extends LogValidatingSpecification {
359359
(Names.SHOW_SHORT_LOG_NAME): Defaults.SHOW_SHORT_LOG_NAME,
360360
(Names.SHOW_THREAD_NAME): false,
361361
(Names.SHOW_DATE_TIME): true,
362+
(Names.JSON_ENABLED): Defaults.JSON_ENABLED,
362363
(Names.DATE_TIME_FORMAT): "relative",
363364
(Names.DEFAULT_LOG_LEVEL): expectedLevel,
364365
(Names.EMBED_EXCEPTION): Defaults.EMBED_EXCEPTION,

dd-java-agent/agent-logging/src/test/groovy/datadog/trace/logging/simplelogger/SLCompatHelperTest.groovy

Lines changed: 82 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -139,25 +139,94 @@ class SLCompatHelperTest extends Specification {
139139
def printStream = new PrintStream(outputStream, true)
140140
def props = new Properties()
141141
def dateTimeFormatter = SLCompatSettings.DTFormatter.create(dateTFS)
142-
def settings = new SLCompatSettings(props, props, warnS, showB, printStream, showS, showL, showT, dateTimeFormatter, showDT, LogLevel.INFO, false)
142+
def settings = new SLCompatSettings(props, props, warnS, showB, printStream, showS, showL, showT, dateTimeFormatter, showDT, jsonE, LogLevel.INFO, false)
143143
def helper = new SLCompatHelper("foo.bar", settings)
144+
144145
helper.log(level, null, 0, 4711, "thread", "log", null)
145146

146147
then:
147148
outputStream.toString() == expected
148149

149150
where:
150-
level | warnS | showB | showS | showL | showT | dateTFS | showDT | expected
151-
LogLevel.WARN | null | false | false | false | false | null | false | "WARN log\n"
152-
LogLevel.WARN | "DANGER" | false | false | false | false | null | false | "DANGER log\n"
153-
LogLevel.INFO | "DANGER" | false | false | false | false | null | false | "INFO log\n"
154-
LogLevel.WARN | null | true | false | false | false | null | false | "[WARN] log\n"
155-
LogLevel.INFO | null | false | true | false | false | null | false | "INFO bar - log\n"
156-
LogLevel.INFO | null | true | true | true | false | null | false | "[INFO] bar - log\n"
157-
LogLevel.INFO | null | true | false | true | false | null | false | "[INFO] foo.bar - log\n"
158-
LogLevel.INFO | null | false | false | false | true | null | false | "[thread] INFO log\n"
159-
LogLevel.INFO | null | false | false | false | true | null | true | "4711 [thread] INFO log\n"
160-
LogLevel.INFO | null | false | false | false | true | "yyyy-MM-dd HH:mm:ss z" | false | "[thread] INFO log\n"
161-
LogLevel.INFO | null | false | false | false | true | "yyyy-MM-dd HH:mm:ss z" | true | "${new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z").format(new Date(4711))} [thread] INFO log\n"
151+
level | warnS | showB | showS | showL | showT | dateTFS | showDT | jsonE | expected
152+
LogLevel.WARN | null | false | false | false | false | null | false | false | "WARN log\n"
153+
LogLevel.WARN | "DANGER" | false | false | false | false | null | false | false | "DANGER log\n"
154+
LogLevel.INFO | "DANGER" | false | false | false | false | null | false | false | "INFO log\n"
155+
LogLevel.WARN | null | true | false | false | false | null | false | false | "[WARN] log\n"
156+
LogLevel.INFO | null | false | true | false | false | null | false | false | "INFO bar - log\n"
157+
LogLevel.INFO | null | true | true | true | false | null | false | false | "[INFO] bar - log\n"
158+
LogLevel.INFO | null | true | false | true | false | null | false | false | "[INFO] foo.bar - log\n"
159+
LogLevel.INFO | null | false | false | false | true | null | false | false | "[thread] INFO log\n"
160+
LogLevel.INFO | null | false | false | false | true | null | true | false | "4711 [thread] INFO log\n"
161+
LogLevel.INFO | null | false | false | false | true | "yyyy-MM-dd HH:mm:ss z" | false | false | "[thread] INFO log\n"
162+
LogLevel.INFO | null | false | false | false | true | "yyyy-MM-dd HH:mm:ss z" | true | false | "${new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z").format(new Date(4711))} [thread] INFO log\n"
163+
}
164+
165+
166+
167+
def "test log output with Json configuration key"() {
168+
when:
169+
def outputStream = new ByteArrayOutputStream()
170+
def printStream = new PrintStream(outputStream, true)
171+
def props = new Properties()
172+
def dateTimeFormatter = SLCompatSettings.DTFormatter.create(dateTFS)
173+
def settings = new SLCompatSettings(props, props, warnS, showB, printStream, showS, showL, showT, dateTimeFormatter, showDT, jsonE, LogLevel.INFO, false)
174+
def helper = new SLCompatHelper("foo.bar", settings)
175+
176+
// helper.log is where we split between logs and JSON logs
177+
helper.log(level, null, "log", null)
178+
179+
then:
180+
outputStream.toString() == expected
181+
182+
where:
183+
level | warnS | showB | showS | showL | showT | dateTFS | showDT | jsonE | expected
184+
LogLevel.WARN | null | false | false | false | false | null | false | false | "WARN log\n"
185+
LogLevel.WARN | "DANGER" | false | false | false | false | null | false | true | "{\"origin\":\"dd.trace\",\"level\":\"DANGER\",\"message\":\"log\"}\n"
186+
}
187+
188+
def "test log output in Json"() {
189+
when:
190+
def outputStream = new ByteArrayOutputStream()
191+
def printStream = new PrintStream(outputStream, true)
192+
def props = new Properties()
193+
def dateTimeFormatter = SLCompatSettings.DTFormatter.create(dateTFS)
194+
def settings = new SLCompatSettings(props, props, warnS, showB, printStream, showS, showL, showT, dateTimeFormatter, showDT, jsonE, LogLevel.INFO, false)
195+
def helper = new SLCompatHelper("foo.bar", settings)
196+
197+
helper.logJson(level,null,0,4711,"thread","log", null)
198+
199+
then:
200+
outputStream.toString() == expected
201+
202+
where:
203+
level | warnS | showB | showS | showL | showT | dateTFS | showDT | jsonE | expected
204+
LogLevel.WARN | "DANGER" | false | false | false | false | null | false | true | "{\"origin\":\"dd.trace\",\"level\":\"DANGER\",\"message\":\"log\"}\n"
205+
LogLevel.INFO | "DANGER" | false | false | false | false | null | false | true | "{\"origin\":\"dd.trace\",\"level\":\"INFO\",\"message\":\"log\"}\n"
206+
LogLevel.WARN | null | true | false | false | false | null | false | true | "{\"origin\":\"dd.trace\",\"level\":\"[WARN]\",\"message\":\"log\"}\n"
207+
LogLevel.INFO | null | false | true | false | false | null | false | true | "{\"origin\":\"dd.trace\",\"level\":\"INFO\",\"logger.name\":\"bar\",\"message\":\"log\"}\n"
208+
LogLevel.INFO | null | true | true | true | false | null | false | true | "{\"origin\":\"dd.trace\",\"level\":\"[INFO]\",\"logger.name\":\"bar\",\"message\":\"log\"}\n"
209+
LogLevel.INFO | null | true | false | true | false | null | false | true | "{\"origin\":\"dd.trace\",\"level\":\"[INFO]\",\"logger.name\":\"foo.bar\",\"message\":\"log\"}\n"
210+
LogLevel.INFO | null | false | false | false | true | null | false | true | "{\"origin\":\"dd.trace\",\"logger.thread_name\":\"thread\",\"level\":\"INFO\",\"message\":\"log\"}\n"
211+
LogLevel.INFO | null | false | false | false | true | "yyyy-MM-dd HH:mm:ss z" | false | true | "{\"origin\":\"dd.trace\",\"logger.thread_name\":\"thread\",\"level\":\"INFO\",\"message\":\"log\"}\n"
212+
LogLevel.INFO | null | false | false | false | true | "yyyy-MM-dd HH:mm:ss z" | true | true | "{\"origin\":\"dd.trace\",\"date\":\"${new SimpleDateFormat(dateTFS).format(new Date(4711))}\",\"logger.thread_name\":\"thread\",\"level\":\"INFO\",\"message\":\"log\"}\n"
213+
}
214+
215+
216+
def "test logging with an embedded exception in Json"() {
217+
setup:
218+
def outputStream = new ByteArrayOutputStream()
219+
def printStream = new PrintStream(outputStream, true)
220+
def props = new Properties()
221+
def dateTimeFormatter = SLCompatSettings.DTFormatter.create("yyyy-MM-dd HH:mm:ss z")
222+
def settings = new SLCompatSettings(props, props, null, false, printStream, false,true,false, dateTimeFormatter, false, true, LogLevel.INFO, true)
223+
def helper = new SLCompatHelper("foo", settings)
224+
try {
225+
throw new IOException("wrong")
226+
} catch(Exception exception) {
227+
helper.log(LogLevel.INFO, null, "log", exception)
228+
}
229+
expect:
230+
outputStream.toString() ==~ /^\{"origin":"dd.trace","level":"INFO","logger.name":"foo","message":"log","exception":\{"message":"wrong","stackTrace":\[.*\]\}\}\n$/
162231
}
163232
}

dd-java-agent/agent-logging/src/test/groovy/datadog/trace/logging/simplelogger/SLCompatSettingsTest.groovy

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ class SLCompatSettingsTest extends Specification {
7171
settings.dateTimeFormatter.class == SLCompatSettings.DiffDTFormatter
7272
settings.showDateTime == false
7373
settings.defaultLogLevel == LogLevel.INFO
74+
settings.jsonEnabled == false
7475
}
7576

7677
def "test file properties"() {
@@ -91,6 +92,7 @@ class SLCompatSettingsTest extends Specification {
9192
formatted.toString() == new SimpleDateFormat("'['yy-dd-MM HH:mm:ss:SSS Z']'").format(new Date(4711 << 20))
9293
settings.showDateTime == true
9394
settings.defaultLogLevel == LogLevel.DEBUG
95+
settings.jsonEnabled == true
9496
}
9597

9698
def "test log file creation"() {

dd-java-agent/agent-logging/src/test/resources/slcompatsettingstest.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ datadog.slf4j.simpleLogger.showThreadName = false
77
datadog.slf4j.simpleLogger.dateTimeFormat = '['yy-dd-MM HH:mm:ss:SSS Z']'
88
datadog.slf4j.simpleLogger.showDateTime = true
99
datadog.slf4j.simpleLogger.defaultLogLevel = DEBUG
10+
datadog.slf4j.simpleLogger.jsonEnabled = true

dd-java-agent/testing/src/main/groovy/datadog/trace/agent/test/SpockRunner.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ public class SpockRunner extends JUnitPlatform {
3838
*/
3939
public static final String[] BOOTSTRAP_PACKAGE_PREFIXES_COPY = {
4040
"datadog.slf4j",
41+
"datadog.json",
4142
"datadog.context",
4243
"datadog.appsec.api",
4344
"datadog.trace.api",

0 commit comments

Comments
 (0)