In a new service that I am working on, we are using MongoDB. Since I am much more familiar with PostgreSQL, I found myself missing some information in the observability spans we were getting from MongoDB.

We are using MongoDB with Spring Data, and by adding a command listener to the MongoDB configuration, we were able to capture spans for database operations. Good observability is equally critical regardless of which database you use. Whether you’re troubleshooting PostgreSQL performance issues or optimizing MongoDB queries, the same principles apply. We could tell whether an operation was an insert, update, or delete, and which entity it was related to.

The problem was that the spans did not include enough detail. For example, when an query operation was slow, it was hard to tell which method had triggered it. The span also did not show the query being executed. So while we had enough observability to measure duration, we still could not pinpoint which method was taking a long time or which field the query was filtering on. This is exactly the kind of problem that good observability solves — being able to see query structure in your spans lets you identify bottlenecks without relying on slow, reactive debugging.

To improve that, we created a custom observation convention and registered it in the MongoDB configuration. These were the two important pieces.

1. Registering the custom observation listener

The first step was wiring the custom convention into the MongoDB client configuration:

.addCommandListener(new MongoObservationCommandListener(
       observationRegistry, null, new SanitizingMongoObservationConvention()))

This is the key part. Once this listener is added, Spring Data MongoDB starts producing spans enriched by our custom convention.

2. Adding sanitized query details to the span

The convention adds a db.statement attribute with the query shape, while hiding the actual values:

class SanitizingMongoObservationConvention implements MongoHandlerObservationConvention {

   private static final String DB_STATEMENT_KEY = "db.statement";

   @Override
   public @NotNull KeyValues getHighCardinalityKeyValues(@NotNull MongoHandlerContext context) {
       try {
           return KeyValues.of(
                   DB_STATEMENT_KEY,
                   buildStatement(context.getCommandStartedEvent().getCommand()));
       } catch (RuntimeException e) {
           return KeyValues.of(DB_STATEMENT_KEY, "?");
       }
   }

   @Override
   public String getContextualName(MongoHandlerContext context) {
       var collectionName = context.getCollectionName();
       var commandName = context.getCommandStartedEvent().getCommandName();

       if (ObjectUtils.isEmpty(collectionName)) {
           return commandName;
       }
       return collectionName + "." + commandName;
   }
}

Two details made the biggest difference for us:

  1. db.statement now contains useful query information for tracing.
  2. getContextualName() makes spans easier to read by naming them as collection.operation.

The sanitization happens when building the statement:

private static String buildStatement(BsonDocument cmd) {
   var result = new BsonDocument();

   appendSanitizedFilter(result, "filter", cmd.get("filter"));

   var updates = cmd.getArray("updates", null);
   if (updates != null && !updates.isEmpty() && updates.getFirst() instanceof BsonDocument firstUpdate) {
       appendSanitizedFilter(result, "q", firstUpdate.get("q"));
   }

   appendIfPresent(result, "sort", cmd.get("sort"));
   appendIfPresent(result, "limit", cmd.get("limit"));
   appendIfPresent(result, "skip", cmd.get("skip"));
   appendIfPresent(result, "$db", cmd.get("$db"));

   return result.toJson();
}

private static BsonDocument sanitizeFilterValues(BsonDocument doc) {
   var result = new BsonDocument();
   for (var entry : doc.entrySet()) {
       var value = entry.getValue();
       result.put(
               entry.getKey(),
               value instanceof BsonDocument nested ? sanitizeFilterValues(nested) : new BsonString("?"));
   }
   return result;
}

That gave us spans with enough information to understand the query structure without leaking sensitive data. A statement like this:

{"filter":{"stockId":"123","syncType":"FULL"}}

becomes this:

{"filter":{"stockId":"?","syncType":"?"}}

One thing I did not want to expose in the span was the field values themselves, since they could contain sensitive information. The approach in the example sanitizes the query and replaces the values with ?. That means we can still see which fields are being queried without exposing the actual values in our tracing system.

With this additional information, it becomes much easier to understand which queries are slow and investigate why.

If you’re running on Spring Boot 3, you’re already set up with Micrometer observability, which provides the foundation for this kind of span enrichment. The same principles apply whether you’re instrumenting MongoDB, PostgreSQL, or any other database. Good spans with enough context make performance troubleshooting straightforward. I learned this lesson while debugging Spring Boot migrations, where observability was the key to catching performance regressions early.

I hope this helps you improve the observability of your system.