OpenResty
Instrument OpenResty (nginx + LuaJIT) with OpenTelemetry to send traces, metrics, and logs to Last9
Monitor your OpenResty reverse proxy with full-stack observability — distributed traces with per-upstream child spans, custom Lua metrics, and structured access logs — all forwarded to Last9 via OpenTelemetry Collector. No module compilation required.
Prerequisites
- OpenResty 1.21+ (includes LuaJIT 2.1)
- OpenResty Package Manager (
opm) — bundled with OpenResty - OpenTelemetry Collector
How it works
Each HTTP request proxied through OpenResty produces:
- A SERVER span covering the full request lifecycle with OTel HTTP semconv v1.23+ attributes
- One CLIENT child span per upstream attempt — retries and failovers appear as separate spans with reconstructed timings
exceptionspan events for 4xx and 5xx with typed error classification- Access and error logs with OTel semconv attribute names
- Prometheus metrics: request counters, latency histograms, upstream response time histograms, active connection gauge
Trace context (traceparent + tracestate) is extracted from incoming requests and injected into upstream calls. Sampling is configurable via OTEL_TRACES_SAMPLER.
Installation
-
Install Lua dependencies
opm get knyar/nginx-lua-prometheus ledgetech/lua-resty-httpFROM openresty/openresty:1.25.3.1-bullseye-fatRUN opm get knyar/nginx-lua-prometheus ledgetech/lua-resty-httpCOPY nginx.conf /usr/local/openresty/nginx/conf/nginx.confCOPY conf.d/ /etc/nginx/conf.d/COPY lua/ /usr/local/openresty/site/lualib/EXPOSE 80 9145CMD ["/usr/local/openresty/bin/openresty", "-g", "daemon off;"]lua-resty-httpis required for the async OTLP export — it is not included in the OpenResty base image. -
Configure
nginx.confAdd to the
http {}block:http {lua_shared_dict prometheus_metrics 10m;lua_package_path "/usr/local/openresty/site/lualib/?.lua;;";# Required for hostname resolution inside ngx.timer.at callbacks.# 127.0.0.11 is Docker's embedded DNS; replace with your resolver otherwise.resolver 127.0.0.11 valid=30s ipv6=off;init_worker_by_lua_block {require("metrics_init").init()}log_format json_combined escape=json '{''"timestamp":"$time_iso8601",''"remote_addr":"$remote_addr",''"request_method":"$request_method",''"request_uri":"$request_uri",''"server_protocol":"$server_protocol",''"status":$status,''"body_bytes_sent":$body_bytes_sent,''"request_length":$request_length,''"request_time":$request_time,''"upstream_response_time":"$upstream_response_time",''"upstream_connect_time":"$upstream_connect_time",''"upstream_addr":"$upstream_addr",''"upstream_status":"$upstream_status",''"upstream_cache_status":"$upstream_cache_status",''"http_user_agent":"$http_user_agent",''"http_referer":"$http_referer",''"trace_id":"$otel_trace_id",''"span_id":"$otel_span_id"''}';access_log /var/log/nginx/access.log json_combined;include /etc/nginx/conf.d/*.conf;} -
Add tracing hooks to your server block
server {listen 80;# Required for log-trace correlation — populated by start_span()set $otel_trace_id "";set $otel_span_id "";access_by_lua_block {require("otel_tracer").start_span()}log_by_lua_block {require("otel_tracer").finish_span()require("metrics_init").record()}location / {proxy_pass http://upstream-app;proxy_http_version 1.1;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;# traceparent + tracestate injected automatically by start_span()}location /health {access_log off;return 200 '{"status":"ok"}';add_header Content-Type application/json;}} -
Add a metrics and status server block
Expose on a separate port not reachable from the internet:
server {listen 9145;location /metrics {access_log off;content_by_lua_block {require("metrics_init").collect()}}location /nginx_status {stub_status on;access_log off;allow 127.0.0.1;allow 10.0.0.0/8;allow 172.0.0.0/8;allow 192.168.0.0/16;deny all;}} -
Configure the OTel Collector
receivers:otlp:protocols:grpc:endpoint: 0.0.0.0:4317http:endpoint: 0.0.0.0:4318prometheus/lua:config:scrape_configs:- job_name: openresty_luascrape_interval: 30sstatic_configs:- targets: ["localhost:9145"]metrics_path: /metricsnginx:endpoint: "http://localhost:9145/nginx_status"collection_interval: 30sfilelog/access:include:- /var/log/nginx/access.logoperators:- type: json_parser- type: movefrom: attributes.request_methodto: attributes["http.request.method"]- type: movefrom: attributes.request_urito: attributes["url.path"]- type: movefrom: attributes.server_protocolto: attributes["network.protocol.version"]- type: movefrom: attributes.statusto: attributes["http.response.status_code"]- type: movefrom: attributes.request_lengthto: attributes["http.request.body.size"]- type: movefrom: attributes.body_bytes_sentto: attributes["http.response.body.size"]- type: movefrom: attributes.request_timeto: attributes["http.request.duration"]- type: movefrom: attributes.upstream_addrto: attributes["server.address"]- type: movefrom: attributes.upstream_cache_statusto: attributes["http.cache_status"]- type: movefrom: attributes.remote_addrto: attributes["client.address"]- type: movefrom: attributes.trace_idto: attributes["trace_id"]- type: addfield: attributes["service.name"]value: "openresty"- type: addfield: attributes["log.source"]value: "nginx.access"filelog/error:include:- /var/log/nginx/error.logoperators:- type: regex_parserregex: '^(?P<timestamp>\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}) \[(?P<level>\w+)\] (?P<pid>\d+)#(?P<tid>\d+): (?P<message>.*)'timestamp:parse_from: attributes.timestamplayout: "%Y/%m/%d %H:%M:%S"layout_type: strptime- type: severity_parserparse_from: attributes.levelmapping:debug: debuginfo: infonotice: info2warn: warnerror: errorcrit: fatalalert: fatalemerg: fatal- type: addfield: attributes["service.name"]value: "openresty"- type: addfield: attributes["log.source"]value: "nginx.error"processors:memory_limiter:check_interval: 5slimit_percentage: 85spike_limit_percentage: 15batch:timeout: 5ssend_batch_size: 100000resource:attributes:- key: service.namevalue: openrestyaction: upsertexporters:otlp/last9:endpoint: ${env:LAST9_OTLP_ENDPOINT}headers:Authorization: ${env:LAST9_OTLP_AUTH}service:pipelines:traces:receivers: [otlp]processors: [memory_limiter, batch]exporters: [otlp/last9]metrics:receivers: [prometheus/lua, nginx]processors: [memory_limiter, resource, batch]exporters: [otlp/last9]logs:receivers: [filelog/access, filelog/error]processors: [memory_limiter, resource, batch]exporters: [otlp/last9] -
Set environment variables
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318export OTEL_SERVICE_NAME=openrestyexport OTEL_SERVICE_VERSION=1.0.0export DEPLOYMENT_ENVIRONMENT=production# Sampling strategy (default follows incoming traceparent; new roots always sample)export OTEL_TRACES_SAMPLER=parentbased_always_on# export OTEL_TRACES_SAMPLER_ARG=0.1 # for traceid_ratio / parentbased_traceid_ratio -
Reload OpenResty
nginx -t && nginx -s reload
Verification
# Send test requests including error scenarioscurl http://localhost/getcurl http://localhost/status/404curl http://localhost/status/500
# Confirm Lua metrics endpointcurl http://localhost:9145/metrics | grep openresty_http
# Confirm nginx stub_statuscurl http://localhost:9145/nginx_status
# Collector healthcurl http://localhost:13133Traces appear in Last9 within seconds. Each request produces a SERVER span and one CLIENT child span per upstream attempt — retried requests show each attempt as a separate span.
Environment Variables
| Variable | Default | Description |
|---|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT | http://localhost:4318 | OTel Collector OTLP HTTP endpoint |
OTEL_SERVICE_NAME | openresty | service.name resource attribute |
OTEL_SERVICE_VERSION | 1.0.0 | service.version resource attribute |
DEPLOYMENT_ENVIRONMENT | production | deployment.environment resource attribute |
OTEL_TRACES_SAMPLER | parentbased_always_on | Sampling strategy — see table below |
OTEL_TRACES_SAMPLER_ARG | 1.0 | Ratio for traceid_ratio / parentbased_traceid_ratio |
OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT | 256 | Max characters for string span attributes |
Sampler options:
| Value | Behaviour |
|---|---|
parentbased_always_on | Respect parent sampled flag; new roots always sample (default) |
parentbased_traceid_ratio | Respect parent sampled flag; new roots use ratio |
always_on | Sample every request |
always_off | Drop all traces |
traceid_ratio | Probabilistic sampling, ignores parent flag |
Troubleshooting
No traces in Last9
- Confirm the collector is running:
curl http://localhost:13133 - Check for Lua errors:
tail -f /var/log/nginx/error.log | grep '\[otel\]' - Verify
OTEL_EXPORTER_OTLP_ENDPOINTpoints to the collector, not Last9 directly - Ensure
resolverdirective is set innginx.conf— without it, hostname resolution fails inside timer callbacks
no resolver defined error in logs
- Add
resolver 127.0.0.11 valid=30s ipv6=off;to thehttp {}block innginx.conf proxy_passresolves DNS at config load time;resty.httpinsidengx.timer.atrequires a runtime resolver
No Lua metrics
- Verify
lua_shared_dict prometheus_metrics 10mis in thehttp {}block - Test:
curl http://localhost:9145/metrics | grep openresty_http - Check
opm listshowsknyar/nginx-lua-prometheusis installed
No upstream child spans
- CLIENT spans are only created when
$upstream_response_timeis populated — directreturnstatements andcontent_by_lualocations produce only the SERVER span
Docker Compose example
services: openresty: build: . ports: - "80:80" - "9145:9145" environment: - OTEL_SERVICE_NAME=openresty - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 - DEPLOYMENT_ENVIRONMENT=production - OTEL_TRACES_SAMPLER=parentbased_always_on volumes: - nginx_logs:/var/log/nginx depends_on: - otel-collector
otel-collector: image: otel/opentelemetry-collector-contrib:0.144.0 command: ["--config=/etc/otel-collector-config.yaml"] environment: - LAST9_OTLP_ENDPOINT=${LAST9_OTLP_ENDPOINT} - LAST9_OTLP_AUTH=${LAST9_OTLP_AUTH} volumes: - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml:ro - nginx_logs:/var/log/nginx:ro ports: - "4318:4318"
volumes: nginx_logs: