LinkMesh

Search docs, blog and changelog

ENDE

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
Components
filelogbatchmemory_limitertransformelasticsearch
Destination
Elasticsearch

OpenTelemetry Collector

OpenTelemetry Collector YAML
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:

Custom source body (filelog)
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

  1. 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
  2. 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
  3. 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).