Skip to content

Commit 45b13ea

Browse files
committed
Make Functions Framework use StackDriver-compatible logging when on GCF.
In this environment, outputting a particular JSON-formatted string will produce a log entry with fields parsed from that string. Remove the special logging.properties file. The intent was to omit information like the timestamp that would be duplicated by the StackDriver logging framework. But we are now replacing the logger completely, so this logging.properties is no longer consulted. The logging.properties that was deleted also didn't include the [`%6$s` field](https://docs.oracle.com/en/java/javase/11/docs/api/java.logging/java/util/logging/SimpleFormatter.html#format(java.util.logging.LogRecord)) which meant that if there was an exception associated with the log message it did not appear. PiperOrigin-RevId: 302709439
1 parent a3d23cc commit 45b13ea

File tree

5 files changed

+241
-23
lines changed

5 files changed

+241
-23
lines changed
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package com.google.cloud.functions.invoker.gcf;
2+
3+
import java.io.PrintStream;
4+
import java.io.PrintWriter;
5+
import java.io.StringWriter;
6+
import java.util.ArrayList;
7+
import java.util.List;
8+
import java.util.logging.Handler;
9+
import java.util.logging.Level;
10+
import java.util.logging.LogRecord;
11+
12+
/**
13+
* A log handler that publishes log messages in a json format. This is StackDriver's
14+
* <a href="https://cloud.google.com/logging/docs/structured-logging">"structured
15+
* logging" format</a>.
16+
*/
17+
public final class JsonLogHandler extends Handler {
18+
private static final String SOURCE_LOCATION_KEY = "\"logging.googleapis.com/sourceLocation\": ";
19+
20+
private static final String DEBUG = "DEBUG";
21+
private static final String INFO = "INFO";
22+
private static final String WARNING = "WARNING";
23+
private static final String ERROR = "ERROR";
24+
private static final String DEFAULT = "DEFAULT";
25+
26+
private final PrintStream out;
27+
private final boolean closePrintStreamOnClose;
28+
29+
public JsonLogHandler(PrintStream out, boolean closePrintStreamOnClose) {
30+
this.out = out;
31+
this.closePrintStreamOnClose = closePrintStreamOnClose;
32+
}
33+
34+
@Override
35+
public void publish(LogRecord record) {
36+
// We avoid String.format and String.join even though they would simplify the code.
37+
// Logging code often shows up in profiling so we want to make this fast and StringBuilder is
38+
// more performant.
39+
StringBuilder json = new StringBuilder("{");
40+
appendSeverity(json, record);
41+
appendSourceLocation(json, record);
42+
appendMessage(json, record); // must be last, see appendMessage
43+
json.append("}");
44+
// We must output the log all at once (should only call println once per call to publish)
45+
out.println(json);
46+
}
47+
48+
private static void appendMessage(StringBuilder json, LogRecord record) {
49+
// This must be the last item in the JSON object, because it has no trailing comma. JSON is
50+
// unforgiving about commas and you can't have one just before }.
51+
json.append("\"message\": \"").append(escapeString(record.getMessage()));
52+
if (record.getThrown() != null) {
53+
json.append("\\n")
54+
.append(escapeString(getStackTraceAsString(record.getThrown())));
55+
}
56+
json.append("\"");
57+
}
58+
59+
private static void appendSeverity(StringBuilder json, LogRecord record) {
60+
json.append("\"severity\": \"").append(levelToSeverity(record.getLevel())).append("\", ");
61+
}
62+
63+
private static String levelToSeverity(Level level) {
64+
int intLevel = (level == null) ? 0 : level.intValue();
65+
switch (intLevel) {
66+
case 300: // FINEST
67+
case 400: // FINER
68+
case 500: // FINE
69+
return DEBUG;
70+
case 700: // CONFIG
71+
case 800: // INFO
72+
// Java's CONFIG is lower than its INFO, while Stackdriver's NOTICE is greater than its
73+
// INFO. So despite the similarity, we don't try to use NOTICE for CONFIG.
74+
return INFO;
75+
case 900: // WARNING
76+
return WARNING;
77+
case 1000: // SEVERE
78+
return ERROR;
79+
default:
80+
return DEFAULT;
81+
}
82+
}
83+
84+
private static void appendSourceLocation(StringBuilder json, LogRecord record) {
85+
if (record.getSourceClassName() == null && record.getSourceMethodName() == null) {
86+
return;
87+
}
88+
List<String> entries = new ArrayList<>();
89+
if (record.getSourceClassName() != null) {
90+
// TODO: Handle nested classes. If the source class name is com.example.Foo$Bar then the
91+
// source file is com/example/Foo.java, not com/example/Foo$Bar.java.
92+
String fileName = record.getSourceClassName().replace('.', '/') + ".java";
93+
entries.add("\"file\": \"" + escapeString(fileName) + "\"");
94+
}
95+
if (record.getSourceMethodName() != null) {
96+
entries.add("\"method\": \"" + escapeString(record.getSourceMethodName()) + "\"");
97+
}
98+
json.append(SOURCE_LOCATION_KEY).append("{").append(String.join(", ", entries)).append("}, ");
99+
}
100+
101+
private static String escapeString(String s) {
102+
return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r");
103+
}
104+
105+
private static String getStackTraceAsString(Throwable t) {
106+
StringWriter stringWriter = new StringWriter();
107+
t.printStackTrace(new PrintWriter(stringWriter));
108+
return stringWriter.toString();
109+
}
110+
111+
@Override
112+
public void flush() {
113+
out.flush();
114+
}
115+
116+
@Override
117+
public void close() throws SecurityException {
118+
if (closePrintStreamOnClose) {
119+
out.close();
120+
}
121+
}
122+
}

