Programming language: Java
License: GNU General Public License v3.0 or later
Tags: Logging     Projects    

Echopraxia alternatives and similar libraries

Based on the "Logging" category.
Alternatively, view echopraxia alternatives based on common mentions on social networks and blogs.

Do you think we are missing an alternative of Echopraxia or a related project?

Add another 'Logging' Library



Echopraxia is a Java logging API designed around structured logging, rich context, and conditional logging. There are Logback and Log4J2 implementations, but Echopraxia's API is completely dependency-free, meaning it can be implemented with any logging API, i.e. jboss-logging, JUL, JEP 264, or even directly.

Echopraxia is a sequel to the Scala logging API Blindsight, hence the name: "Echopraxia is the involuntary repetition or imitation of an observed action."

Echopraxia is based around several main concepts that build and leverage on each other:

  • Structured Logging (API based around structured fields and values)
  • Contextual Logging (API based around building state in loggers)
  • Conditions (API based around context-aware functions and dynamic scripting)
  • Semantic Logging (API based around typed arguments)
  • Fluent Logging (API based around log entry builder)

For a worked example, see this Spring Boot Project.

Although Echopraxia is tied on the backend to an implementation, it is designed to hide implementation details from you, just as SLF4J hides the details of the logging implementation. For example, logstash-logback-encoder provides Markers or StructuredArguments, but you will not see them in the API. Instead, Echopraxia works with independent Field and Value objects that are converted by a CoreLogger provided by an implementation.

Please see the blog posts for more background on logging stuff.

Statement of Intent

Echopraxia is not a replacement for SLF4J. It is not an attempt to compete with Log4J2 API, JUL, commons-logging for the title of "one true logging API" and restart the logging mess. SLF4J won that fight a long time ago.

Echopraxia is a structured logging API. It is an appropriate solution when you control the logging implementation and have decided you're going to do structured logging, e.g. a web application where you've decided to use logstash-logback-encoder already.

SLF4J is an appropriate solution when you do not control the logging output, e.g. in an open-source library that could be used in arbitrary situations by anybody.

Echopraxia is best described as a specialization or augmentation for application code -- as you're building framework support code for your application and build up your domain objects, you can write custom field builders, then log everywhere in your application with a consistent schema.


Benchmarks show [performance inline with straight calls to the implementation](BENCHMARKS.md).

Please be aware that how fast and how much you can log is dramatically impacted by your use of an asynchronous appender, your available I/O, your storage, and your ability to manage and process logs.

Logging can be categorized as either diagnostic (DEBUG/TRACE) or operational (INFO/WARN/ERROR).

If you are doing significant diagnostic logging, consider using an appender optimized for fast local logging, such as Blacklite, and consider writing to tmpfs.

If you are doing significant operational logging, you should commit to a budget for operational costs i.e. storage, indexing, centralized logging infrastructure. It is very likely that you will run up against budget constraints long before you ever need to optimize your logging for greater throughput.


There is a Logback implementation based around logstash-logback-encoder implementation of event specific custom fields.




implementation "com.tersesystems.echopraxia:logstash:1.2.0" 


There is a Log4J implementation that works with the JSON Template Layout.




implementation "com.tersesystems.echopraxia:log4j:1.2.0" 

You will need to integrate the com.tersesystems.echopraxia.log4j.layout package into your log4j2.xml file, e.g. by using the packages attribute, and add an EventTemplateAdditionalField element:

<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN" packages="com.tersesystems.echopraxia.log4j.layout">
        <Console name="Console" target="SYSTEM_OUT" follow="true">
            <JsonTemplateLayout eventTemplateUri="classpath:LogstashJsonEventLayoutV1.json">
                        value='{"$resolver": "echopraxiaFields"}'/>
        <Root level="info">
            <AppenderRef ref="Console" />

If you want to separate the context fields from the argument fields, you can define them separately:

<JsonTemplateLayout eventTemplateUri="classpath:LogstashJsonEventLayoutV1.json">
            value='{"$resolver": "echopraxiaArgumentFields"}'/>
            value='{"$resolver": "echopraxiaContextFields"}'/>

