Access log middleware for Deno Fresh
Tags: deno, fresh, typescript, javascript
Published: 2022-10-16 22:25:51

> **TL/DR**: If you read the title and know exactly what I mean already, simply go to https://deno.land/x/fresh_logging and follow the README.

This is part 3 of a series about my Deno Fresh plugins, so I will not reiterate on the topics already defined in part 1. If you are not interested in my Cloudflare Turnstile plugin, please at least read [part 1's Background section](https://dev.to/khuongduybui/cloudflare-turnstile-plugin-for-deno-fresh-3ph0#:~:text=by%20all%20means.-,Background,-Alright%2C%20now%20that) if you are unsure what Deno Fresh is.

# Background

I was looking for a Deno middleware to help me easily handle the request's **Accept** header and couldn't find any. As a good citizen of the open-source community, I set out to create one myself and started reading about Deno middlewares. As soon as I read the [official docs](https://fresh.deno.dev/docs/concepts/middleware), however, I realized what the community is missing: an access log middleware! (Side note: as a security software engineer by day, it's my occupational habit to care about access logs.)

As of this post's writing, my plugin supports the two most commonly used (in my daily work) access log formats: the [W3C Common Log Format]([Logging in W3C httpd](https://www.w3.org/Daemon/User/Config/Logging.html#common-logfile-format)) and a variant by Apache called [Combined Log Format](https://httpd.apache.org/docs/2.4/logs.html#:~:text=%25B%20instead.-,Combined%20Log%20Format,-Another%20commonly%20used).

# Prerequisites

By now you should already know the drill.

Create a Fresh app if you haven't.

```sh
deno run -A -r https://fresh.deno.dev sample-app
```

Now that you have created your Fresh app, add `fresh-logging` to your `import_map.json` for convenience:

```json
{
  "imports": {
    "$logging/": "https://deno.land/x/fresh_logging@1.1.2/"
  }
}
```

Then consume the middleware in your app's `routes/_middleware.ts`.

```ts
import * as getLogger from "$logging/index.ts";
// or
// import { getLogger, ... } from "$logging/index.ts";

export const handler = [
  getLogger(),
  // ... other middlewares
];
```

# Use Case 1: Common Log Format

You do not have to do anything, this is the default format.

# Use Case 2: Apache Combined Log Format

Specify `LoggingFormat.APACHE_COMBINED` for the `format` option like this:  

```ts
import { getLogger, LoggingFormat } from "$logging/index.ts";

export const handler = [
  getLogger({
    format: LoggingFormat.APACHE_COMBINED,
  }),
];
```

The default two headers included are **Referer** and **User-agent**. You can override that by optionally providing the `combinedHeaders` option, which expects a string array of length 2.

# Limitations

As of v1.1.2, the following fields are **completely** omitted (hard-coded to `-`):

- `rfc931` (client identifier): not sure how to obtain this
- `authuser` (user identifier): not sure how to obtain this either
- `bytes` (response content length): one way I can think of is to use `res.clone()` then read its as `ArrayBuffer` and get the `byteLength`, but that is both [time and memory consuming](https://github.com/khuongduybui/fresh-logging/issues/1). Until I can find [a more efficient way](https://github.com/denoland/fresh/issues/829) to obtain this piece of information, omission is the decision. 

Users can use the `resolvers` to provide custom resolutions of the missing fields. For example, the following code snippet allows logging the response bytes:  

```ts
import { getLogger, ResolutionField } from "$logging/index.ts";

export const handler = [
  getLogger({
    resolvers: {
      [ResolutionField.bytes]: async (_req, _ctx, res) => `${(await res.clone().arrayBuffer()).byteLength}`,
    },
  }),
];
```

Again, please note that the example above only serves to illustrate how to provide customer resolvers for the missing fields, the actual implementation is sub-optimal. Otherwise, it would have been included as default resolver for that field.

# Additional Features

## Response Time

Regardless of log format, you can set the option `includeDuration` to `true` to include the handler response time at the end of each log entry. The header **Server-Timing** will also be set on the response.

```ts
import { getLogger } from "$logging/index.ts";

export const handler = [
  getLogger({includeDuration: true}),
  // ... other middlewares
];
``` 

**Note**: if `includeDuration` option is ON, `getLogger()` will also count the time taken by all of its subsequent middlewares.

For example, putting `getLogger()` at the beginning of your `handler` array will count the time taken by all middlewares, while putting it at the very end of your `handler` array will yield the time taken only by the route handler.

## Custom Logger

By default, the middleware uses an [Optic](https://deno.land/x/optic@1.3.5) logger that sends output to the console at INFO level, with the color, but without the **INFO** prefix.

You can provide a log function with the signature `(message: string) => string` to the `logger` option to use your own log writing implementation.

```ts
import { getLogger } from "$logging/index.ts";

export const handler = [
  getLogger({
    logger: (message: string) => {
      console.debug(message);
      return message;
    },
  }),
];
```

The Optic logging library is full of features (seriously, [look at it](https://deno.land/x/optic)) and is highly recommended, but you can literally choose any implementation in your custom logger function.

## Custom Resolver

As briefly discussed in the [Limitations](#limitations) section above, you can provide a custom resolver for fields that the middleware is unsure how to populate. The following code snippet illustrates how to resolve the **authuser** field.

```ts
 
import { getLogger, ResolutionField } from "$logging/index.ts";

const auth: MiddlewareHandler = (req: Request, ctx: MiddlewareHandlerContext) => {
  ctx.state.user = "-";
  if (req.headers.has("Authorization")) {
    // inspect req.headers.get("Authorization") and override ctx.state.user
  }
  return ctx.next();
};

export const handler = [
  auth,
  getLogger({
    resolvers: {
      [ResolutionField.authuser]: (_req, ctx, _res) => ctx.state.user as string,
    },
  }),
];
```

# Appendix: Options

The `getLogger()` function accepts an optional object `{}` with the following options:

| Option            | Default Value                | Notes                                                                                                                                                  |
| ----------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `format`          | `LoggingFormat.COMMON`       | The log format to use, defaulting to the [W3C Common Log Format](https://www.w3.org/Daemon/User/Config/Logging.html#common-logfile-format).            |
| `utcTime`         | `false`                      | Whether to log timestamps in UTC or server timezone.                                                                                                   |
| `includeDuration` | `false`                      | Whether to include handler response time.                                                                                                              |
| `resolvers`       | `{}`                         | Selectively supply customer resolvers for the missing fields. See the section on [limitations](#limitations) for more details.                         |
| `logger`          | `console.info` +color -level | Optionally supply a custom logger function of type `(message: string) => string`. See the section on [custom logger](#custom-logger) for more details. |
| `combinedHeaders` | ["Referer", "User-agent"]    | Optionally supply custom request headers to include. Requires setting `format: LoggingFormat.APACHE_COMBINED`.                                         | 

---

That's it, my friends. Thank you for staying with me till the end of it. Feel free to [start a discussion](https://github.com/khuongduybui/fresh-logging/discussions/new) or [file an issue](https://github.com/khuongduybui/fresh-logging/issues/new)! You may also hit me up on dev.to messaging anytime too!