001package ca.uhn.fhir.rest.server.interceptor; 002 003import ca.uhn.fhir.interceptor.api.Hook; 004import ca.uhn.fhir.interceptor.api.Interceptor; 005import ca.uhn.fhir.interceptor.api.Pointcut; 006import ca.uhn.fhir.rest.api.Constants; 007import ca.uhn.fhir.rest.api.EncodingEnum; 008import ca.uhn.fhir.rest.api.server.RequestDetails; 009import ca.uhn.fhir.rest.server.RestfulServer; 010import ca.uhn.fhir.rest.server.RestfulServerUtils; 011import ca.uhn.fhir.rest.server.RestfulServerUtils.ResponseEncoding; 012import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException; 013import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; 014import ca.uhn.fhir.util.UrlUtil; 015import org.apache.commons.lang3.StringUtils; 016import org.apache.commons.lang3.Validate; 017import org.apache.commons.text.StringSubstitutor; 018import org.apache.commons.text.lookup.StringLookup; 019import org.slf4j.Logger; 020import org.slf4j.LoggerFactory; 021 022import javax.servlet.ServletException; 023import javax.servlet.http.HttpServletRequest; 024import javax.servlet.http.HttpServletResponse; 025import java.io.IOException; 026import java.util.Date; 027import java.util.Map.Entry; 028 029import static org.apache.commons.lang3.StringUtils.isNotBlank; 030 031/* 032 * #%L 033 * HAPI FHIR - Server Framework 034 * %% 035 * Copyright (C) 2014 - 2019 University Health Network 036 * %% 037 * Licensed under the Apache License, Version 2.0 (the "License"); 038 * you may not use this file except in compliance with the License. 039 * You may obtain a copy of the License at 040 * 041 * http://www.apache.org/licenses/LICENSE-2.0 042 * 043 * Unless required by applicable law or agreed to in writing, software 044 * distributed under the License is distributed on an "AS IS" BASIS, 045 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 046 * See the License for the specific language governing permissions and 047 * limitations under the License. 048 * #L% 049 */ 050 051/** 052 * Server interceptor which logs each request using a defined format 053 * <p> 054 * The following substitution variables are supported: 055 * </p> 056 * <table summary="Substitution variables supported by this class"> 057 * <tr> 058 * <td>${id}</td> 059 * <td>The resource ID associated with this request (or "" if none)</td> 060 * </tr> 061 * <tr> 062 * <td>${idOrResourceName}</td> 063 * <td>The resource ID associated with this request, or the resource name if the request applies to a type but not an 064 * instance, or "" otherwise</td> 065 * </tr> 066 * <tr> 067 * <td>${operationName}</td> 068 * <td>If the request is an extended operation (e.g. "$validate") this value will be the operation name, or "" 069 * otherwise</td> 070 * </tr> 071 * <tr> 072 * <td>${operationType}</td> 073 * <td>A code indicating the operation type for this request, e.g. "read", "history-instance", 074 * "extended-operation-instance", etc.)</td> 075 * </tr> 076 * <tr> 077 * <td>${remoteAddr}</td> 078 * <td>The originating IP of the request</td> 079 * </tr> 080 * <tr> 081 * <td>${requestHeader.XXXX}</td> 082 * <td>The value of the HTTP request header named XXXX. For example, a substitution variable named 083 * "${requestHeader.x-forwarded-for} will yield the value of the first header named "x-forwarded-for 084 * ", or "" if none.</td> 085 * </tr> 086 * <tr> 087 * <td>${requestParameters}</td> 088 * <td>The HTTP request parameters (or "")</td> 089 * </tr> 090 * <tr> 091 * <td>${responseEncodingNoDefault}</td> 092 * <td>The encoding format requested by the client via the _format parameter or the Accept header. Value will be "json" 093 * or "xml", or "" if the client did not explicitly request a format</td> 094 * </tr> 095 * <tr> 096 * <td>${servletPath}</td> 097 * <td>The part of thre requesting URL that corresponds to the particular Servlet being called (see 098 * {@link HttpServletRequest#getServletPath()})</td> 099 * </tr> 100 * <tr> 101 * <td>${requestBodyFhir}</td> 102 * <td>The complete body of the request if the request has a FHIR content-type (this can be quite large!). Will emit an 103 * empty string if the content type is not a FHIR content type</td> 104 * </tr> 105 * <tr> 106 * <td>${requestUrl}</td> 107 * <td>The complete URL of the request</td> 108 * </tr> 109 * <tr> 110 * <td>${requestVerb}</td> 111 * <td>The HTTP verb of the request</td> 112 * </tr> 113 * <tr> 114 * <td>${exceptionMessage}</td> 115 * <td>Applies only to an error message: The message from {@link Exception#getMessage()}</td> 116 * </tr> 117 * <tr> 118 * <td>${processingTimeMillis}</td> 119 * <td>The number of milliseconds spent processing this request</td> 120 * </tr> 121 * </table> 122 */ 123@Interceptor 124public class LoggingInterceptor { 125 126 private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(LoggingInterceptor.class); 127 128 private String myErrorMessageFormat = "ERROR - ${operationType} - ${idOrResourceName}"; 129 private boolean myLogExceptions = true; 130 private Logger myLogger = ourLog; 131 private String myMessageFormat = "${operationType} - ${idOrResourceName}"; 132 133 /** 134 * Constructor for server logging interceptor 135 */ 136 public LoggingInterceptor() { 137 super(); 138 } 139 140 /** 141 * Get the log message format to be used when logging exceptions 142 */ 143 public String getErrorMessageFormat() { 144 return myErrorMessageFormat; 145 } 146 147 @Hook(Pointcut.SERVER_HANDLE_EXCEPTION) 148 public boolean handleException(RequestDetails theRequestDetails, BaseServerResponseException theException, HttpServletRequest theServletRequest, HttpServletResponse theServletResponse) throws ServletException, IOException { 149 if (myLogExceptions) { 150 // Perform any string substitutions from the message format 151 StringLookup lookup = new MyLookup(theServletRequest, theException, theRequestDetails); 152 StringSubstitutor subs = new StringSubstitutor(lookup, "${", "}", '\\'); 153 154 // Actuall log the line 155 String line = subs.replace(myErrorMessageFormat); 156 myLogger.info(line); 157 158 } 159 return true; 160 } 161 162 163 @Hook(Pointcut.SERVER_PROCESSING_COMPLETED_NORMALLY) 164 public void processingCompletedNormally(ServletRequestDetails theRequestDetails) { 165 // Perform any string substitutions from the message format 166 StringLookup lookup = new MyLookup(theRequestDetails.getServletRequest(), theRequestDetails); 167 StringSubstitutor subs = new StringSubstitutor(lookup, "${", "}", '\\'); 168 169 // Actually log the line 170 String line = subs.replace(myMessageFormat); 171 myLogger.info(line); 172 } 173 174 /** 175 * Should exceptions be logged by this logger 176 */ 177 public boolean isLogExceptions() { 178 return myLogExceptions; 179 } 180 181 /** 182 * Set the log message format to be used when logging exceptions 183 */ 184 public void setErrorMessageFormat(String theErrorMessageFormat) { 185 Validate.notBlank(theErrorMessageFormat, "Message format can not be null/empty"); 186 myErrorMessageFormat = theErrorMessageFormat; 187 } 188 189 /** 190 * Should exceptions be logged by this logger 191 */ 192 public void setLogExceptions(boolean theLogExceptions) { 193 myLogExceptions = theLogExceptions; 194 } 195 196 public void setLogger(Logger theLogger) { 197 Validate.notNull(theLogger, "Logger can not be null"); 198 myLogger = theLogger; 199 } 200 201 public void setLoggerName(String theLoggerName) { 202 Validate.notBlank(theLoggerName, "Logger name can not be null/empty"); 203 myLogger = LoggerFactory.getLogger(theLoggerName); 204 205 } 206 207 /** 208 * Sets the message format itself. See the {@link LoggingInterceptor class documentation} for information on the 209 * format 210 */ 211 public void setMessageFormat(String theMessageFormat) { 212 Validate.notBlank(theMessageFormat, "Message format can not be null/empty"); 213 myMessageFormat = theMessageFormat; 214 } 215 216 private static final class MyLookup implements StringLookup { 217 private final Throwable myException; 218 private final HttpServletRequest myRequest; 219 private final RequestDetails myRequestDetails; 220 221 private MyLookup(HttpServletRequest theRequest, RequestDetails theRequestDetails) { 222 myRequest = theRequest; 223 myRequestDetails = theRequestDetails; 224 myException = null; 225 } 226 227 MyLookup(HttpServletRequest theServletRequest, BaseServerResponseException theException, RequestDetails theRequestDetails) { 228 myException = theException; 229 myRequestDetails = theRequestDetails; 230 myRequest = theServletRequest; 231 } 232 233 @Override 234 public String lookup(String theKey) { 235 236 /* 237 * TODO: this method could be made more efficient through some sort of lookup map 238 */ 239 240 if ("operationType".equals(theKey)) { 241 if (myRequestDetails.getRestOperationType() != null) { 242 return myRequestDetails.getRestOperationType().getCode(); 243 } 244 return ""; 245 } else if ("operationName".equals(theKey)) { 246 if (myRequestDetails.getRestOperationType() != null) { 247 switch (myRequestDetails.getRestOperationType()) { 248 case EXTENDED_OPERATION_INSTANCE: 249 case EXTENDED_OPERATION_SERVER: 250 case EXTENDED_OPERATION_TYPE: 251 return myRequestDetails.getOperation(); 252 default: 253 return ""; 254 } 255 } 256 return ""; 257 } else if ("id".equals(theKey)) { 258 if (myRequestDetails.getId() != null) { 259 return myRequestDetails.getId().getValue(); 260 } 261 return ""; 262 } else if ("servletPath".equals(theKey)) { 263 return StringUtils.defaultString(myRequest.getServletPath()); 264 } else if ("idOrResourceName".equals(theKey)) { 265 if (myRequestDetails.getId() != null) { 266 return myRequestDetails.getId().getValue(); 267 } 268 if (myRequestDetails.getResourceName() != null) { 269 return myRequestDetails.getResourceName(); 270 } 271 return ""; 272 } else if (theKey.equals("requestParameters")) { 273 StringBuilder b = new StringBuilder(); 274 for (Entry<String, String[]> next : myRequestDetails.getParameters().entrySet()) { 275 for (String nextValue : next.getValue()) { 276 if (b.length() == 0) { 277 b.append('?'); 278 } else { 279 b.append('&'); 280 } 281 b.append(UrlUtil.escapeUrlParam(next.getKey())); 282 b.append('='); 283 b.append(UrlUtil.escapeUrlParam(nextValue)); 284 } 285 } 286 return b.toString(); 287 } else if (theKey.startsWith("requestHeader.")) { 288 String val = myRequest.getHeader(theKey.substring("requestHeader.".length())); 289 return StringUtils.defaultString(val); 290 } else if (theKey.startsWith("remoteAddr")) { 291 return StringUtils.defaultString(myRequest.getRemoteAddr()); 292 } else if (theKey.equals("responseEncodingNoDefault")) { 293 ResponseEncoding encoding = RestfulServerUtils.determineResponseEncodingNoDefault(myRequestDetails, myRequestDetails.getServer().getDefaultResponseEncoding()); 294 if (encoding != null) { 295 return encoding.getEncoding().name(); 296 } 297 return ""; 298 } else if (theKey.equals("exceptionMessage")) { 299 return myException != null ? myException.getMessage() : null; 300 } else if (theKey.equals("requestUrl")) { 301 return myRequest.getRequestURL().toString(); 302 } else if (theKey.equals("requestVerb")) { 303 return myRequest.getMethod(); 304 } else if (theKey.equals("requestBodyFhir")) { 305 String contentType = myRequest.getContentType(); 306 if (isNotBlank(contentType)) { 307 int colonIndex = contentType.indexOf(';'); 308 if (colonIndex != -1) { 309 contentType = contentType.substring(0, colonIndex); 310 } 311 contentType = contentType.trim(); 312 313 EncodingEnum encoding = EncodingEnum.forContentType(contentType); 314 if (encoding != null) { 315 byte[] requestContents = myRequestDetails.loadRequestContents(); 316 return new String(requestContents, Constants.CHARSET_UTF8); 317 } 318 } 319 return ""; 320 } else if ("processingTimeMillis".equals(theKey)) { 321 Date startTime = (Date) myRequest.getAttribute(RestfulServer.REQUEST_START_TIME); 322 if (startTime != null) { 323 long time = System.currentTimeMillis() - startTime.getTime(); 324 return Long.toString(time); 325 } 326 } 327 328 return "!VAL!"; 329 } 330 } 331 332}