Unfortunately, I don't know of a way to "flatten" fields so that they show up on the root object instead of under an additional field. If you know how to do this, let me know!

Basic Usage

For almost all use cases, you will be working with the API which is a single import:

import com.tersesystems.echopraxia.*;

First you define a logger (usually in a controller or singleton -- getClass() is particularly useful for abstract controllers):

final Logger<?> basicLogger = LoggerFactory.getLogger(getClass());

Logging simple messages and exceptions are done as in SLF4J:

try {
  basicLogger.info("Simple message");
} catch (Exception e) {
  basicLogger.error("Error message", e);  

However, when you log arguments, you pass a function which provides you with a field builder and returns a list of fields:

basicLogger.info("Message name {} age {}", fb -> fb.list(
  fb.string("name", "value"),
  fb.number("age", 13)

You can specify a single field using only:

basicLogger.info("Message name {}", fb -> fb.only(fb.string("name", "value")));

And there are some shortcut methods like onlyString that combine only and string:

basicLogger.info("Message name {}", fb -> fb.onlyString("name", "value"));

You can log multiple arguments and include the exception if you want the stack trace:

basicLogger.info("Message name {}", fb -> fb.list(
  fb.string("name", "value"),

So far so good. But logging strings and numbers can get tedious. Let's go into custom field builders.

Custom Field Builders

Echopraxia lets you specify custom field builders whenever you want to log domain objects:

public class BuilderWithDate implements Field.Builder {
  public BuilderWithDate() {}

  // Renders a date as an ISO 8601 string.
  public StringValue dateValue(Date date) {
    return Value.string(DateTimeFormatter.ISO_INSTANT.format(date.toInstant()));

  public Field date(String name, Date date) {
    return string(name, dateValue(date));

  // Renders a date using the `only` idiom returning a list of `Field`.
  // This is a useful shortcut when you only have one field you want to add.
  public List<Field> onlyDate(String name, Date date) {
    return only(date(name, date));

And now you can render a date automatically:

Logger<BuilderWithDate> dateLogger = basicLogger.withFieldBuilder(BuilderWithDate.class);
dateLogger.info("Date {}", fb -> fb.onlyDate("creation_date", new Date()));

This also applies to more complex objects:

public class PersonBuilder implements Field.Builder {

  // Renders a `Person` as an object field.
  public Field person(String fieldName, Person p) {
    return keyValue(fieldName, personValue(p));

  public Value<?> personValue(Person p) {
    if (p == null) {
      return Value.nullValue();
    Field name = string("name", p.name());
    Field age = number("age", p.age());
    // optional returns either an object value or null value, keyValue is untyped
    Field father = keyValue("father", Value.optional(p.getFather().map(this::personValue)));
    Field mother = keyValue("mother", Value.optional(p.getMother().map(this::personValue)));
    Field interests = array("interests", p.interests());
    return Value.object(name, age, father, mother, interests);

And then you can do the same by calling fb.person:

Person user = ...
Logger<PersonBuilder> personLogger = basicLogger.withFieldBuilder(PersonBuilder.class);
personLogger.info("Person {}", fb -> fb.only(fb.person("user", user)));

If you are using a particular set of field builders for your domain and want them available by default, the Logger class is designed to be easy to subclass.

public class MyLogger extends Logger<PersonBuilder> {
  protected MyLogger(CoreLogger core, PersonBuilder fieldBuilder) {
    super(core, fieldBuilder);

public class MyLoggerFactory {
  private static final String FQCN = Logger.class.getName(); // used for caller info
  private static final PersonBuilder PERSON_BUILDER_SINGLETON = new PersonBuilder();

  public static MyLogger getLogger() {
    CoreLogger core = CoreLoggerFactory.getLogger(FQCN, Caller.resolveClassName());
    return new MyLogger(core, PERSON_BUILDER_SINGLETON);

MyLogger myLogger = MyLoggerFactory.getLogger();

Subclassing the logger will also remove the type parameter from your code, so you don't have to type Logger<?> everywhere.

Nulls and Exceptions

By default, values are @NotNull, and passing in null to values is not recommended. If you want to handle nulls, you can extend the field builder as necessary:

public interface NullableFieldBuilder extends Field.Builder {
  // extend as necessary
  default Field nullableString(String name, String nullableString) {
    Value<?> nullableValue = (value == null) ? Value.nullValue() : Value.string(nullableString);
    return keyValue(name, nullableValue);

Field names are never allowed to be null. If a field name is null, it will be replaced at runtime with unknown-echopraxia-N where N is an incrementing number.

logger.info("Message name {}", fb -> 
  fb.only(fb.string(null, "some-value")) // null field names not allowed

In addition, fb.only() will return an empty list if a null field is passed in:

logger.info("Message name {}", fb -> 
  Field field = null;
  return fb.only(field); // returns an empty list of fields.

Because a field builder function runs in a closure, if an exception occurs it will be caught by the default thread exception handler. If it's the main thread, it will print to console and terminate the JVM, but other threads will swallow the exception whole. Consider setting a default thread exception handler that additionally logs, and avoid uncaught exceptions in field builder closures:

logger.info("Message name {}", fb -> {
  String name = methodThatThrowsException(); // BAD
  return fb.only(fb.string(name, "some-value"));

Instead, only call field builder methods inside the closure and keep any construction logic outside:

String name = methodThatThrowsException(); // GOOD
logger.info("Message name {}", fb -> {
  return fb.only(fb.string(name, "some-value"));


You can also add fields directly to the logger using logger.withFields for contextual logging:

Logger<?> loggerWithFoo = basicLogger.withFields(fb -> fb.onlyString("foo", "bar"));
loggerWithFoo.info("JSON field will log automatically") // will log "foo": "bar" field in a JSON appender.

This works very well for HTTP session and request data such as correlation ids.

One thing to be aware of that the popular idiom of using public static final Logger<?> logger can be limiting in cases where you want to include context data. For example, if you have a number of objects with their own internal state, it may be more appropriate to create a logger field on the object.

public class PlayerData {

  // the date is scoped to an instance of this player
  private Date lastAccessedDate = new Date();

  // logger is not static because lastAccessedDate is an instance variable
  private final Logger<BuilderWithDate> logger =
          .withFields(fb -> fb.onlyDate("last_accessed_date", lastAccessedDate));


Thread Context

You can also resolve any fields in Mapped Diagnostic Context (MDC) into fields, using logger.withThreadContext(). This method provides a pre-built function that calls fb.string for each entry in the map.

Because MDC is thread local, if you pass the logger between threads or use asynchronous processing i.e. CompletionStage/CompletableFuture, you may have inconsistent results.

org.slf4j.MDC.put("mdckey", "mdcvalue");
myLogger.withThreadContext().info("This statement has MDC values in context");

Thread Safety

Thread safety is something to be aware of when using context fields. While fields are thread-safe and using a context is far more convenient than using MDC, you do still have to be aware when you are accessing non-thread safe state.

For example, SimpleDateFormat is infamously not thread-safe, and so the following code is not safe to use in a multi-threaded context:

private final static DateFormat df = new SimpleDateFormat("yyyyMMdd");

private static final Logger<?> logger =
        .withFields(fb -> fb.onlyString("unsafe_date", df.format(new Date())));


Logging conditions can be handled gracefully using Condition functions. A Condition will take a Level and a LoggingContext which will return the fields of the logger.

final Condition mustHaveFoo = (level, context) ->
        context.getFields().stream().anyMatch(field -> field.name().equals("foo"));

Conditions can be used either on the logger, on the statement, or against the predicate check.

There are two specialized conditions, Condition.always() and Condition.never(). Echopraxia has optimizations for conditions; it will treat Condition.always() as a no-op, and return a NeverLogger that has no operations for logging. The JVM can recognize that logging has no effect at all, and will eliminate the method call as dead code.

NOTE: Conditions are a great way to manage diagnostic logging in your application with more flexibility than global log levels can provide.

Consider enabling setting your application logging to DEBUG i.e. <logger name="your.application.package" level="DEBUG"/> and using conditions to turn on and off debugging as needed.


You can use conditions in a logger, and statements will only log if the condition is met:

Logger<?> loggerWithCondition = logger.withCondition(condition);

You can also build up conditions:

Logger<?> loggerWithAandB = logger.withCondition(conditionA).withCondition(conditionB);


You can use conditions in an individual statement:

logger.info(mustHaveFoo, "Only log if foo is present");


Conditions can also be used in predicate blocks for expensive objects.

if (logger.isInfoEnabled(condition)) {
  // only true if condition and is info  

Asynchronous Logging for Expensive Conditions

By default, conditions are evaluated in the running thread. This can be a problem if conditions rely on external elements such as network calls or database lookups, or involve resources with locks.

Echopraxia provides an AsyncLogger that will evaluate conditions and log using another executor, so that the main business logic thread is not blocked on execution. An AsyncLogger is created when calling the withExecutor method. All statements are placed on a work queue and run on a thread specified by the executor at a later time.

All the usual logging statements are available in AsyncLogger, i.e. logger.debug will log as usual.

However, there are no predicates in the AsyncLogger -- instead, a Consumer of LoggerHandle is used, which serves the same purpose as the if (isLogging*()) { .. } block.

AsyncLogger<?> logger = LoggerFactory.getLogger().withExecutor(loggingExecutor);
logger.info(handle -> {
  // do conditional logic that would normally happen in an if block
  // this may be expensive or blocking because it runs asynchronously
  handle.log("Message template {}", fb -> fb.onlyString("foo", "bar");

In the unfortunate event of an exception, the underlying (SLF4J|Log4J) logger will be called at error level from the relevant core logger:

// only happens if uncaught exception from consumer
logger.error("Uncaught exception when running asyncLog", cause);

One important detail is that the logging executor should be a daemon thread, so that it does not block JVM exit:

private static final Executor loggingExecutor =
      r -> {
        Thread t = new Thread(r);
        t.setDaemon(true); // daemon means the thread won't stop JVM from exiting
        return t;

Using a single thread executor is nice because you can keep ordering of your logging statements, but it may not scale up in production. Generally speaking, if you are CPU bound and want to distribute load over several cores, you should use ForkJoinPool.commonPool() or a bounded fork-join work stealing pool as your executor. If your conditions involve blocking, or work is IO bound, you should configure a thread pool executor.

Likewise, if your conditions involve calls to external services (for example, calling Consul or a remote HTTP service), you should consider using a failure handling library like failsafe to set up appropriate circuit breakers, bulkheads, timeouts, and rate limiters to manage interactions.

Because of parallelism and concurrency, your logging statements may not appear in order, but you can add extra fields to ensure you can reorder statements appropriately.

Putting it all together:

public class Async {
  private static final ExecutorService loggingExecutor =
          r -> {
            Thread t = new Thread(r);
            t.setDaemon(true); // daemon means the thread won't stop JVM from exiting
            return t;

  private static final Condition expensiveCondition = new Condition() {
    public boolean test(Level level, LoggingContext context) {
      try {
        return true;
      } catch (InterruptedException e) {
        return false;

  private static final AsyncLogger<?> logger = LoggerFactory.getLogger()

  public static void main(String[] args) throws InterruptedException {
    System.out.println("BEFORE logging block");
    for (int i = 0; i < 10; i++) {
      // This should take no time on the rendering thread :-)
      logger.info("Prints out after expensive condition");
    System.out.println("AFTER logging block");
    System.out.println("Sleeping so that the JVM stays up");
    Thread.sleep(1001L * 10L);

Note that because logging is asynchronous, you must be very careful when accessing thread local state. Thread local state associated with logging, i.e. MDC / ThreadContext is automatically carried through, but in some cases you may need to do additional work.

For example, if you are using Spring Boot and are using RequestContextHolder.getRequestAttributes() when constructing context fields, you must call RequestContextHolder.setRequestAttributes(requestAttributes) so that the attributes are available to the thread:

public class GreetingController {
  private static final String template = "Hello, %s!";
  private final AtomicLong counter = new AtomicLong();

  private static final AsyncLogger<HttpRequestFieldBuilder> logger =
        fb -> {
          // Any fields that you set in context you can set conditions on later,
          // i.e. on the URI path, content type, or extra headers.
          HttpServletRequest request =
            ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes())
          return fb.requestFields(request);

  public Greeting greeting(@RequestParam(value = "name", defaultValue = "World") String name) {
    logger.info(wrap(h -> h.log("This logs asynchronously with HTTP request fields")));
    return new Greeting(counter.incrementAndGet(), String.format(template, name));

  private Consumer<LoggerHandle<HttpRequestFieldBuilder>> wrap(Consumer<LoggerHandle<HttpRequestFieldBuilder>> c) {
    // Because this takes place in the fork-join common pool, we need to set request
    // attributes in the thread before logging so we can get request fields.
    final RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();
    return h -> {
      try {
      } finally {

There are other ways to handle this wrapping, for example subclassing the AsyncLogger and overriding the logging methods. There are also fancy executor-based ways to extend context, but that's a different topic.

Dynamic Conditions with Scripts

One of the limitations of logging is that it's not that easy to change logging levels in an application at run-time. In modern applications, you typically have complex inputs and may want to enable logging for some very specific inputs without turning on your logging globally.

Script Conditions lets you tie your conditions to scripts that you can change and re-evaluate at runtime.

The security concerns surrounding Groovy or Javascript make them unsuitable in a logging environment. Fortunately, Echopraxia provides a Tweakflow script integration that lets you evaluate logging statements safely. Tweakflow comes with a VS Code integration, a reference guide, and a standard library that contains useful regular expression and date manipulation logic.

Because Scripting has a dependency on Tweakflow, it is broken out into a distinct library that you must add to your build.




implementation "com.tersesystems.echopraxia:scripting:1.2.0" 

String Based Scripts

You also have the option of passing in a string directly:

StringBuilder b = new StringBuilder("");
b.append("library echopraxia {");
b.append("  function evaluate: (string level, dict fields) ->");
b.append("    level == \"INFO\";");
String scriptString = b.toString();  
Condition c = ScriptCondition.create(false, scriptString, Throwable::printStackTrace);

File Based Scripts

Creating a script condition is done with ScriptCondition.create:

import com.tersesystems.echopraxia.scripting.*;

Path path = Paths.get("src/test/tweakflow/condition.tf");
Condition condition = ScriptCondition.create(false, path, Throwable::printStackTrace);

Logger<?> logger = LoggerFactory.getLogger(getClass()).withCondition(condition);

Where condition.tf contains a tweakflow script, e.g.

import * as std from "std";
alias std.strings as str;

library echopraxia {
  # level: the logging level
  # fields: the dictionary of fields
  function evaluate: (string level, dict fields) ->
    str.lower_case(fields[:person][:name]) == "will";   

Watched Scripts

You can also change file based scripts while the application is running, if they are in a directory watched by ScriptWatchService.

To configure ScriptWatchService, pass it the directory that contains your script files:

final Path watchedDir = Paths.get("/your/script/directory");
ScriptWatchService watchService = new ScriptWatchService(watchedDir);

Path filePath = watchedDir.resolve("myscript.tf");

Logger logger = LoggerFactory.getLogger();

final ScriptHandle watchedHandle = watchService.watchScript(filePath, 
        e -> logger.error("Script compilation error", e));
final Condition condition = ScriptCondition.create(watchedHandle);

logger.info(condition, "Statement only logs if condition is met!")

// After that, you can edit myscript.tf and the condition will 
// re-evaluate the script as needed automatically!

// You can delete the file, but doing so will log a warning from `ScriptWatchService`
// Recreating a deleted file will trigger an evaluation, same as modification.

// Note that the watch service creates a daemon thread to watch the directory.
// To free up the thread and stop watching, you should call close() as appropriate:

Semantic Logging

Semantic Loggers are strongly typed, and will only log a particular kind of argument. All the work of field building and setting up a message is done from setup.

Basic Usage

To set up a logger for a Person with name and age properties, you would do the following:

import com.tersesystems.echopraxia.semantic.*;

SemanticLogger<Person> logger =
        person -> "person.name = {}, person.age = {}",
        p -> fb -> fb.list(fb.string("name", p.name), fb.number("age", p.age)));

Person person = new Person("Eloise", 1);


Semantic loggers take conditions in the same way that other loggers do, either through predicate:

if (logger.isInfoEnabled(condition)) {

or directly on the method:

logger.info(condition, person);

or on the logger:



Semantic loggers can add fields to context in the same way other loggers do.

SemanticLogger<Person> loggerWithContext =
  logger.withFields(fb -> fb.onlyString("some_context_field", contextValue));


Semantic Loggers have a dependency on the api module, but do not have any implementation dependencies.




implementation "com.tersesystems.echopraxia:semantic:1.2.0" 

Fluent Logging

Fluent logging is done using a FluentLoggerFactory.

It is useful in situations where arguments may need to be built up over time.

import com.tersesystems.echopraxia.fluent.*;

FluentLogger<?> logger = FluentLoggerFactory.getLogger(getClass());

Person person = new Person("Eloise", 1);

    .message("name = {}, age = {}")
    .argument(sfb -> sfb.string("name", person.name)) // note only a single field
    .argument(sfb -> sfb.number("age", person.age))


Fluent Loggers have a dependency on the api module, but do not have any implementation dependencies.




implementation "com.tersesystems.echopraxia:fluent:1.2.0" 

Core Logger

Because Echopraxia provides its own implementation independent API, some implementation features are not exposed normally. If you want to use implementation specific features like markers, you will need to use a core logger.

Logstash API

First, import the logstash package and the core package. This gets you access to the CoreLoggerFactory and CoreLogger, which can be cast to LogstashCoreLogger:

import com.tersesystems.echopraxia.logstash.*;
import com.tersesystems.echopraxia.core.*;

LogstashCoreLogger core = (LogstashCoreLogger) CoreLoggerFactory.getLogger();

The LogstashCoreLogger has a withMarkers method that takes an SLF4J marker:

Logger<?> logger = LoggerFactory.getLogger(
      core.withMarkers(MarkerFactory.getMarker("SECURITY")), Field.Builder.instance);

If you have markers set as context, you can evaluate them in a condition through casting to LogstashLoggingContext:

Condition hasAnyMarkers = (level, context) -> {
   LogstashLoggingContext c = (LogstashLoggingContext) context;
   List<org.slf4j.Marker> markers = c.getMarkers();
   return markers.size() > 0;

If you need to get at the SLF4J logger from a core logger, you can cast and call core.logger():

Logger<?> baseLogger = LoggerFactory.getLogger();
LogstashCoreLogger core = (LogstashCoreLogger) baseLogger.core();
org.slf4j.Logger slf4jLogger = core.logger();


Similar to Logstash, you can get access to Log4J specific features by importing

import com.tersesystems.echopraxia.log4j.*;
import com.tersesystems.echopraxia.core.*;

Log4JCoreLogger core = (Log4JCoreLogger) CoreLoggerFactory.getLogger();

The Log4JCoreLogger has a withMarker method that takes a Log4J marker:

final Marker securityMarker = MarkerManager.getMarker("SECURITY");
Logger<?> logger = LoggerFactory.getLogger(
      core.withMarker(securityMarker), Field.Builder.instance);

If you have a marker set as context, you can evaluate it in a condition through casting to Log4JLoggingContext:

Condition hasAnyMarkers = (level, context) -> {
   Log4JLoggingContext c = (Log4JLoggingContext) context;
   Marker m = c.getMarker();
   return securityMarker.equals(m);

If you need to get the Log4j logger from a core logger, you can cast and call core.logger():

Logger<?> baseLogger = LoggerFactory.getLogger();
Log4JCoreLogger core = (Log4JCoreLogger) baseLogger.core();
org.apache.logging.log4j.Logger log4jLogger = core.logger();