invoker/core/src/main/java/com/google/cloud/functions/invoker/runner/Invoker.java

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import com.google.cloud.functions.invoker.HttpFunctionSignatureMatcher;
3232
import com.google.cloud.functions.invoker.NewBackgroundFunctionExecutor;
3333
import com.google.cloud.functions.invoker.NewHttpFunctionExecutor;
34+
import com.google.cloud.functions.invoker.gcf.JsonLogHandler;
3435
import java.io.File;
3536
import java.io.IOException;
3637
import java.io.UncheckedIOException;
@@ -50,8 +51,8 @@
5051
import java.util.Objects;
5152
import java.util.Optional;
5253
import java.util.Set;
54+
import java.util.logging.Handler;
5355
import java.util.logging.Level;
54-
import java.util.logging.LogManager;
5556
import java.util.logging.Logger;
5657
import java.util.stream.Stream;
5758
import javax.servlet.ServletException;
@@ -80,17 +81,19 @@
8081
* </ul>
8182
*/
8283
public class Invoker {
83-
84-
private static final Logger logger;
84+
private static final Logger rootLogger = Logger.getLogger("");
85+
private static final Logger logger = Logger.getLogger(Invoker.class.getName());
8586

8687
static {
87-
try {
88-
LogManager.getLogManager()
89-
.readConfiguration(Invoker.class.getResourceAsStream("/logging.properties"));
90-
} catch (IOException e) {
91-
System.out.println("Failed to read logging configuration file, error: " + e.getMessage());
88+
if (isGcf()) {
89+
// If we're running with Google Cloud Functions, we'll get better-looking logs if we arrange
90+
// for them to be formatted using StackDriver's "structured logging" JSON format. Remove the
91+
// JDK's standard logger and replace it with the JSON one.
92+
for (Handler handler : rootLogger.getHandlers()) {
93+
rootLogger.removeHandler(handler);
94+
}
95+
rootLogger.addHandler(new JsonLogHandler(System.out, false));
9296
}
93-
logger = Logger.getLogger(Invoker.class.getName());
9497
}
9598

9699
private static class Options {
@@ -365,13 +368,19 @@ private static List<URL> jarsIn(String dir) {
365368
}
366369

367370
private void logServerInfo() {
368-
if (isLocalRun()) {
371+
if (!isGcf()) {
369372
logger.log(Level.INFO, "Serving function...");
370373
logger.log(Level.INFO, "Function: {0}", functionTarget);
371374
logger.log(Level.INFO, "URL: http://localhost:{0,number,#}/", port);
372375
}
373376
}
374377

378+
private static boolean isGcf() {
379+
// This environment variable is set in the GCF environment but won't be set when invoking
380+
// the Functions Framework directly. We don't use its value, just whether it is set.
381+
return System.getenv("K_SERVICE") != null;
382+
}
383+
375384
/**
376385
* Wrapper that intercepts requests for {@code /favicon.ico} and {@code /robots.txt} and causes
377386
* them to produce a 404 status. Otherwise they would be sent to the function code, like any

invoker/core/src/main/resources/logging.properties

Lines changed: 0 additions & 4 deletions
This file was deleted.

invoker/core/src/test/java/com/google/cloud/functions/invoker/IntegrationTest.java

Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
import java.net.ServerSocket;
4242
import java.net.URI;
4343
import java.net.URL;
44+
import java.net.URLEncoder;
4445
import java.nio.charset.StandardCharsets;
4546
import java.nio.file.Files;
4647
import java.nio.file.Path;
@@ -147,6 +148,8 @@ abstract static class TestCase {
147148

148149
abstract Optional<String> expectedContentType();
149150

151+
abstract Optional<String> expectedOutput();
152+
150153
abstract String httpContentType();
151154

152155
abstract ImmutableMap<String, String> httpHeaders();
@@ -178,6 +181,8 @@ abstract static class Builder {
178181

179182
abstract Builder setExpectedContentType(String x);
180183

184+
abstract Builder setExpectedOutput(String x);
185+
181186
abstract Builder setExpectedJsonString(String x);
182187

183188
abstract Builder setHttpContentType(String x);
@@ -272,6 +277,41 @@ public void newEchoUrl() throws Exception {
272277
testHttpFunction(fullTarget("NewEchoUrl"), testCases);
273278
}
274279

280+
@Test
281+
public void stackDriverLogging() throws Exception {
282+
String simpleExpectedOutput =
283+
"{\"severity\": \"INFO\", "
284+
+ "\"logging.googleapis.com/sourceLocation\": "
285+
+ "{\"file\": \"com/google/cloud/functions/invoker/testfunctions/Log.java\","
286+
+ " \"method\": \"service\"},"
287+
+ " \"message\": \"blim\"}";
288+
TestCase simpleTestCase =
289+
TestCase.builder()
290+
.setUrl("/?message=blim")
291+
.setExpectedOutput(simpleExpectedOutput)
292+
.build();
293+
String quotingExpectedOutput = "\"message\": \"foo\\nbar\\\"";
294+
TestCase quotingTestCase =
295+
TestCase.builder()
296+
.setUrl("/?message=" + URLEncoder.encode("foo\nbar\"", "UTF-8"))
297+
.setExpectedOutput(quotingExpectedOutput)
298+
.build();
299+
String exceptionExpectedOutput =
300+
"{\"severity\": \"ERROR\", "
301+
+ "\"logging.googleapis.com/sourceLocation\": "
302+
+ "{\"file\": \"com/google/cloud/functions/invoker/testfunctions/Log.java\", "
303+
+ "\"method\": \"service\"}, "
304+
+ "\"message\": \"oops\\njava.lang.Exception: disaster\\n"
305+
+ " at com.google.cloud.functions.invoker.testfunctions.Log.service(Log.java:";
306+
TestCase exceptionTestCase =
307+
TestCase.builder()
308+
.setUrl("/?message=oops&level=severe&exception=disaster")
309+
.setExpectedOutput(exceptionExpectedOutput)
310+
.build();
311+
testHttpFunction(
312+
fullTarget("Log"), ImmutableList.of(simpleTestCase, quotingTestCase, exceptionTestCase));
313+
}
314+
275315
@Test
276316
public void background() throws Exception {
277317
File snoopFile = snoopFile();
@@ -480,6 +520,8 @@ private void testFunction(
480520
if (testCase.snoopFile().isPresent()) {
481521
checkSnoopFile(testCase.snoopFile().get(), testCase.expectedJsonString().get());
482522
}
523+
testCase.expectedOutput()
524+
.ifPresent(output -> expect.that(serverProcess.output().toString()).contains(output));
483525
}
484526
} finally {
485527
serverProcess.close();
@@ -505,13 +547,29 @@ public String toString() {
505547
}
506548
}
507549

508-
@AutoValue
509-
abstract static class ServerProcess implements AutoCloseable {
510-
abstract Process process();
511-
abstract Future<?> outputMonitorResult();
550+
private static class ServerProcess implements AutoCloseable {
551+
private final Process process;
552+
private final Future<?> outputMonitorResult;
553+
private final StringBuilder output;
512554

513-
static ServerProcess of(Process process, Future<?> outputMonitorResult) {
514-
return new AutoValue_IntegrationTest_ServerProcess(process, outputMonitorResult);
555+
ServerProcess(Process process, Future<?> outputMonitorResult, StringBuilder output) {
556+
this.process = process;
557+
this.outputMonitorResult = outputMonitorResult;
558+
this.output = output;
559+
}
560+
561+
Process process() {
562+
return process;
563+
}
564+
565+
Future<?> outputMonitorResult() {
566+
return outputMonitorResult;
567+
}
568+
569+
String output() {
570+
synchronized (output) {
571+
return output.toString();
572+
}
515573
}
516574

517575
@Override
@@ -544,24 +602,29 @@ private ServerProcess startServer(
544602
processBuilder.environment().putAll(environment);
545603
Process serverProcess = processBuilder.start();
546604
CountDownLatch ready = new CountDownLatch(1);
605+
StringBuilder output = new StringBuilder();
547606
Future<?> outputMonitorResult = EXECUTOR.submit(
548-
() -> monitorOutput(serverProcess.getInputStream(), ready));
607+
() -> monitorOutput(serverProcess.getInputStream(), ready, output));
549608
boolean serverReady = ready.await(5, TimeUnit.SECONDS);
550609
if (!serverReady) {
551610
serverProcess.destroy();
552611
throw new AssertionError("Server never became ready");
553612
}
554-
return ServerProcess.of(serverProcess, outputMonitorResult);
613+
return new ServerProcess(serverProcess, outputMonitorResult, output);
555614
}
556615

557-
private void monitorOutput(InputStream processOutput, CountDownLatch ready) {
616+
private void monitorOutput(
617+
InputStream processOutput, CountDownLatch ready, StringBuilder output) {
558618
try (BufferedReader reader = new BufferedReader(new InputStreamReader(processOutput))) {
559619
String line;
560620
while ((line = reader.readLine()) != null) {
561621
if (line.contains(SERVER_READY_STRING)) {
562622
ready.countDown();
563623
}
564624
System.out.println(line);
625+
synchronized (output) {
626+
output.append(line).append('\n');
627+
}
565628
if (line.contains("WARNING")) {
566629
throw new AssertionError("Found warning in server output:\n" + line);
567630
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.google.cloud.functions.invoker.testfunctions;
2+
3+
import com.google.cloud.functions.HttpFunction;
4+
import com.google.cloud.functions.HttpRequest;
5+
import com.google.cloud.functions.HttpResponse;
6+
import java.lang.reflect.Field;
7+
import java.util.Optional;
8+
import java.util.logging.Level;
9+
import java.util.logging.Logger;
10+
11+
/** Emit log messages with configurable level, message, and exception. */
12+
public class Log implements HttpFunction {
13+
private static final Logger logger = Logger.getLogger(Log.class.getName());
14+
15+
@Override
16+
public void service(HttpRequest request, HttpResponse response) throws Exception {
17+
String message = request.getFirstQueryParameter("message").orElse("Default message");
18+
String levelString = request.getFirstQueryParameter("level").orElse("info");
19+
Optional<String> exceptionString = request.getFirstQueryParameter("exception");
20+
Field levelField = Level.class.getField(levelString.toUpperCase());
21+
Level level = (Level) levelField.get(null);
22+
if (exceptionString.isPresent()) {
23+
logger.log(level, message, new Exception(exceptionString.get()));
24+
} else {
25+
logger.log(level, message);
26+
}
27+
}
28+
}

0 commit comments

Comments
 (0)