How do you manage context?

Coming from golang I’m searching a replacement for the ubiquitous context package - context - Go Packages package in crystal. The context is useful for logging, security, cancelation, tracing, …

I first thought was that this must be a property of the current Fiber (e.g. Fiber.current.context) but that is not the case. So how do you do deal with that? Does everyone and every package implement his own context?

Thanks for your help.

I’m not super familiar with Go or its context package, but I’d say it probably depends on the context (ba dum tsch), or what exactly you’re wanting to do.

Like for logging, the Log module has fiber specific context that you can use to apply some data to all messages regardless of where they’re logged. Otherwise if you’re in the context of a web request, the framework might provide some way to do it. Do you have some more specific examples in mind?

Related: Fiber-local state?

2 Likes

Yes that is exactly what I need but in general not only for logging. Is the idea that everyone justs extends the Fiber?

Using thread local variables has some caviat the way the golang context package approaches it is different. The context package basically fors a tree. The context is an object that has a key-value pair and a origin context. There is no way to “overwrite values” only add a new context and by that shadow values. The use throughout the wohle program is therefore more safe.

The context package is in the meantime to important in go that it became the first parameter of almost all IO related functions.

Usually I manage everything with the context package in golang. I stuggle to find a good way to do it in crystal without making a copy of that concept. Making a copy makes only sense if integrated into the standard library…

