
001/* 002 * #%L 003 * HAPI FHIR - Server Framework 004 * %% 005 * Copyright (C) 2014 - 2025 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.model.primitive.IdDt; 026import ca.uhn.fhir.rest.api.Constants; 027import ca.uhn.fhir.rest.api.EncodingEnum; 028import ca.uhn.fhir.rest.api.server.RequestDetails; 029import ca.uhn.fhir.rest.server.RestfulServer; 030import ca.uhn.fhir.rest.server.RestfulServerUtils; 031import ca.uhn.fhir.rest.server.RestfulServerUtils.ResponseEncoding; 032import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException; 033import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; 034import ca.uhn.fhir.util.UrlUtil; 035import jakarta.servlet.ServletException; 036import jakarta.servlet.http.HttpServletRequest; 037import jakarta.servlet.http.HttpServletResponse; 038import org.apache.commons.lang3.StringUtils; 039import org.apache.commons.lang3.Validate; 040import org.apache.commons.text.StringSubstitutor; 041import org.apache.commons.text.lookup.StringLookup; 042import org.slf4j.Logger; 043import org.slf4j.LoggerFactory; 044 045import java.io.IOException; 046import java.util.Date; 047import java.util.Map.Entry; 048 049import static org.apache.commons.lang3.StringUtils.isNotBlank; 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>${requestId}</td> 073 * <td>The request ID assigned to this request (either automatically, or via the <code>X-Request-ID</code> header in the request)</td> 074 * </tr> 075 * <tr> 076 * <td>${operationType}</td> 077 * <td>A code indicating the operation type for this request, e.g. "read", "history-instance", 078 * "extended-operation-instance", etc.)</td> 079 * </tr> 080 * <tr> 081 * <td>${remoteAddr}</td> 082 * <td>The originating IP of the request</td> 083 * </tr> 084 * <tr> 085 * <td>${requestHeader.XXXX}</td> 086 * <td>The value of the HTTP request header named XXXX. For example, a substitution variable named 087 * "${requestHeader.x-forwarded-for} will yield the value of the first header named "x-forwarded-for 088 * ", or "" if none.</td> 089 * </tr> 090 * <tr> 091 * <td>${requestParameters}</td> 092 * <td>The HTTP request parameters (or "")</td> 093 * </tr> 094 * <tr> 095 * <td>${responseEncodingNoDefault}</td> 096 * <td>The encoding format requested by the client via the _format parameter or the Accept header. Value will be "json" 097 * or "xml", or "" if the client did not explicitly request a format</td> 098 * </tr> 099 * <tr> 100 * <td>${servletPath}</td> 101 * <td>The part of thre requesting URL that corresponds to the particular Servlet being called (see 102 * {@link HttpServletRequest#getServletPath()})</td> 103 * </tr> 104 * <tr> 105 * <td>${requestBodyFhir}</td> 106 * <td>The complete body of the request if the request has a FHIR content-type (this can be quite large!). Will emit an 107 * empty string if the content type is not a FHIR content type</td> 108 * </tr> 109 * <tr> 110 * <td>${requestUrl}</td> 111 * <td>The complete URL of the request</td> 112 * </tr> 113 * <tr> 114 * <td>${requestVerb}</td> 115 * <td>The HTTP verb of the request</td> 116 * </tr> 117 * <tr> 118 * <td>${responseId}</td> 119 * <td>For operations which write a resource (create/update/patch), provides the ID of that resource as supplied in the <code>Location</code> header.</td> 120 * </tr> 121 * <tr> 122 * <td>${exceptionMessage}</td> 123 * <td>Applies only to an error message: The message from {@link Exception#getMessage()}</td> 124 * </tr> 125 * <tr> 126 * <td>${processingTimeMillis}</td> 127 * <td>The number of milliseconds spen processing this request</td> 128 * </tr> 129 * </table> 130 */ 131@Interceptor 132public class LoggingInterceptor { 133 134 private static final Logger ourLog = LoggerFactory.getLogger(LoggingInterceptor.class); 135 136 private String myErrorMessageFormat = "ERROR - ${operationType} - ${idOrResourceName}"; 137 private boolean myLogExceptions = true; 138 private Logger myLogger = ourLog; 139 private String myMessageFormat = "${operationType} - ${idOrResourceName}"; 140 141 /** 142 * Constructor for server logging interceptor 143 */ 144 public LoggingInterceptor() { 145 super(); 146 } 147 148 /** 149 * Get the log message format to be used when logging exceptions 150 */ 151 public String getErrorMessageFormat() { 152 return myErrorMessageFormat; 153 } 154 155 @Hook(Pointcut.SERVER_HANDLE_EXCEPTION) 156 public boolean handleException( 157 RequestDetails theRequestDetails, 158 BaseServerResponseException theException, 159 HttpServletRequest theServletRequest, 160 HttpServletResponse theServletResponse) 161 throws ServletException, IOException { 162 if (myLogExceptions) { 163 // Perform any string substitutions from the message format 164 StringLookup lookup = new MyLookup(theServletRequest, theServletResponse, theException, theRequestDetails); 165 StringSubstitutor subs = new StringSubstitutor(lookup, "${", "}", '\\'); 166 167 // Actually log the line 168 String line = subs.replace(myErrorMessageFormat); 169 myLogger.info(line); 170 } 171 return true; 172 } 173 174 @Hook(Pointcut.SERVER_PROCESSING_COMPLETED_NORMALLY) 175 public void processingCompletedNormally(ServletRequestDetails theRequestDetails) { 176 // Perform any string substitutions from the message format 177 StringLookup lookup = new MyLookup( 178 theRequestDetails.getServletRequest(), theRequestDetails.getServletResponse(), theRequestDetails); 179 StringSubstitutor subs = new StringSubstitutor(lookup, "${", "}", '\\'); 180 181 // Actually log the line 182 String line = subs.replace(myMessageFormat); 183 myLogger.info(line); 184 } 185 186 /** 187 * Should exceptions be logged by this logger 188 */ 189 public boolean isLogExceptions() { 190 return myLogExceptions; 191 } 192 193 /** 194 * Set the log message format to be used when logging exceptions 195 */ 196 public void setErrorMessageFormat(String theErrorMessageFormat) { 197 Validate.notBlank(theErrorMessageFormat, "Message format can not be null/empty"); 198 myErrorMessageFormat = theErrorMessageFormat; 199 } 200 201 /** 202 * Should exceptions be logged by this logger 203 */ 204 public void setLogExceptions(boolean theLogExceptions) { 205 myLogExceptions = theLogExceptions; 206 } 207 208 public void setLogger(Logger theLogger) { 209 Validate.notNull(theLogger, "Logger can not be null"); 210 myLogger = theLogger; 211 } 212 213 public void setLoggerName(String theLoggerName) { 214 Validate.notBlank(theLoggerName, "Logger name can not be null/empty"); 215 myLogger = LoggerFactory.getLogger(theLoggerName); 216 } 217 218 /** 219 * Sets the message format itself. See the {@link LoggingInterceptor class documentation} for information on the 220 * format 221 */ 222 public void setMessageFormat(String theMessageFormat) { 223 Validate.notBlank(theMessageFormat, "Message format can not be null/empty"); 224 myMessageFormat = theMessageFormat; 225 } 226 227 private static final class MyLookup implements StringLookup { 228 private final Throwable myException; 229 private final HttpServletRequest myRequest; 230 private final RequestDetails myRequestDetails; 231 private final HttpServletResponse myResponse; 232 233 private MyLookup( 234 HttpServletRequest theRequest, HttpServletResponse theResponse, RequestDetails theRequestDetails) { 235 myRequest = theRequest; 236 myResponse = theResponse; 237 myRequestDetails = theRequestDetails; 238 myException = null; 239 } 240 241 MyLookup( 242 HttpServletRequest theServletRequest, 243 HttpServletResponse theResponse, 244 BaseServerResponseException theException, 245 RequestDetails theRequestDetails) { 246 myException = theException; 247 myRequestDetails = theRequestDetails; 248 myRequest = theServletRequest; 249 myResponse = theResponse; 250 } 251 252 @Override 253 public String lookup(String theKey) { 254 255 /* 256 * TODO: this method could be made more efficient through some sort of lookup map 257 */ 258 259 switch (theKey) { 260 case "operationType": 261 if (myRequestDetails.getRestOperationType() != null) { 262 return myRequestDetails.getRestOperationType().getCode(); 263 } 264 return ""; 265 case "operationName": 266 if (myRequestDetails.getRestOperationType() != null) { 267 //noinspection EnumSwitchStatementWhichMissesCases 268 switch (myRequestDetails.getRestOperationType()) { 269 case EXTENDED_OPERATION_INSTANCE: 270 case EXTENDED_OPERATION_SERVER: 271 case EXTENDED_OPERATION_TYPE: 272 return myRequestDetails.getOperation(); 273 default: 274 return ""; 275 } 276 } 277 return ""; 278 case "id": 279 if (myRequestDetails.getId() != null) { 280 return myRequestDetails.getId().getValue(); 281 } 282 return ""; 283 case "servletPath": 284 return StringUtils.defaultString(myRequest.getServletPath()); 285 case "idOrResourceName": 286 if (myRequestDetails.getId() != null) { 287 return myRequestDetails.getId().getValue(); 288 } 289 if (myRequestDetails.getResourceName() != null) { 290 return myRequestDetails.getResourceName(); 291 } 292 return ""; 293 case "requestParameters": 294 StringBuilder b = new StringBuilder(); 295 for (Entry<String, String[]> next : 296 myRequestDetails.getParameters().entrySet()) { 297 for (String nextValue : next.getValue()) { 298 if (b.length() == 0) { 299 b.append('?'); 300 } else { 301 b.append('&'); 302 } 303 b.append(UrlUtil.escapeUrlParam(next.getKey())); 304 b.append('='); 305 b.append(UrlUtil.escapeUrlParam(nextValue)); 306 } 307 } 308 return b.toString(); 309 case "remoteAddr": 310 return StringUtils.defaultString(myRequest.getRemoteAddr()); 311 case "responseEncodingNoDefault": { 312 ResponseEncoding encoding = RestfulServerUtils.determineResponseEncodingNoDefault( 313 myRequestDetails, myRequestDetails.getServer().getDefaultResponseEncoding()); 314 if (encoding != null) { 315 return encoding.getEncoding().name(); 316 } 317 return ""; 318 } 319 case "exceptionMessage": 320 return myException != null ? myException.getMessage() : null; 321 case "requestUrl": 322 return myRequest.getRequestURL().toString(); 323 case "requestVerb": 324 return myRequest.getMethod(); 325 case "requestBodyFhir": { 326 String contentType = myRequest.getContentType(); 327 if (isNotBlank(contentType)) { 328 int colonIndex = contentType.indexOf(';'); 329 if (colonIndex != -1) { 330 contentType = contentType.substring(0, colonIndex); 331 } 332 contentType = contentType.trim(); 333 334 EncodingEnum encoding = EncodingEnum.forContentType(contentType); 335 if (encoding != null) { 336 byte[] requestContents = myRequestDetails.loadRequestContents(); 337 return new String(requestContents, Constants.CHARSET_UTF8); 338 } 339 } 340 return ""; 341 } 342 case "processingTimeMillis": 343 Date startTime = (Date) myRequest.getAttribute(RestfulServer.REQUEST_START_TIME); 344 if (startTime != null) { 345 long time = System.currentTimeMillis() - startTime.getTime(); 346 return Long.toString(time); 347 } 348 break; 349 case "requestId": 350 return myRequestDetails.getRequestId(); 351 case "responseId": 352 String locationHeader = myResponse.getHeader(Constants.HEADER_LOCATION); 353 if (isNotBlank(locationHeader)) { 354 return new IdDt(locationHeader).toUnqualified().getValue(); 355 } 356 return ""; 357 default: 358 if (theKey.startsWith("requestHeader.")) { 359 String val = myRequest.getHeader(theKey.substring("requestHeader.".length())); 360 return StringUtils.defaultString(val); 361 } 362 } 363 364 return "!VAL!"; 365 } 366 } 367}