001/*
002 * jPOS Project [http://jpos.org]
003 * Copyright (C) 2000-2026 jPOS Software SRL
004 *
005 * This program is free software: you can redistribute it and/or modify
006 * it under the terms of the GNU Affero General Public License as
007 * published by the Free Software Foundation, either version 3 of the
008 * License, or (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
013 * GNU Affero General Public License for more details.
014 *
015 * You should have received a copy of the GNU Affero General Public License
016 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
017 */
018
019package org.jpos.util;
020
021import org.jdom2.Element;
022import org.jdom2.output.Format;
023import org.jdom2.output.XMLOutputter;
024import org.jpos.jfr.LogEventDump;
025import org.jpos.log.LogRenderer;
026import org.jpos.log.LogRendererRegistry;
027import org.jpos.log.evt.SysInfo;
028import org.jpos.log.render.txt.SysInfoTxtLogRenderer;
029
030import java.io.ByteArrayOutputStream;
031import java.io.IOException;
032import java.io.PrintStream;
033import java.sql.SQLException;
034import java.time.Duration;
035import java.time.Instant;
036import java.time.LocalDateTime;
037import java.time.ZoneId;
038import java.util.*;
039
040/**
041 * A single structured log event that carries a tag, realm, payload items, and optionally a {@link Throwable}.
042 * @author @apr
043 */
044public class LogEvent {
045    private LogSource source;
046    private String tag;
047    private final List<Object> payLoad;
048    private Instant createdAt;
049    private Instant dumpedAt;
050    private boolean honorSourceLogger;
051    private boolean noArmor;
052    private boolean hasException;
053    private Map<String,String> tags;
054
055    /**
056     * Constructs an empty event with the given tag.
057     *
058     * @param tag log tag (level/event name)
059     */
060    public LogEvent (String tag) {
061        super();
062        this.tag = tag;
063        createdAt = Instant.now();
064        this.payLoad = Collections.synchronizedList (new ArrayList<>());
065    }
066
067    /** Default constructor; uses the {@code info} tag. */
068    public LogEvent () {
069        this("info");
070    }
071    /**
072     * Constructs an event with the given tag and an initial payload entry.
073     *
074     * @param tag log tag (level/event name)
075     * @param msg initial payload entry
076     */
077    public LogEvent (String tag, Object msg) {
078        this (tag);
079        addMessage(msg);
080    }
081    /**
082     * Constructs an event tied to a {@link LogSource}.
083     *
084     * @param source source whose logger and realm govern this event
085     * @param tag log tag (level/event name)
086     */
087    public LogEvent (LogSource source, String tag) {
088        this (tag);
089        this.source  = source;
090        honorSourceLogger = true;
091    }
092    /**
093     * Constructs an event tied to a {@link LogSource} with an initial payload entry.
094     *
095     * @param source source whose logger and realm govern this event
096     * @param tag log tag (level/event name)
097     * @param msg initial payload entry
098     */
099    public LogEvent (LogSource source, String tag, Object msg) {
100        this (tag);
101        this.source  = source;
102        honorSourceLogger = true;
103        addMessage(msg);
104    }
105    /**
106     * Returns the log tag.
107     *
108     * @return the event tag
109     */
110    public String getTag() {
111        return tag;
112    }
113    /**
114     * Sets the log tag for this event.
115     * @param tag the log tag to set
116     */
117    public void setTag (String tag) {
118        this.tag = tag;
119    }
120    /**
121     * Adds a message or object to this event's payload.
122     * @param msg the message or object to add
123     */
124    public void addMessage (Object msg) {
125        payLoad.add (msg);
126        if (msg instanceof Throwable)
127            hasException = true;
128    }
129    /**
130     * Adds a message wrapped in an XML tag to this event's payload.
131     * @param tagname the XML tag name to wrap the message in
132     * @param message the message text
133     */
134    public void addMessage (String tagname, String message) {
135        payLoad.add ("<"+tagname+">"+message+"</"+tagname+">");
136    }
137    /**
138     * Returns the {@link LogSource} associated with this event, if any.
139     *
140     * @return the source, or {@code null} if not set
141     */
142    public LogSource getSource() {
143        return source;
144    }
145    /**
146     * Replaces the {@link LogSource} associated with this event.
147     *
148     * @param source new source (may be {@code null})
149     */
150    public void setSource(LogSource source) {
151        this.source = source;
152    }
153    /**
154     * Controls whether the XML wrapper is suppressed in log output.
155     * @param noArmor if true, suppress the XML wrapper
156     */
157    public void setNoArmor (boolean noArmor) {
158        this.noArmor = noArmor;
159    }
160    /**
161     * Writes the log event header to the given PrintStream.
162     * @param p the PrintStream to write the header to
163     * @param indent the indentation prefix
164     * @return the inner indentation string for nested content
165     */
166    protected String dumpHeader (PrintStream p, String indent) {
167        if (noArmor) {
168            p.println("");
169        } else {
170            dumpedAt = getDumpedAt();
171            StringBuilder sb = new StringBuilder(indent);
172            sb.append ("<log realm=\"");
173            sb.append (getRealm());
174            sb.append("\" at=\"");
175            sb.append(LocalDateTime.ofInstant(dumpedAt, ZoneId.systemDefault()));
176            sb.append ('"');
177            long elapsed = Duration.between(createdAt, dumpedAt).toMillis();
178            if (elapsed > 0) {
179                sb.append (" lifespan=\"");
180                sb.append (elapsed);
181                sb.append ("ms\"");
182            }
183            String traceId = tags != null ? tags.get("trace-id") : null;
184            if (traceId != null) {
185                sb.append (String.format (" trace-id=\"%s\"", traceId));
186            }
187            sb.append ('>');
188            p.println (sb);
189        }
190        return indent + "  ";
191    }
192    /**
193     * Writes the log event trailer to the given PrintStream.
194     * @param p the PrintStream to write the trailer to
195     * @param indent the indentation prefix
196     */
197    protected void dumpTrailer (PrintStream p, String indent) {
198        if (!noArmor)
199            p.println (indent + "</log>");
200    }
201    /**
202     * Dumps the full log event to the given PrintStream.
203     * @param p     the PrintStream to dump to
204     * @param outer the outer indentation string
205     */
206    public void dump (PrintStream p, String outer) {
207        var jfr = new LogEventDump();
208        jfr.begin();
209        try {
210            String indent = dumpHeader (p, outer);
211            if (payLoad.isEmpty()) {
212                if (tag != null)
213                    p.println (indent + "<" + tag + "/>");
214            }
215            else {
216                String newIndent;
217                if (tag != null) {
218                    if (!tag.isEmpty())
219                        p.println (indent + "<" + tag + ">");
220                    newIndent = indent + "  ";
221                }
222                else
223                    newIndent = "";
224                synchronized (payLoad) {
225                    for (Object o : payLoad) {
226                        if (o instanceof Loggeable)
227                            ((Loggeable) o).dump(p, newIndent);
228                        else if (o instanceof SQLException) {
229                            SQLException e = (SQLException) o;
230                            p.println(newIndent + "<SQLException>"
231                              + e.getMessage() + "</SQLException>");
232                            p.println(newIndent + "<SQLState>"
233                              + e.getSQLState() + "</SQLState>");
234                            p.println(newIndent + "<VendorError>"
235                              + e.getErrorCode() + "</VendorError>");
236                            ((Throwable) o).printStackTrace(p);
237                        } else if (o instanceof Throwable) {
238                            p.println(newIndent + "<exception name=\""
239                              + ((Throwable) o).getMessage() + "\">");
240                            p.print(newIndent);
241                            ((Throwable) o).printStackTrace(p);
242                            p.println(newIndent + "</exception>");
243                        } else if (o instanceof Object[]) {
244                            Object[] oa = (Object[]) o;
245                            p.print(newIndent + "[");
246                            for (int j = 0; j < oa.length; j++) {
247                                if (j > 0)
248                                    p.print(",");
249                                p.print(oa[j].toString());
250                            }
251                            p.println("]");
252                        } else if (o instanceof Element) {
253                            p.println("");
254                            XMLOutputter out = new XMLOutputter(Format.getPrettyFormat());
255                            out.getFormat().setLineSeparator("\n");
256                            try {
257                                out.output((Element) o, p);
258                            } catch (IOException ex) {
259                                ex.printStackTrace(p);
260                            }
261                            p.println("");
262                        } else if (o != null) {
263                            LogRenderer<Object> renderer = LogRendererRegistry.getRenderer(o.getClass(), LogRenderer.Type.TXT);
264                            if (renderer != null)
265                                renderer.render(o, p, newIndent);
266                            else
267                                p.println(newIndent + o);
268                        } else {
269                            p.println(newIndent + "null");
270                        }
271                    }
272                }
273                if (tag != null && !tag.isEmpty())
274                    p.println (indent + "</" + tag + ">");
275            }
276        } catch (Throwable t) {
277            t.printStackTrace(p);
278
279        } finally {
280            dumpTrailer (p, outer);
281            jfr.commit();
282        }
283    }
284    /**
285     * Returns the realm of the associated source, or empty when no source is set.
286     *
287     * @return the source's realm, or an empty string
288     */
289    public String getRealm() {
290        return source != null ? source.getRealm() : "";
291    }
292    /**
293     * Sets the {@code trace-id} tag explicitly.
294     *
295     * @param traceId trace identifier
296     * @return this event for chaining
297     */
298    public LogEvent withTraceId (String traceId) {
299        return withTag("trace-id", traceId);
300    }
301    /**
302     * Sets the {@code trace-id} tag from a UUID (dashes stripped).
303     *
304     * @param uuid trace UUID
305     * @return this event for chaining
306     */
307    public LogEvent withTraceId (UUID uuid) {
308        return withTag("trace-id", uuid.toString().replace("-", ""));
309    }
310    /**
311     * Adds or overwrites a tag on this event.
312     *
313     * @param key tag name
314     * @param value tag value
315     * @return this event for chaining
316     */
317    public LogEvent withTag(String key, String value) {
318        if (tags == null)
319            tags = new LinkedHashMap<>();
320        tags.put(key, value);
321        return this;
322    }
323    /**
324     * Adds the supplied tags to this event.
325     *
326     * @param map tags to add (ignored if {@code null} or empty)
327     * @return this event for chaining
328     */
329    public LogEvent withTags(Map<String,String> map) {
330        if (map != null && !map.isEmpty()) {
331            if (tags == null)
332                tags = new LinkedHashMap<>();
333            tags.putAll(map);
334        }
335        return this;
336    }
337    /**
338     * Returns an unmodifiable view of this event's tags.
339     *
340     * @return event tags, or an empty map if none have been set
341     */
342    public Map<String,String> getTags() {
343        return tags != null ? Collections.unmodifiableMap(tags) : Collections.emptyMap();
344    }
345    /**
346     * Sets the {@link LogSource} associated with this event and returns it for chaining.
347     *
348     * @param source new source
349     * @return this event for chaining
350     */
351    public LogEvent withSource (LogSource source) {
352        setSource(source);
353        return this;
354    }
355    /**
356     * Appends a payload entry and returns this event for chaining.
357     *
358     * @param o payload entry
359     * @return this event for chaining
360     */
361    public LogEvent add (Object o) {
362        addMessage(o);
363        return this;
364    }
365    /**
366     * Ensures a {@code trace-id} tag is present, generating one if needed.
367     *
368     * @return this event for chaining
369     */
370    public LogEvent withTraceId () {
371        getTraceId();
372        return this;
373    }
374    /**
375     * Returns the current trace-id, generating one if absent.
376     *
377     * @return the trace-id (never {@code null})
378     */
379    public String getTraceId() {
380        synchronized(getPayLoad()) {
381            String traceId = tags != null ? tags.get("trace-id") : null;
382            if (traceId == null) {
383                traceId = UUID.randomUUID().toString().replace("-","");
384                withTag("trace-id", traceId);
385            }
386            return traceId;
387        }
388    }
389
390    /**
391     * WARNING: payLoad is a SynchronizedList. If you intend to get a reference
392     * to it in order to iterate over the list, you need to synchronize on the
393     * returned object.
394     *
395     * <pre>
396     *     synchronized (evt.getPayLoad()) {
397     *        Iterator iter = evt.getPayLoad().iterator();
398     *        while (iter.hasNext()) {
399     *            ...
400     *            ...
401     *
402     *        }
403     *     }
404     * </pre>
405     * @return payLoad, which is a SynchronizedList
406     */
407    public List<Object> getPayLoad() {
408        return payLoad;
409    }
410    /**
411     * Renders this event to a string with the given indent prefix.
412     *
413     * @param indent indent prefix to apply to every emitted line
414     * @return string rendering of this event
415     */
416    public String toString(String indent) {
417        ByteArrayOutputStream baos = new ByteArrayOutputStream();
418        PrintStream p = new PrintStream (baos);
419        synchronized (getPayLoad()) {
420            dump(p, indent);
421        }
422        return baos.toString();
423    }
424    /**
425     * Renders this event to a string with no leading indent.
426     *
427     * @return string rendering of this event
428     */
429    public String toString() {
430        return toString("");
431    }
432
433    /**
434     * Indicates whether the payload contains a {@link Throwable}.
435     *
436     * @return {@code true} if any payload entry is a {@link Throwable}
437     */
438    public boolean hasException() {
439        return hasException;
440    }
441    /**
442     * This is a hack for backward compatibility after accepting PR67
443     * @see <a href="https://github.com/jpos/jPOS/pull/67">PR67</a>
444     * @return true if ISOSource has been set
445     */
446    public boolean isHonorSourceLogger() {
447        return honorSourceLogger;
448    }
449
450    /**
451     * Returns the time at which this event was first dumped, capturing it on first call.
452     *
453     * @return the dump timestamp
454     */
455    public synchronized Instant getDumpedAt() {
456        if (dumpedAt == null)
457            dumpedAt = Instant.now();
458        return dumpedAt;
459    }
460    /**
461     * Returns the time at which this event was constructed.
462     *
463     * @return the creation timestamp
464     */
465    public Instant getCreatedAt() {
466        return createdAt;
467    }
468}