Skip to content

Commit ad05690

Browse files
authored
Request logging for HTTP 2.x + fixed docs prefix stripping bug (#304)
**Request Logging** As part of the HTTP 2.x upgrade I wanted to ensure we didn't drop support for request logs. I've carried over the exact same spec + small improvements so previous log formats should work as is. Any web framework needs to have some level of default logging in place, so I had to postpone the upgrade until something like this was in place - `access_log` is still the main entry point for customizing the log format - The `logfmt` macro from 1.x of HTTP.jl is carried over - The same log formatting is used and exposed as`oxygen_logfmt` (used by default) **Docs Prefix Bug** Fixed an issue where docs wouldn't render properly due to the absolute paths that were configured internally. When running oxygen behind a reverse-proxy, the docs pages weren't accessible. The solution was to make sure all docs links are relative links so they work with custom and global prefixes
1 parent ffeb40d commit ad05690

18 files changed

Lines changed: 686 additions & 48 deletions

Project.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ authors = ["Nathan Ortega <nate.ortega95@gmail.com>"]
55
repo = "https://github.com/OxygenFramework/Oxygen.jl.git"
66

77
[deps]
8+
Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
89
DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8"
910
Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
1011
HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3"
1112
JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
13+
Logging = "56ddb016-857b-54e1-b83d-db4d58db5568"
1214
LRUCache = "8ac3fa9e-de4c-5943-b1dc-09c6b5f20637"
1315
MIMEs = "6c6e2e6c-3030-632d-7369-2d6c69616d65"
1416
Reexport = "189a3867-3050-52da-a836-e630ba90ab69"
@@ -37,6 +39,7 @@ TimeZonesExt = "TimeZones"
3739
WGLMakieExt = ["WGLMakie", "Bonito"]
3840

3941
[compat]
42+
Base64 = "^1"
4043
Bonito = "^5"
4144
CairoMakie = "^0.13, 0.15"
4245
DataStructures = "^0.18.15, 0.19"

README.md

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1055,10 +1055,34 @@ serve()
10551055
```
10561056
## Logging
10571057

1058-
HTTP.jl 2.x no longer does per-request access logging in the server, so the `access_log` keyword
1059-
(and the `logfmt"..."` format macro) from earlier Oxygen versions is gone. The `access_log` kwarg
1060-
is still accepted by `serve()` and `serveparallel()` for backwards compatibility, but it is ignored.
1061-
If you need request logging, add it yourself with a small [middleware](#middleware) function.
1058+
Since HTTP.jl 2.x no longer performs per-request access logging in the server,
1059+
we reintroduced request-level logging through the built-in `AccessLog` middleware. It emits a log entry
1060+
for each request through Julia's logging system (at `Info` level with `_group=:access`) using
1061+
NGINX-style format strings defined with the `logfmt"..."` macro.
1062+
1063+
In practice, you'll rarely need to configure this middleware function yourself. The main entrypoint to configure the logging format is the `access_log` keyword in the `serve()` and `serverparallel()` function:
1064+
1065+
```julia
1066+
serve(access_log=logfmt"[$time_iso8601] \"$request\" $status $body_bytes_sent")
1067+
```
1068+
1069+
If you ever want to have additional request logging formats running alongside the default you can insert the middleware function like this:
1070+
1071+
```julia
1072+
using Oxygen
1073+
1074+
# Oxygen Log Format (default)
1075+
serve(middleware=[AccessLog()])
1076+
1077+
# Combined Log Format
1078+
serve(middleware=[AccessLog(format=combined_logfmt)])
1079+
1080+
# Custom format
1081+
serve(middleware=[AccessLog(format=logfmt"[$time_iso8601] \"$request\" $status $body_bytes_sent")])
1082+
```
1083+
1084+
Because entries go through the standard logging system, they can be captured, filtered or
1085+
redirected to a file using the `Logging` / `LoggingExtras` APIs.
10621086

10631087
## Middleware
10641088

demo/Project.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Gtk = "4c0ca9eb-093a-5379-98c5-f87ac0bbbf44"
1010
HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3"
1111
JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
1212
JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1"
13+
LRUCache = "8ac3fa9e-de4c-5943-b1dc-09c6b5f20637"
1314
MIMEs = "6c6e2e6c-3030-632d-7369-2d6c69616d65"
1415
Mustache = "ffc61752-8dc7-55ee-8c37-f3e9cdd09e70"
1516
OteraEngine = "b2d7f28f-acd6-4007-8b26-bc27716e5513"
@@ -23,4 +24,5 @@ StructTypes = "856f2bd8-1eba-4b0a-8007-ebc267875bd4"
2324
Suppressor = "fd094767-a336-5f1f-9728-57cf17d0bbfb"
2425
SwaggerMarkdown = "1b6eb727-ad4b-44eb-9669-b9596a6e760f"
2526
TimeZones = "f269a46b-ccf7-5d73-abea-4c690281aa53"
27+
URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4"
2628
WGLMakie = "276b4fcb-3e11-5398-bf8b-a0c2d153d008"

demo/ergonomicsdemo.jl

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ module ErgonomicsDemo
22

33
using Oxygen
44
using Dates
5-
using HTTP
65
using JSON
76
using Base: @kwdef
87
using BenchmarkTools

docs/src/index.md

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1055,10 +1055,34 @@ serve()
10551055
```
10561056
## Logging
10571057

1058-
HTTP.jl 2.x no longer does per-request access logging in the server, so the `access_log` keyword
1059-
(and the `logfmt"..."` format macro) from earlier Oxygen versions is gone. The `access_log` kwarg
1060-
is still accepted by `serve()` and `serveparallel()` for backwards compatibility, but it is ignored.
1061-
If you need request logging, add it yourself with a small [middleware](#middleware) function.
1058+
Since HTTP.jl 2.x no longer performs per-request access logging in the server,
1059+
we reintroduced request-level logging through the built-in `AccessLog` middleware. It emits a log entry
1060+
for each request through Julia's logging system (at `Info` level with `_group=:access`) using
1061+
NGINX-style format strings defined with the `logfmt"..."` macro.
1062+
1063+
In practice, you'll rarely need to configure this middleware function yourself. The main entrypoint to configure the logging format is the `access_log` keyword in the `serve()` and `serverparallel()` function:
1064+
1065+
```julia
1066+
serve(access_log=logfmt"[$time_iso8601] \"$request\" $status $body_bytes_sent")
1067+
```
1068+
1069+
If you ever want to have additional request logging formats running alongside the default you can insert the middleware function like this:
1070+
1071+
```julia
1072+
using Oxygen
1073+
1074+
# Oxygen Log Format (default)
1075+
serve(middleware=[AccessLog()])
1076+
1077+
# Combined Log Format
1078+
serve(middleware=[AccessLog(format=combined_logfmt)])
1079+
1080+
# Custom format
1081+
serve(middleware=[AccessLog(format=logfmt"[$time_iso8601] \"$request\" $status $body_bytes_sent")])
1082+
```
1083+
1084+
Because entries go through the standard logging system, they can be captured, filtered or
1085+
redirected to a file using the `Logging` / `LoggingExtras` APIs.
10621086

10631087
## Middleware
10641088

@@ -1089,7 +1113,7 @@ function CorsMiddleware(handler)
10891113
return function(req::HTTP.Request)
10901114
println("CORS middleware")
10911115
# determine if this is a pre-flight request from the browser
1092-
if req.method=="OPTIONS"
1116+
if HTTP.method(req)=="OPTIONS"
10931117
return HTTP.Response(200, CORS_HEADERS)
10941118
else
10951119
return handler(req) # passes the request to the AuthMiddleware

src/Oxygen.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export @oxidize, @oxidise, @get, @post, @put, @patch, @delete, @route,
4848
Path, Query, Header, Json, JsonFragment, Form, Body, extract, validate,
4949
# Middleware
5050
BearerAuth, Cors, RateLimiter, ExtractIP,
51+
AccessLog, common_logfmt, combined_logfmt, oxygen_logfmt, @logfmt_str,
5152
# Docs
5253
configdocs, mergeschema, setschema, getschema, router,
5354
enabledocs, disabledocs, isdocsenabled,

src/autodoc.jl

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -801,7 +801,7 @@ function readstaticfile(filepath::String)::String
801801
end
802802

803803

804-
function redochtml(schemapath::String, docspath::String) :: HTTP.Response
804+
function redochtml(schemapath::String) :: HTTP.Response
805805
redocjs = readstaticfile("$REDOC_VERSION/redoc.standalone.js")
806806

807807
html("""
@@ -813,7 +813,7 @@ function redochtml(schemapath::String, docspath::String) :: HTTP.Response
813813
<meta charset="utf-8"/>
814814
<meta name="description" content="Docs" />
815815
<meta name="viewport" content="width=device-width, initial-scale=1">
816-
<link rel="icon" type="image/x-icon" href="$docspath/metrics/favicon.ico">
816+
<link rel="icon" type="image/x-icon" href="metrics/favicon.ico">
817817
</head>
818818
819819
<body>
@@ -829,7 +829,7 @@ end
829829
"""
830830
Return HTML page to render the autogenerated docs
831831
"""
832-
function swaggerhtml(schemapath::String, docspath::String) :: HTTP.Response
832+
function swaggerhtml(schemapath::String) :: HTTP.Response
833833

834834
# load static content files
835835
swaggerjs = readstaticfile("$SWAGGER_VERSION/swagger-ui-bundle.js")
@@ -845,7 +845,7 @@ function swaggerhtml(schemapath::String, docspath::String) :: HTTP.Response
845845
<meta name="viewport" content="width=device-width, initial-scale=1" />
846846
<meta name="description" content="Docs" />
847847
<style>$swaggerstyles</style>
848-
<link rel="icon" type="image/x-icon" href="$docspath/metrics/favicon.ico">
848+
<link rel="icon" type="image/x-icon" href="metrics/favicon.ico">
849849
</head>
850850
851851
<body>

src/core.jl

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,14 @@ include("repeattasks.jl"); @reexport using .RepeatTasks
2626
include("metrics.jl"); @reexport using .Metrics
2727
include("reflection.jl"); @reexport using .Reflection
2828
include("extractors.jl"); @reexport using .Extractors
29-
using .Extractors: Form # Prefer over HTTP.Form
3029
include("autodoc.jl"); @reexport using .AutoDoc
3130

31+
# Both HTTP and our Extractors module export a type named `Form`, which makes the
32+
# name ambiguous (and thus unbound) after `using HTTP` + `@reexport using .Extractors`.
33+
# The explicit import below resolves the ambiguity so `Oxygen.Form` is actually defined,
34+
# preferring our own extractor over HTTP.Form.
35+
using .Extractors: Form
36+
3237
export start, serve, serveparallel, terminate,
3338
internalrequest, staticfiles, dynamicfiles
3439

@@ -84,7 +89,7 @@ function ReviseHandler()
8489
end
8590

8691
"""
87-
serve(; middleware::Vector=[], handler=stream_handler, host="127.0.0.1", port=8080, async=false, parallel=false, serialize=true, catch_errors=true, docs=true, metrics=true, show_errors=true, show_banner=true, docs_path="/docs", schema_path="/schema", external_url=nothing, revise, kwargs...)
92+
serve(; middleware::Vector=[], handler=stream_handler, host="127.0.0.1", port=8080, async=false, parallel=false, serialize=true, catch_errors=true, docs=true, metrics=true, show_errors=true, show_banner=true, docs_path="/docs", schema_path="/schema", external_url=nothing, access_log=oxygen_logfmt, revise, kwargs...)
8893
8994
Start the webserver with your own custom request handler
9095
"""
@@ -107,6 +112,7 @@ function serve(ctx::ServerContext;
107112
prefix = nothing,
108113
context = missing,
109114
revise = :none, # :none, :lazy, :eager
115+
access_log = oxygen_logfmt, # (io::IO, req::HTTP.Request) -> nothing formatter
110116
kwargs...) :: Server
111117

112118
if !ismissing(context)
@@ -143,7 +149,7 @@ function serve(ctx::ServerContext;
143149
end
144150

145151
# compose our middleware ahead of time (so it only has to be built up once)
146-
configured_middelware = setupmiddleware(ctx; middleware, serialize, catch_errors, docs, metrics, show_errors)
152+
configured_middelware = setupmiddleware(ctx; middleware, serialize, catch_errors, docs, metrics, show_errors, access_log)
147153

148154
# setup the primary stream handler function (can be customized by the caller)
149155
handle_stream = handler(configured_middelware)
@@ -319,11 +325,11 @@ Compose the user & internally defined middleware functions together. Practically
319325
users to 'chain' middleware functions like `serve(handler1, handler2, handler3)` when starting their
320326
application and have them execute in the order they were passed (left to right) for each incoming request
321327
"""
322-
function setupmiddleware(ctx::ServerContext; middleware::Vector=[], docs::Bool=true, metrics::Bool=true, serialize::Bool=true, catch_errors::Bool=true, show_errors=true)::Function
328+
function setupmiddleware(ctx::ServerContext; middleware::Vector=[], docs::Bool=true, metrics::Bool=true, serialize::Bool=true, catch_errors::Bool=true, show_errors=true, access_log=nothing)::Function
323329

324330
# determine if we have any special router or route-specific middleware
325331
raw_middleware = reverse(middleware)
326-
332+
327333
processed_middleware = process_middleware(ctx, raw_middleware)
328334

329335
custom_middleware = if !isempty(ctx.service.custommiddleware)
@@ -344,13 +350,22 @@ function setupmiddleware(ctx::ServerContext; middleware::Vector=[], docs::Bool=t
344350
# check if we need to track metrics
345351
collect_metrics = metrics ? [MetricsMiddleware(ctx.service, metrics)] : []
346352

353+
# check if access logging was requested
354+
access_loggers = if access_log !== nothing
355+
access_log isa Function || throw(ArgumentError("access_log must be a formatter function `(io::IO, req::HTTP.Request) -> nothing`, e.g. a logfmt\"...\" value"))
356+
[AccessLog(format=access_log)]
357+
else
358+
[]
359+
end
360+
347361
# combine all our middleware functions
348362
return reduce(|>, [
349363
ctx.service.router,
350364
serializer...,
351365
custom_middleware...,
352366
collect_metrics...,
353367
docs_middleware...,
368+
access_loggers...,
354369
global_prefix_middleware...
355370
])
356371
end
@@ -395,12 +410,11 @@ end
395410

396411

397412
"""
398-
Removes deprecated keys from incoming keyword arguments, currently: :stream, :access_log, and :queuesize.
413+
Removes deprecated keys from incoming keyword arguments, currently: :stream and :queuesize.
399414
"""
400415
function preprocesskwargs(kwargs)
401416
kwargs_dict = Dict{Symbol,Any}(kwargs)
402417
delete!(kwargs_dict, :stream)
403-
delete!(kwargs_dict, :access_log)
404418
delete!(kwargs_dict, :queuesize)
405419
return kwargs_dict
406420
end
@@ -802,16 +816,20 @@ end
802816
function setupdocs(ctx::ServerContext, router::Router, schema::Dict, docspath::String, schemapath::String)
803817
full_schema = "$docspath$schemapath"
804818

805-
# If a global prefix is assigned, then we need to make sure we inject the prefixes into the source url as well.
806-
prefixed_schema = join_url_path(ctx.service.prefix[], full_schema)
807-
prefixed_docspath = join_url_path(ctx.service.prefix[], docspath)
819+
# Emit the schema URL relative (no leading slash) so it resolves regardless of
820+
# prefix-stripping reverse proxies. Nested pages (/docs/swagger, /docs/redoc) resolve
821+
# a bare "schema" against <docspath>/, but the bare /docs page needs the docspath
822+
# segment ("docs/schema") since its base directory is "/".
823+
schema_url = String(lstrip(schemapath, '/'))
824+
bare_schema_url = String(lstrip("$docspath$schemapath", '/'))
808825

809-
# Need to update the "path" in our open-api schema to include the global prefix
826+
# If a global prefix is assigned, then we need to make sure we inject it into the
827+
# "paths" of the open-api schema.
810828
prefixed_openapi_schema = prefix_schema_paths(schema, ctx.service.prefix[])
811829

812-
register_internal(ctx, router, "GET", "$docspath", () -> swaggerhtml(prefixed_schema, prefixed_docspath))
813-
register_internal(ctx, router, "GET", "$docspath/swagger", () -> swaggerhtml(prefixed_schema, prefixed_docspath))
814-
register_internal(ctx, router, "GET", "$docspath/redoc", () -> redochtml(prefixed_schema, prefixed_docspath))
830+
register_internal(ctx, router, "GET", "$docspath", () -> swaggerhtml(bare_schema_url))
831+
register_internal(ctx, router, "GET", "$docspath/swagger", () -> swaggerhtml(schema_url))
832+
register_internal(ctx, router, "GET", "$docspath/redoc", () -> redochtml(schema_url))
815833
register_internal(ctx, router, "GET", full_schema, () -> prefixed_openapi_schema)
816834
end
817835

src/middleware.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ include("middleware/extract_ip.jl"); @reexport using .ExtractIPMiddleware
55
include("middleware/rate_limiter.jl"); @reexport using .RateLimiterMiddleware
66
include("middleware/auth_middleware.jl"); @reexport using .AuthMiddleware
77
include("middleware/cors_middleware.jl"); @reexport using .CORSMiddleware
8+
include("middleware/access_log.jl"); @reexport using .AccessLogMiddleware
89

910
end

0 commit comments

Comments
 (0)