001package net.zileo.logback.logdna;
002
003import java.net.InetAddress;
004import java.net.UnknownHostException;
005import java.util.ArrayList;
006import java.util.Arrays;
007import java.util.HashMap;
008import java.util.List;
009import java.util.Map;
010import java.util.Map.Entry;
011import java.util.concurrent.TimeUnit;
012
013import javax.ws.rs.client.Client;
014import javax.ws.rs.client.ClientBuilder;
015import javax.ws.rs.client.Entity;
016import javax.ws.rs.client.WebTarget;
017import javax.ws.rs.core.MediaType;
018import javax.ws.rs.core.MultivaluedHashMap;
019import javax.ws.rs.core.MultivaluedMap;
020import javax.ws.rs.core.Response;
021
022import org.slf4j.Logger;
023import org.slf4j.LoggerFactory;
024
025import com.fasterxml.jackson.annotation.JsonInclude;
026import com.fasterxml.jackson.core.JsonProcessingException;
027import com.fasterxml.jackson.databind.DeserializationFeature;
028import com.fasterxml.jackson.databind.ObjectMapper;
029import com.fasterxml.jackson.databind.PropertyNamingStrategies;
030
031import ch.qos.logback.classic.encoder.PatternLayoutEncoder;
032import ch.qos.logback.classic.spi.ILoggingEvent;
033import ch.qos.logback.core.UnsynchronizedAppenderBase;
034
035/**
036 * Logback appender for sending logs to <a href="https://logdna.com">LogDNA.com</a>.
037 * 
038 * @author jlannoy
039 */
040public class LogDnaAppender extends UnsynchronizedAppenderBase<ILoggingEvent> {
041
042    private static final String CUSTOM_USER_AGENT = "LogDna Logback Appender";
043
044    private final Logger errorLog = LoggerFactory.getLogger(LogDnaAppender.class);
045
046    private final ObjectMapper dataMapper;
047
048    private final ObjectMapper responseMapper;
049
050    private Client client;
051
052    private boolean disabled;
053
054    protected final MultivaluedMap<String, Object> headers;
055
056    // Assignable fields
057
058    protected String hostname;
059
060    protected PatternLayoutEncoder encoder;
061
062    protected String appName;
063
064    protected String ingestUrl = "https://logs.logdna.com/logs/ingest";
065
066    protected List<String> mdcFields = new ArrayList<>();
067
068    protected List<String> mdcTypes = new ArrayList<>();
069
070    protected String tags;
071
072    protected long connectTimeout = 0;
073
074    protected long readTimeout = 0;
075
076    protected boolean useTimeDrift = true;
077
078    /**
079     * Appender initialization.
080     */
081    public LogDnaAppender() {
082        this.headers = new MultivaluedHashMap<>();
083        this.headers.add("User-Agent", CUSTOM_USER_AGENT);
084        this.headers.add("Accept", MediaType.APPLICATION_JSON);
085        this.headers.add("Content-Type", MediaType.APPLICATION_JSON);
086
087        this.dataMapper = new ObjectMapper();
088        this.dataMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
089        this.dataMapper.setPropertyNamingStrategy(PropertyNamingStrategies.UPPER_CAMEL_CASE);
090
091        this.responseMapper = new ObjectMapper();
092        this.responseMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
093    }
094
095    private String identifyHostname() {
096        try {
097            return InetAddress.getLocalHost().getHostName();
098        } catch (UnknownHostException e) {
099            return "localhost";
100        }
101    }
102
103    // Postpone client initialization to allow timeouts configuration
104    protected Client client() {
105        if (this.client == null) {
106
107            if (this.hostname == null) {
108                this.hostname = identifyHostname();
109            }
110
111            this.client = ClientBuilder.newBuilder() //
112                    .connectTimeout(this.connectTimeout, TimeUnit.MILLISECONDS) //
113                    .readTimeout(this.readTimeout, TimeUnit.MILLISECONDS) //
114                    .build();
115        }
116
117        return this.client;
118    }
119
120    /**
121     * @see ch.qos.logback.core.UnsynchronizedAppenderBase#append(java.lang.Object)
122     */
123    @Override
124    protected void append(ILoggingEvent event) {
125
126        if (disabled) {
127            return;
128        }
129
130        if (event.getLoggerName().equals(LogDnaAppender.class.getName())) {
131            return;
132        }
133
134        if (!this.headers.containsKey("apikey") || this.headers.getFirst("apikey").toString().trim().length() == 0) {
135            errorLog.warn("Empty ingest API key for LogDNA ; disabling LogDnaAppender");
136            this.disabled = true;
137            return;
138        }
139
140        try {
141            String jsonData = convertLogEventToJson(event);
142
143            Response response = callIngestApi(jsonData);
144
145            if (response.getStatus() != 200) {
146                LogDnaResponse logDnaResponse = convertResponseToObject(response);
147                errorLog.error("Error calling LogDna : {} ({})", logDnaResponse.getError(), response.getStatus());
148            }
149
150        } catch (JsonProcessingException e) {
151            errorLog.error("Error processing JSON data : {}", e.getMessage());
152
153        } catch (Exception e) {
154            errorLog.error("Error trying to call LogDna : {}", e.getMessage());
155        }
156
157    }
158
159    protected String convertLogEventToJson(ILoggingEvent event) throws JsonProcessingException {
160        return this.dataMapper.writeValueAsString(buildPostData(event));
161    }
162
163    protected LogDnaResponse convertResponseToObject(Response response) throws JsonProcessingException {
164        return this.responseMapper.readValue(response.readEntity(String.class), LogDnaResponse.class);
165    }
166
167    /**
168     * Call LogDna API posting given JSON formated string.
169     * 
170     * @param jsonData
171     *            a json oriented map
172     * @return the http response
173     */
174
175    protected Response callIngestApi(String jsonData) {
176        WebTarget wt = client().target(ingestUrl) //
177                .queryParam("hostname", this.hostname) //
178                .queryParam("tags", tags);
179
180        if (useTimeDrift) {
181            wt = wt.queryParam("now", System.currentTimeMillis());
182        }
183
184        return wt.request() //
185                .headers(headers) //
186                .post(Entity.json(jsonData));
187    }
188
189    /**
190     * Converts a logback logging event to a JSON oriented map.
191     * 
192     * @param event
193     *            the logging event
194     * @return a json oriented map
195     */
196    protected Map<String, Object> buildPostData(ILoggingEvent event) {
197        Map<String, Object> line = new HashMap<>();
198        line.put("timestamp", event.getTimeStamp());
199        line.put("level", event.getLevel().toString());
200        line.put("app", this.appName);
201        line.put("line", this.encoder != null ? new String(this.encoder.encode(event)) : event.getFormattedMessage());
202
203        Map<String, Object> meta = new HashMap<>();
204        meta.put("logger", event.getLoggerName());
205        if (!mdcFields.isEmpty() && !event.getMDCPropertyMap().isEmpty()) {
206            for (Entry<String, String> entry : event.getMDCPropertyMap().entrySet()) {
207                if (mdcFields.contains(entry.getKey())) {
208                    String type = mdcTypes.get(mdcFields.indexOf(entry.getKey()));
209                    meta.put(entry.getKey(), getMetaValue(type, entry.getValue()));
210                }
211            }
212        }
213        line.put("meta", meta);
214
215        Map<String, Object> lines = new HashMap<>();
216        lines.put("lines", Arrays.asList(line));
217        return lines;
218    }
219
220    private Object getMetaValue(String type, String value) {
221        try {
222            if ("int".equals(type)) {
223                return Integer.valueOf(value);
224            }
225            if ("long".equals(type)) {
226                return Long.valueOf(value);
227            }
228            if ("boolean".equals(type)) {
229                return Boolean.valueOf(value);
230            }
231        } catch (NumberFormatException e) {
232            errorLog.warn("Error getting meta value : {}", e.getMessage());
233        }
234        return value;
235
236    }
237
238    public void setEncoder(PatternLayoutEncoder encoder) {
239        this.encoder = encoder;
240    }
241
242    /**
243     * Sets the application name for LogDNA indexation.
244     * 
245     * @param appName
246     *            application name
247     */
248    public void setAppName(String appName) {
249        this.appName = appName;
250    }
251
252    /**
253     * Sets the LogDNA ingest API url.
254     * 
255     * @param ingestUrl
256     *            logdna url
257     */
258    public void setIngestUrl(String ingestUrl) {
259        this.ingestUrl = ingestUrl;
260    }
261
262    /**
263     * Sets your LogDNA ingest API key.
264     * 
265     * @param ingestKey
266     *            your ingest key
267     */
268    public void setIngestKey(String ingestKey) {
269        this.headers.add("apikey", ingestKey);
270    }
271
272    /**
273     * Sets the MDC fields that needs to be sent inside LogDNA metadata, separated by a comma.
274     * 
275     * @param mdcFields
276     *            MDC fields to use
277     */
278    public void setMdcFields(String mdcFields) {
279        this.mdcFields = Arrays.asList(mdcFields.split(","));
280    }
281
282    /**
283     * Sets the MDC fields types that will be sent inside LogDNA metadata, in the same order as <i>mdcFields</i> are set
284     * up, separated by a comma. Possible values are <i>string</i>, <i>boolean</i>, <i>int</i> and <i>long</i>. The last
285     * two will result as an indexed <i>number</i> in LogDNA's console.
286     * 
287     * @param mdcTypes
288     *            MDC fields types
289     */
290    public void setMdcTypes(String mdcTypes) {
291        this.mdcTypes = Arrays.asList(mdcTypes.split(","));
292    }
293
294    /**
295     * Sets the tags that needs to be sent to LogDNA, for grouping hosts for example.
296     * 
297     * @param tags
298     *            fixed tags
299     */
300    public void setTags(String tags) {
301        this.tags = tags;
302    }
303
304    /**
305     * Set whether using time drift. If set true, now parameter is supplied (https://docs.logdna.com/reference).
306     *
307     * @param useTimeDrift
308     *            true: Use time drift. false: Do not use time drift.
309     */
310    public void setUseTimeDrift(String useTimeDrift) {
311        this.useTimeDrift = !useTimeDrift.equalsIgnoreCase("false");
312    }
313
314    /**
315     * Force a given value for the hostname LogDNA parameter.
316     *
317     * @param hostname
318     *            local hostname value
319     */
320    public void setHostname(String hostname) {
321        this.hostname = hostname;
322    }
323
324    /**
325     * Sets the connection timeout of the underlying HTTP client, in milliseconds.
326     * 
327     * @param connectTimeout
328     *            client connection timeout
329     */
330    public void setConnectTimeout(Long connectTimeout) {
331        this.connectTimeout = connectTimeout;
332    }
333
334    /**
335     * Sets the read timeout of the underlying HTTP client, in milliseconds.
336     * 
337     * @param readTimeout
338     *            client read timeout
339     */
340    public void setReadTimeout(Long readTimeout) {
341        this.readTimeout = readTimeout;
342    }
343
344    public boolean isDisabled() {
345        return this.disabled;
346    }
347}