class Permissions def get(user) logger.tagged("Auth API") do uri = URI("https://auth.example.com/permissions/?user=#{user}") logger.info { "Fetching #{uri}" } permissions = Permissions.parse(Net::HTTP.get(uri)) logger.info { "Got permissions #{permissions.to_s}" } end endend# Log Output#[Auth API] Fetching https://auth.example.com/permissions/?user=bob#[Auth API] Got permissions admin, writer, reader
An example from Justin Weiss shows us how we might do this for users:
logger.tagged(current_user_id ? "user-#{current_user_id}" : "user-anonymous") do logger.debug "Finding people..." @people = Person.all logger.debug "Found #{@people.length} people!"end# Log Output#[user-123] Finding people...#[user-123] Person Load (0.3ms) SELECT "people".* FROM "people"#[user-123] Found 0 people!
What's Good About Tagged Logging
Good Readability / Grep-ability
It's nice that the log lines for multiple parts of a request have the same tag. That's going to be helpful with ctrl-f locally or grep in production.
It's DRY (Don't Repeat Yourself)
We put the user.id in one place, and it is now in all the log output. We could even put that in an around_action and
have it in all logging for our request.
What's Not as Good
Poor Machine Readability
logger.tagged "user-#{user.id}" do logger.tagged team.name do logger.info "hello" endend# The plain text version of the log reads nicely#INFO [user-1] [The Best Team] hello# But the JSON Log isn't ideal#{severity: INFO, message: "hello", tags: ["user-123", "The Best Team"]}
Almost every log aggregator will support JSON logging and user-123 won't be as easy to search as {user: 123}. With the structured format you're going to be able to display and search the column user for 123. Additionally, the team name is just floating there without a key. This post Structured logging in Rails lays out the problem well. Does your logging aggregator want:
🤔 But are these things you're tagging really "tags" or should they be new keypairs? In a practical example, the code is more likely something like this:
Rails.logger.tagged("BillingJob") do Rails.logger.tagged("User #{user.id}").info("nested tagged log message")end
and wouldn't that be better JSON as:
{ "class": "BillingJob", "user": 1234}
vs
{ "tags": ["BillingJob", "User 1234"] }
If you're nodding along, at this point, you're probably wondering whether maybe we should just write:
If so, congratulations, you've just invented structured logging!
What's Structured Logging?
As you can see, structured logging is pretty straightforward. It lets us do something like this:
log.info "hello", user: "1", team: "The best team"# Text Log# INFO hello user=1, team=The Best Team# JSON Log# {severity: INFO, message: "hello", user: 1, team: "The Best Team"}
Hey now, that really is better.
So, what libraries do this? Quite a few, actually. Here's what they look like:
Semantic Logger has about 12M downloads, and there is also a rails_semantic_logger gem with Rails-specific integrations.
What About Lograge?
Lograge is sort of a structured logger. It does output structured logs for your Rails requests, but it doesn't give you a way to structure your own logs. It's 101M downloads and a proven tool, but I'm not including it here because we're talking about structured logging and tagged logging in general.
So, is Tagged Logger worse than Structured Logging?
Well, yes. Mostly. Honestly I didn't have an opinion before I really dug in and researched this, but in my view structured logging can give us the same benefits as tagged logging, but in a cleaner, more machine-readable way that fits better with modern log aggregators.
🤔 However there is one thing I found valuable with tagged logging and that is nested contexts. It's a convenient way to propagate context instead of adding it to each log entry. Does using structured logging mean we have to give that up?
No! Fortunately, we can do this in SemanticLogger as well. It looks like this:
logger.tagged {user: 1} do logger.info "nested"end# Log Output# nested user=1
Could it be better?
My last gripe would be that I'm repeating this context. Isn't this the same context I'm using for my feature flags library, datadog, and sentry?
Why do I need to repeat this again for logging? Can't I just do it once?
The Ideal
I ended up writing this post because we're working on a dynamic log library, and I wanted to make sure we had all our bases covered with respect to structured logging and tagged logging.
Semantic logger is pretty darn good, but if we're going to really DRY things up I could reuse the context I use for feature flags. Here's what that might look like.
My proposed ideal code
# application_controller.rbclass ApplicationController < ActionController::Base around_action do |_, block| # Put our context together in one place for the whole request with_context({ user: { key: current_user.tracking_id, id: current_user.id, name: current_user.name, } }, &block) endend# application.rbmodule MyApplication class Application < Rails::Application # tell the logger to add the values of these contexts to log messages logger.add_context_keys "user.id", "user.name" # or add extra logging context in a block logger.with_context {runtime: {value: 42}} do logger.info "hello" # Feature flag value evaluation uses existing context for user.id and runtime.value get("my-feature-flag") end endend# Log Output# INFO hello user.id=1234, user.name=Bob Beemer, runtime_value=42
This is the approach we're planning on, but I'd love to hear your thoughts.
Summary
Tagged logging is nice.
Tagged logging makes decent-looking console output.
Tagged logging requires adding things manually.
Tagged logging is a bit like structured logging, but in a way that log aggregators can't use.
Structured logging is better.
But ideally structured logging can also add structure in a block.
Semantic Logger is a great library that does this, and it's the same approach we're taking at Quonfig.
Want to try it?
Quonfig stores your config in git. Feature flags, dynamic config, log levels, and secrets — all as files you own.