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