Pluggability
Error levels and options
As a reminder, error levels and their logging can be controlled in your development.yaml
:
Logger
# Application logging configuration
logger:
# Enable or disable logging.
enable: true
# Enable pretty backtrace (sets RUST_BACKTRACE=1)
pretty_backtrace: true
# Log level, options: trace, debug, info, warn or error.
level: debug
# Define the logging format. options: compact, pretty or json
format: compact
# By default the logger has filtering only logs that came from your code or logs that came from `loco` framework. to see all third party libraries
# Uncomment the line below to override to see all third party libraries you can enable this config and override the logger filters.
# override_filter: trace
The most important knobs here are:
level
- your standard logging levels. Typicallydebug
ortrace
in development. In production choose what you are used to.pretty_backtrace
- provides clear, concise path to the line of code causing the error. usetrue
in development and turn off in production. In cases where you are debugging things in production and need some extra hand, you can turn it on and then off when you're done.
Controller logging
In server.middlewares
you will find:
server:
middlewares:
#
# ...
#
# Generating a unique request ID and enhancing logging with additional information such as the start and completion of request processing, latency, status code, and other request details.
logger:
# Enable/Disable the middleware.
enable: true
You should enable it to get detailed request errors and a useful request-id
that can help collate multiple request-scoped errors.
Database
You have the option of logging live SQL queries, in your database
section:
database:
# When enabled, the sql query will be logged.
enable_logging: false
Operating around errors
You'll be mostly looking at your terminal for errors while developing your app, it can look something like this:
error.chain="" http.method=GET http.uri=/notes http.version=HTTP/1.1 http.user_agent=curl/8.1.2 environment=development request_id=8622e624-9bda-49ce-9730-876f2a8a9a46
))
Usually you can expect the following from errors:
error.msg
ato_string()
version of an error, for operators.error.detail
a debug representation of an error, for developers.- An error type e.g.
controller_error
as the primary message tailored for searching, rather than a verbal error message. - Errors are logged as tracing events and spans, so that you can build any infrastructure you want to provide custom tracing subscribers. Check out the prometheus example in
loco-extras
.
Notes:
- An error chain was experimented with, but provides little value in practice.
- Errors that an end user sees are a completely different thing. We strive to provide minimal internal details about an error for an end user when we know a user can't do anything about an error (e.g. "database offline error"), mostly it will be a generic "Inernal Server Error" on purpose -- for security reasons.
Producing errors
When you build controllers, you write your handlers to return Result<impl IntoResponse>
. The Result
here is a Loco Result
, which means it also associates a Loco Error
type.
If you reach out for the Loco Error
type you can use any of the following as a response:
Err;
Err; // turns other_error to its string representation
Err;
Err
// or through controller helpers:
unauthorized // create a full response object, calling Err on a created error
Initializers
Initializers are a way to encapsulate a piece of infrastructure "wiring" that you need to do in your app. You put initializers in src/initializers/
.
Writing initializers
Currently, an initializer is anything that implements the Initializer
trait:
Example: Integrating Axum Session
You might want to add sessions to your app using axum-session
. Also, you might want to share that piece of functionality between your own projects, or grab that piece of code from someone else.
You can achieve this reuse easily, if you code the integration as an initializer:
// place this in `src/initializers/axum_session.rs`
And now your app structure looks like this:
src/
bin/
controllers/
:
:
initializers/ <--- a new folder
mod.rs <--- a new module
axum_session.rs <--- your new initializer
:
:
app.rs <--- register initializers here
Using initializers
After you've implemented your own initializer, you should implement the initializers(..)
hook in your src/app.rs
and provide a Vec of your initializers:
async
Loco will now run your initializer stack in the correct places during the app boot process.
What other things you can do?
Right now initializers contain two integration points:
before_run
- happens before running the app -- this is a pure "initialization" type of a hook. You can send web hooks, metric points, do cleanups, pre-flight checks, etc.after_routes
- happens after routes have been added. You have access to the Axum router and its powerful layering integration points, this is where you will spend most of your time.
Compared to Rails initializers
Rails initializers, are regular scripts that run once -- for initialization and have access to everything. They get their power from being able to access a "live" Rails app, modify it as a global instance.
In Loco, accessing a global instance and mutating it is not possible in Rust (for a good reason!), and so we offer two integration points which are explicit and safe:
- Pure initialization (without any influence on a configured app)
- Integration with a running app (via Axum router)
Rails initializers need ordering and modification. Meaning, a user should be certain that they run in a specific order (or re-order them), and a user is able to remove initializers that other people set before them.
In Loco, we circumvent this complexity by making the user provide a full vec of initializers. Vecs are ordered, and there are no implicit initializers.
The global logger initializer
Some developers would like to customize their logging stack. In Loco this involves setting up tracing and tracing subscribers.
Because at the moment tracing does not allow for re-initialization, or modification of an in-flight tracing stack, you only get one chance to initialize and registr a global tracing stack.
This is why we added a new App level hook, called init_logger
, which you can use to provide your own logging stack initialization.
// in src/app.rs
After you've set up your own logger, return Ok(true)
to signal that you took over initialization.
Middlewares
Loco
is a framework that is built on top of axum
and tower
. They provide a way to
add layers
and services as middleware to your routes and handlers.
Middleware is a way to add pre- and post-processing to your requests. This can be used for logging, authentication, rate limiting, route specific processing, and more.
Source Code
Loco
's implementation of route middleware / layer is similar
to axum
's Router::layer
. You can
find the source code for middleware in
the src/controllers/routes
directory.
This layer
function will attach the
middleware layer to each handler of the route.
// src/controller/routes.rs
use ;
use ;
Basic Middleware
In this example, we will create a basic middleware that will log the request method and path.
// src/controllers/middleware/log.rs
use ;
use ;
use BoxFuture;
use ;
use ;
use crate;
;
/// Implement the Service trait for LogService
/// # Generics
/// * `S` - The inner service, in this case is the `/auth/register` handler
/// * `B` - The body type
At the first glance, this middleware is a bit overwhelming. Let's break it down.
The LogLayer
is a tower::Layer
that wraps around the inner
service.
The LogService
is a tower::Service
that implements
the Service
trait for the request.
Generics Explanation
Layer
In the Layer
trait, S
represents the inner service, which in this case is the /auth/register
handler. The layer
function takes this inner service and returns a new service that wraps around it.
Service
S
is the inner service, in this case, it is the /auth/register
handler. If we have a look about
the get
, post
, put
, delete
functions which we use for handlers, they all return
a MethodRoute<S, Infallible>
(Which is a service).
Therefore, S: Service<Request<B>, Response = Response<Body>, Error = Infallible>
means it takes in a Request<B>
(
Request with a body) and returns a Response<Body>
. The Error
is Infallible
which means the handler never errors.
S::Future: Send + 'static
means the future of the inner service must implement Send
trait and 'static
.
type Response = S::Response
means the response type of the middleware is the same as the inner service.
type Error = S::Error
means the error type of the middleware is the same as the inner service.
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>
means the future type of the middleware is the
same as the inner service.
B: Send + 'static
means the request body type must implement the Send
trait and 'static
.
Function Explanation
LogLayer
The LogLayer::new
function is used to create a new instance of the LogLayer
.
LogService
The LogService::poll_ready
function is used to check if the service is ready to process a request. It can be used for
backpressure, for more information see
the tower::Service
documentation
and Tokio tutorial.
The LogService::call
function is used to process the request. In this case, we are logging the request method and
path. Then we are calling the inner service with the request.
Importance of poll_ready
:
In the Tower framework, before a service can be used to handle a request, it must be
checked for readiness
using the
poll_ready method. This method returns Poll::Ready(Ok(()))
when the service is ready to process a request. If a
service is not ready, it may return Poll::Pending
, indicating that the caller should wait before sending a request.
This mechanism ensures that the service has the necessary resources or state to process the request efficiently and
correctly.
Cloning and Readiness
When cloning a service, particularly to move it into a boxed future or similar context, it's crucial to understand that
the clone does not inherit the readiness state of the original service. Each clone of a service maintains its own state.
This means that even if the original service was ready (Poll::Ready(Ok(())))
, the cloned service might not be in the
same state immediately after cloning. This can lead to issues where a cloned service is used before it is ready,
potentially causing panics or other failures.
Correct approach to cloning services using std::mem::replace
To handle cloning correctly, it's recommended to use std::mem::replace
to swap the ready service with its clone in a
controlled manner. This approach ensures that the service being used to handle the request is the one that has been
verified as ready. Here’s how it works:
- Clone the service: First, create a clone of the service. This clone will eventually replace the original service in the service handler.
- Replace the original with the clone: Use
std::mem::replace
to swap the original service with the clone. This operation ensures that the service handler continues to hold a service instance. - Use the original service to handle the request: Since the original service was already checked for readiness (via poll_ready), it's safe to use it to handle the incoming request. The clone, now in the handler, will be the one checked for readiness next time.
This method ensures that each service instance used to handle requests is always the one that has been explicitly checked for readiness, thus maintaining the integrity and reliability of the service handling process.
Here is a simplified example to illustrate this pattern:
// Wrong
// Correct
In this example, inner
is the service that was ready, and after handling the request, self.inner
now holds the
clone, which will be checked for readiness in the next cycle. This careful management of service readiness and cloning
is essential for maintaining robust and error-free service operations in asynchronous Rust applications using Tower.
Tower Service Cloning Documentation
Adding Middleware to Handler
Add the middleware to the auth::register
handler.
// src/controllers/auth.rs
Now when you make a request to the auth::register
handler, you will see the request method and path logged.
2024-XX-XXTXX:XX:XX.XXXXXZ INFO http-request: xx::controllers::middleware::log Request: POST "/auth/register" http.method=POST http.uri=/auth/register http.version=HTTP/1.1 environment=development request_id=xxxxx
Adding Middleware to Route
Add the middleware to the auth
route.
// src/main.rs
;
Now when you make a request to any handler in the auth
route, you will see the request method and path logged.
2024-XX-XXTXX:XX:XX.XXXXXZ INFO http-request: xx::controllers::middleware::log Request: POST "/auth/register" http.method=POST http.uri=/auth/register http.version=HTTP/1.1 environment=development request_id=xxxxx
Advanced Middleware (With AppContext)
There will be times when you need to access the AppContext
in your middleware. For example, you might want to access
the database connection to perform some authorization checks. To do this, you can add the AppContext
to
the Layer
and Service
.
Here we will create a middleware that checks the JWT token and gets the user from the database then prints the user's name
// src/controllers/middleware/log.rs
use ;
use ;
use BoxFuture;
use ;
use ;
use crate;
In this example, we have added the AppContext
to the LogLayer
and LogService
. We are using the AppContext
to get
the database connection and the JWT token for pre-processing.
Adding Middleware to Route (advanced)
Add the middleware to the notes
route.
// src/app.rs
;
Now when you make a request to any handler in the notes
route, you will see the user's name logged.
2024-XX-XXTXX:XX:XX.XXXXXZ INFO http-request: xx::controllers::middleware::log User: John Doe environment=development request_id=xxxxx
Adding Middleware to Handler (advanced)
In order to add the middleware to the handler, you need to add the AppContext
to the routes
function
in src/app.rs
.
// src/app.rs
;
Then add the middleware to the notes::create
handler.
// src/controllers/notes.rs
Now when you make a request to the notes::create
handler, you will see the user's name logged.
2024-XX-XXTXX:XX:XX.XXXXXZ INFO http-request: xx::controllers::middleware::log User: John Doe environment=development request_id=xxxxx