Coming back to usecase. The first usecase is with a middleware for the HTTP::Server that would authenticate the request by session cookie / token and then add the security context to the token. The second use indeed is indeed a logger with e.g. a request id and the user id based on the session id (in go the security data is usually shared via the context object between these two middlewares. The next middleware is a tracing one, it determines if there is an open tracing request and would add the tracing id to the context…and so on and so on…
Lets talk about actual business code, the code may takes the user id from the context to filter the user data in a postres database. Since there is also an http call to another backend a HTTP::Request.before_request hook will be executed and the tracing and security information will be added and forwarded to the next backend system…

That is not even the full picture but it hopefully gives an impression on the context usage

It sounds quite a bit like Log::Context in the Crystal stdlib. You can set context and then grab it out elsewhere down the call stack:

require "log"

log = Log.for("stuff")

pp log.context.metadata[:foo]?
# => nil

log.with_context foo: "bar" do
  pp inside_block: log.context.metadata[:foo]
  # => {inside_block: "bar"}

  do_a_thing
  # => {inside_method: "bar"}
end

pp log.context.metadata[:foo]?
# => nil

do_a_thing
# => {inside_method: nil}

def do_a_thing
  # Note that we're using the top-level Log constant here
  pp inside_method: Log.context.metadata[:foo]?
end

I’ve used it for instrumentation by setting the user_id for a request in the authentication helper, then every span for that trace can be associated with that user — so for example, if a DB query is only slow for a small percentage of users, you can find out which users they are because they’ll have a user_id attribute.

if (current_user_id = session["user_id"]?) && (current_user = UserQuery.new.find(current_user_id))
  Log.context.set user_id: current_user_id
end

The Honeybadger shard also imports it into the Honeybadger context by default, too. It’s pretty much the de facto fiber-local context library for Crystal.

1 Like

@jgaskins yes, it is exactly Log::Context for logging, but I was basically asking, where the ::Context is. From the response so far it seems that people simply extend the Fiber.

So basically very similar to the logging example:

require "context"

context = Fiber.context

pp context[:foo]
# => nil

context.with token: "someToken" do
  pp inside_block: Fiber.context[:token]
  # => {inside_block: "someToken"}

end

pp outside_block: Fiber.context[:token]
# => {outside_block: nil}

I think that would be easy to add to the stdlib and what I need. What do you think

Yea the Log context is deff the way to go for the logging side of things, but I don’t think its something you should also tap into for your business logic.

In regards to the HTTP side of things, its fairly common for people to monkey patch additional things into HTTP::Server::Context such that handlers would be able to set data, then future ones could then access it. For example:

However, for your example use case, each framework may have alternative/better ways to handle it, so it would ultimately depend on which one you go with.

It’s also possible the answer is that it’s just not a common way of doing things in Crystal, and blindly starting to use some top level Context object just isn’t the right approach.

I agree that the Log::Context is not for the business logic.

Well these context have a different nature they are not for bridging the gap between different application code, frameworks, logging libraries, …

It’s also possible the answer is that it’s just not a common way of doing things in Crystal,

Okay maybe not commont, that is why I asked the question in the first place. So far it seems only common to extend Fiber…

and blindly starting to use some top level Context object just isn’t the right approach.

Well not sure how you get to blindly I hope to be very reflected and argue with logic. Having spend many years in golang I just saw the immense potential of this approach. I I’m “missing” the right concept in crystal.

So long story short, one could say the defacto way of doing it in crystal is to extend Fiber. I think that is okay, however it feels more messy to me and also doesn’t mimic some of the scoping and shadowing I was talking about.

So I think here is something clearly missing in the Crystal stdlib and everyone is filling the gap a slightly different way. Can’t we think of a way that does not require Monkeypatching supported by the stdlib?

I more so meant that maybe this entire pattern of context objects isn’t as prevalent in Crystal than Go versus how people have implemented a similar concept in the past. I.e. does the language really need this feature?

I meant that I don’t think its the right approach to come to the immediate conclusion that “we need to have a ::Context object in the stdlib” without first exploring the use cases it’s meant to solve, while keeping things idiomatic. E.g. like discussed in Extensible HTTP::Server::Context for libraries · Issue #3863 · crystal-lang/crystal · GitHub.

I just don’t feel like it would be very Crystal like to have a ctx : Context parameter on every method in your code like you do in Go. Exposing Fiber.context or something similar helps with that, but I’m sure there are other considerations.

Either way, I’ll defer to someone else who has more familiarity with this pattern.

EDIT: To me it seems the primary/most common use case if for HTTP, in which case you can either add your stuff to HTTP::Server::Context, or if you really need, do something with Fiber. But outside of this, I just fear having something like a more general Log::Context will just lead to people being lazy and code that is less thought out if “oh i can just throw stuff in here and not worry about it”.

1 Like

Well I know a few programming languages that have solved it differently. Usually in a kind of thread local storage type way. I don’t say this is not a solution but is is also like a global variable with Fiber scope. In golang the green threads are not exposed, so there is no object they can be attached to as such an alternative needed to be found.

The context package as in golang can’t and should not be directly translated. Yes, a ctx : Context parameter on every method doen’t make sense as the Fiber is already exposed and can be extended.

The two main use cases for the context are cancelation and value sharing. We only covered value sharing so far but not a real example so far. I will add one later on. I have used the context for many things in golang not only related to HTTP so I think that HTTP::Server::Context will not cut it.

Since most of it is already implemented in Log::Metadata it was easy to create a simple example implementation based on that:

require "log"

class Fiber
  # :nodoc:
  getter value_context : Log::Metadata { Log::Metadata.empty }

  def with_values(**kwargs, &)
    previous = value_context
    new_metadata = value_context.not_nil!.extend(kwargs) unless kwargs.empty?
    begin
      @value_context = new_metadata
      yield
    ensure
      @value_context = previous
    end
  end

  def [](key : Symbol) : Value
    value_context.not_nil!.fetch(key) { raise KeyError.new "Missing metadata key: #{key.inspect}" }
  end
end

puts Fiber.current[:foo] rescue "failed"
Fiber.current.with_values foo: "123" do
  puts Fiber.current[:foo]
  Fiber.current.with_values foo: "456" do
    puts Fiber.current[:foo]
  end
  puts Fiber.current[:foo]
end
puts Fiber.current[:foo]

The above example renders:
*123
*456
*123

This is the good property of immutability the same way as in go, it will also prevent people from shooting themselfs into the food easily.

Lets see it in a “more” real world scenario:

require "http/server"
require "http/client"

class AuthHandler
  include HTTP::Handler

  def call(context)
    # take toekn from request and verify it
    Fiber.current.with_values token: "123" do
      call_next(context)
    end
  end
end

# lets assume that is andifferent internal microserice that wants to get the token...
client = HTTP::Client.new(URI.parse("https://example.com"))
client.before_request do |context|
  context.headers["Authorization"] = "Bearer #{Fiber.current[:token]}"
end

server = HTTP::Server.new([
  AuthHandler.new,
]) do |context|
  context.response.content_type = "text/plain"
  context.response.print "Hello world, got #{context.request.path}!"

  client.get("/secret/data")
end

puts "Listening on http://127.0.0.1:8080"
server.listen(8080)

Since we use the context we can easily pass the security context around. Even more, in case the service does a token execange to e.g. get more privileges or to call a third party, will only affect the new context and only temporarily shadow the original token value.

For what it’s worth, this sounds like something that could be added as a shard first and proved out before adding straight to the standard library. I use this pattern in Java and generally like it, but I’m not sure it needs to be ubiquitous everywhere (yet).

1 Like

Creating a shard is a good idea, so this is what I did: GitHub - threez/context.cr: The missing context shard for crystal

5 Likes