Snippets · Pipelines
nginx access logs to Elasticsearch, parsed into semantic conventions
The full path from an nginx access log to an Elasticsearch index: the filelog receiver tails the log and remembers its position, the regex parser splits the combined format and takes the timestamp from the line, a transform maps the fields to OpenTelemetry semantic conventions with integer status codes and sizes, and the elasticsearch exporter writes it with the OpenTelemetry-native mapping. The before/after below is the collector's own output for real nginx lines.
- Signals
- Logs
- Category
- CollectionParsing
- Source
- nginx access log
- Destination
- Elasticsearch
Included steps
OpenTelemetry Collector
extensions:
file_storage:
directory: /var/lib/otelcol/storage
create_directory: true
receivers:
filelog:
include: [/var/log/nginx/access.log]
start_at: end
storage: file_storage
operators:
# Combined log format: client, user, time, request line, status, bytes,
# referer, user agent. The request line is split into method, path,
# query and protocol version.
- type: regex_parser
regex: '^(?P<client_address>\S+) \S+ (?P<user_name>\S+) \[(?P<time>[^\]]+)\] "(?P<method>[A-Z]+) (?P<path>[^ ?"]*)(?:\?(?P<query>[^ "]*))? HTTP/(?P<protocol_version>[0-9.]+)" (?P<status>\d{3}) (?P<size>\d+|-) "(?P<referer>[^"]*)" "(?P<user_agent>[^"]*)"'
timestamp:
parse_from: attributes.time
layout: '%d/%b/%Y:%H:%M:%S %z'
on_error: send
processors:
memory_limiter:
check_interval: 1s
limit_percentage: 80
spike_limit_percentage: 25
transform/semconv:
error_mode: ignore
log_statements:
- context: log
# Only lines the regex parsed: an unparsed line keeps its body and
# gets none of these attributes.
conditions:
- attributes["method"] != nil
statements:
- set(attributes["client.address"], attributes["client_address"])
- set(attributes["user.name"], attributes["user_name"]) where attributes["user_name"] != "-"
- set(attributes["http.request.method"], attributes["method"])
- set(attributes["url.path"], attributes["path"])
- set(attributes["url.query"], attributes["query"]) where attributes["query"] != nil and attributes["query"] != ""
- set(attributes["network.protocol.name"], "http")
- set(attributes["network.protocol.version"], attributes["protocol_version"])
- set(attributes["http.response.status_code"], Int(attributes["status"]))
- set(attributes["http.response.body.size"], Int(attributes["size"])) where attributes["size"] != "-"
- set(attributes["http.request.header.referer"], [attributes["referer"]]) where attributes["referer"] != "-"
- set(attributes["user_agent.original"], attributes["user_agent"])
- delete_matching_keys(attributes, "^(client_address|user_name|time|method|path|query|protocol_version|status|size|referer|user_agent)$")
batch:
timeout: 5s
exporters:
elasticsearch:
endpoints: [https://elasticsearch.example.internal:9200]
api_key: ${env:ELASTICSEARCH_API_KEY}
mapping:
mode: otel
logs_index: logs-nginx-access
sending_queue:
enabled: true
queue_size: 5000
retry_on_failure:
enabled: true
initial_interval: 5s
max_interval: 30s
max_elapsed_time: 300s
service:
extensions: [file_storage]
pipelines:
logs:
receivers: [filelog]
processors: [memory_limiter, transform/semconv, batch]
exporters: [elasticsearch]
LinkMesh
LinkMesh has no built-in template for this job. Add it as a custom source in raw mode, with filelog as the component type and this body - the same lines as under filelog in the collector config:
include: [/var/log/nginx/access.log]
start_at: end
storage: file_storage
operators:
- type: regex_parser
regex: '^(?P<client_address>\S+) \S+ (?P<user_name>\S+) \[(?P<time>[^\]]+)\] "(?P<method>[A-Z]+) (?P<path>[^ ?"]*)(?:\?(?P<query>[^ "]*))? HTTP/(?P<protocol_version>[0-9.]+)" (?P<status>\d{3}) (?P<size>\d+|-) "(?P<referer>[^"]*)" "(?P<user_agent>[^"]*)"'
timestamp:
parse_from: attributes.time
layout: '%d/%b/%Y:%H:%M:%S %z'
on_error: send
How custom components are validated and rolled out: create a custom source.
Download config.yamlValidated with otelcol-contrib v0.161.0 on 2026-10-26
Complete config files, each checked with the collector's own validate command (otelcol-contrib v0.161.0).
Before and after
Example telemetry in, and what this exact config sent out when we ran it in otelcol-contrib v0.161.0. CI re-runs it on every change and fails if the output differs. Changed values are highlighted.
3 log records in, 3 log records out
Before
- body
- 203.0.113.7 - - [22/Oct/2026:10:15:02 +0200] "GET /api/orders?page=2 HTTP/1.1" 200 5320 "https://shop.example.ch/cart" "Mozilla/5.0 (X11; Linux x86_64) Firefox/131.0"
After
- body
- 203.0.113.7 - - [22/Oct/2026:10:15:02 +0200] "GET /api/orders?page=2 HTTP/1.1" 200 5320 "https://shop.example.ch/cart" "Mozilla/5.0 (X11; Linux x86_64) Firefox/131.0"
- timestamp
- 2026-10-22T08:15:02.000Z
- attributes.log.file.name
- input.log
- attributes.client.address
- 203.0.113.7
- attributes.http.request.method
- GET
- attributes.url.path
- /api/orders
- attributes.url.query
- page=2
- attributes.network.protocol.name
- http
- attributes.network.protocol.version
- 1.1
- attributes.http.response.status_code
- 200
- attributes.http.response.body.size
- 5320
- attributes.http.request.header.referer
- ["https://shop.example.ch/cart"]
- attributes.user_agent.original
- Mozilla/5.0 (X11; Linux x86_64) Firefox/131.0
Before
- body
- 198.51.100.23 - anna [22/Oct/2026:10:15:03 +0200] "POST /api/checkout HTTP/2.0" 502 0 "-" "curl/8.5.0"
After
- body
- 198.51.100.23 - anna [22/Oct/2026:10:15:03 +0200] "POST /api/checkout HTTP/2.0" 502 0 "-" "curl/8.5.0"
- timestamp
- 2026-10-22T08:15:03.000Z
- attributes.log.file.name
- input.log
- attributes.client.address
- 198.51.100.23
- attributes.user.name
- anna
- attributes.http.request.method
- POST
- attributes.url.path
- /api/checkout
- attributes.network.protocol.name
- http
- attributes.network.protocol.version
- 2.0
- attributes.http.response.status_code
- 502
- attributes.http.response.body.size
- 0
- attributes.user_agent.original
- curl/8.5.0
Before
- body
- 192.0.2.44 - - [22/Oct/2026:10:15:04 +0200] "\x16\x03\x01\x00\xA5" 400 157 "-" "-"
After
- body
- 192.0.2.44 - - [22/Oct/2026:10:15:04 +0200] "\x16\x03\x01\x00\xA5" 400 157 "-" "-"
- attributes.log.file.name
- input.log
The gotcha
A TLS handshake or scanner hitting the plain-HTTP port still lands in the access log and does not match the format. In the fixture below that line arrives unparsed, body intact, with none of the request fields set - the transform only runs on parsed lines. Without that guard every field would be written as null into the index, as we measured on the parse-nginx snippet.
Manage this across a fleet
LinkMesh manages collector configuration across a fleet from one self-hosted control plane, so a change like this is made once rather than host by host. The first 25 Collectors are free after a no-card registration in the OpenSight Customer Portal (5 without one).
Related snippets
- Parse Apache access logs into semantic conventionsTurn Apache combined-format access log lines into semantic-convention attributes, with typed status codes and sizes.
- Parse nginx access logs into semantic conventionsTurn nginx combined-format access log lines into OpenTelemetry semantic-convention attributes, with typed status codes.
- Parse IIS W3C access logs into semantic conventionsEvery IIS request as a structured log: method, path, query, status, sizes, time taken and user agent, with the request's own UTC timestamp.
- Parse JSON log lines into attributesTurn each JSON log line into attributes you can filter and group by, keeping the original line as the body.