Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.logwiz.io/llms.txt

Use this file to discover all available pages before exploring further.

Caddy writes structured JSON logs by default. This page sets up Vector as a systemd service that tails /var/log/caddy/access.log and /var/log/caddy/error.log, parses each line with parse_json, maps Caddy’s level field to OTel severity, and ships the result to Logwiz’s OTLP endpoint with a Bearer token. Records land in the otel-logs-v0_9 index with service.name: caddy and OpenTelemetry HTTP attributes (http.request.method, url.path, http.response.status_code, client.address, user_agent.original, …) pre-populated.
Paths and systemctl invocations below assume Debian or Ubuntu. Caddy itself runs on every major platform, but this page does not cover macOS or Windows.

Setup

1

Pick the target index and create an ingest token

The default index for OTLP traffic is otel-logs-v0_9 — see Indexes for its schema. In Administration → Tokens, click Create Token, pick otel-logs-v0_9, and copy the token value from the new row. Tokens are scoped to one index — you cannot reuse a token across indexes.
2

Enable Caddy file logging

Stock Caddy installs log to journald. Add the following to /etc/caddy/Caddyfile so Caddy writes JSON to file instead, then reload Caddy. The log block has to be added to every site whose access log you want shipped — Caddy’s default JSON encoder is what the parser expects, so do not add a format directive.
# Global block — Caddy runtime errors
{
    log default {
        output file /var/log/caddy/error.log
    }
}

# Per-site — JSON access log
example.com {
    log {
        output file /var/log/caddy/access.log
    }
    # ... rest of the site config
}
sudo systemctl reload caddy
3

Install Vector

Install the Vector package for your platform from the official installation page. Per-distro instructions (Debian/Ubuntu apt, RHEL/Fedora dnf, container images) are maintained upstream.
4

Write the Vector config

Save the following at /etc/vector/vector.yaml. Replace <your-logwiz> with your Logwiz base URL and <your-ingest-token> with the token you copied in step 1.
sources:
  caddy_logs:
    type: file
    include:
      - /var/log/caddy/access.log
      - /var/log/caddy/error.log
    read_from: end

transforms:
  parse_caddy:
    type: remap
    inputs: [caddy_logs]
    source: |
      parsed, err = parse_json(.message)
      if err == null && is_object(parsed) {
        . = merge!(., object!(parsed))
      }

      level = downcase(string(.level) ?? "info")
      if level == "debug" {
        .severity_number = 5
        .severity_text   = "DEBUG"
      } else if level == "info" {
        .severity_number = 9
        .severity_text   = "INFO"
      } else if level == "warn" {
        .severity_number = 13
        .severity_text   = "WARN"
      } else if level == "error" {
        .severity_number = 17
        .severity_text   = "ERROR"
      } else if level == "panic" || level == "fatal" {
        .severity_number = 21
        .severity_text   = "FATAL"
      } else {
        .severity_number = 9
        .severity_text   = "INFO"
      }

  to_otlp:
    type: remap
    inputs: [parse_caddy]
    source: |
      msg       = string(.msg) ?? string(.message) ?? ""
      file_path = string(.file) ?? ""

      ts_nano = to_unix_timestamp!(now(), unit: "nanoseconds")
      ts_float, ts_err = to_float(.ts)
      if ts_err == null {
        ts_nano = to_int(ts_float * 1000000000.0) ?? ts_nano
      }

      sev_num  = to_int(.severity_number) ?? 9
      sev_text = string(.severity_text)   ?? "INFO"

      attrs = [
        { "key": "log.file.path", "value": { "stringValue": file_path } }
      ]
      if exists(.logger) {
        attrs = push(attrs, { "key": "caddy.logger", "value": { "stringValue": string!(.logger) } })
      }

      if exists(.request) {
        if exists(.request.method) {
          attrs = push(attrs, { "key": "http.request.method", "value": { "stringValue": string!(.request.method) } })
        }
        if exists(.request.uri) {
          attrs = push(attrs, { "key": "url.path", "value": { "stringValue": string!(.request.uri) } })
        }
        if exists(.request.host) {
          attrs = push(attrs, { "key": "server.address", "value": { "stringValue": string!(.request.host) } })
        }
        if exists(.request.remote_ip) {
          attrs = push(attrs, { "key": "client.address", "value": { "stringValue": string!(.request.remote_ip) } })
        }
        if exists(.request.remote_port) {
          port_int, port_err = to_int(.request.remote_port)
          if port_err == null {
            attrs = push(attrs, { "key": "client.port", "value": { "intValue": port_int } })
          }
        }
        if exists(.request.proto) {
          parts = split(string!(.request.proto), "/")
          if length(parts) == 2 {
            attrs = push(attrs, { "key": "network.protocol.name",    "value": { "stringValue": downcase(parts[0]) } })
            attrs = push(attrs, { "key": "network.protocol.version", "value": { "stringValue": parts[1] } })
          }
        }
        ua = .request.headers."User-Agent"
        if is_array(ua) && length!(ua) > 0 {
          attrs = push(attrs, { "key": "user_agent.original", "value": { "stringValue": string!(ua[0]) } })
        }
        if exists(.status) {
          status_int, status_err = to_int(.status)
          if status_err == null {
            attrs = push(attrs, { "key": "http.response.status_code", "value": { "intValue": status_int } })
          }
        }
        if exists(.size) {
          size_int, size_err = to_int(.size)
          if size_err == null {
            attrs = push(attrs, { "key": "http.response.body.size", "value": { "intValue": size_int } })
          }
        }
      }

      . = {
        "resourceLogs": [{
          "resource": {
            "attributes": [
              { "key": "service.name", "value": { "stringValue": "caddy" } },
              { "key": "host.name",    "value": { "stringValue": get_hostname!() } }
            ]
          },
          "scopeLogs": [{
            "scope": { "name": "vector", "version": "" },
            "logRecords": [{
              "timeUnixNano":         ts_nano,
              "observedTimeUnixNano": to_unix_timestamp!(now(), unit: "nanoseconds"),
              "severityNumber":       sev_num,
              "severityText":         sev_text,
              "body":                 { "stringValue": msg },
              "attributes":           attrs,
              "traceId":              "",
              "spanId":               "",
              "flags":                0,
              "droppedAttributesCount": 0
            }]
          }]
        }]
      }

sinks:
  logwiz:
    type: opentelemetry
    inputs: [to_otlp]
    protocol:
      type: http
      uri: https://<your-logwiz>/api/otlp/v1/logs
      method: post
      encoding:
        codec: otlp
      compression: gzip
      request:
        headers:
          Authorization: "Bearer <your-ingest-token>"
      batch:
        timeout_secs: 1
        max_bytes: 8388608
read_from: end skips existing content on first start, so installing Vector against an existing access.log does not replay every historical request. Flip it to beginning if you want a one-time backfill.
5

Grant Vector read access to /var/log/caddy

The default Caddy package creates /var/log/caddy/ owned by caddy:caddy with mode 750, so Vector’s vector user cannot read it. Add vector to the caddy group:
sudo usermod -aG caddy vector
6

Restart Vector

sudo systemctl restart vector
sudo systemctl status vector
The status output should show active (running) and the most recent log lines should not contain config-parse or sink-startup errors.
7

Send a test request

Hit any site Caddy is serving so it writes a fresh access-log line:
curl -i http://localhost/
Replace http://localhost/ with whichever site URL you configured the log block on.
8

Verify in Logwiz

Open Search, pick otel-logs-v0_9 from the index selector, and query service.name:caddy. Records typically appear within 5–10 seconds.

What the parsing does

Two remap transforms run in sequence. The first, parse_caddy, turns each raw JSON line into structured fields and assigns severity. The second, to_otlp, packs those fields into the OTLP wire format that Logwiz’s ingest endpoint expects. parse_caddy:
  • JSON parseparse_json(.message) returns Caddy’s structured object; merge! lifts every key (ts, level, logger, msg, request, status, size, …) onto the event. If parsing fails (transitional config, non-JSON output), the line falls through with the raw .message intact rather than being dropped.
  • Severity — Caddy’s level field maps directly to OTel severity: debug → 5/DEBUG, info → 9/INFO, warn → 13/WARN, error → 17/ERROR, panic/fatal → 21/FATAL. No status-code override; a 5xx access-log entry stays at severity_text: INFO because Caddy logs every access at info level. Filter on attributes.http.response.status_code for triage.
to_otlp assembles the OTLP envelope. service.name is hard-coded to caddy (resource attribute). The if exists(.request) branch is what makes one VRL block handle both access and error logs cleanly: error-log entries (no .request block) get only log.file.path and caddy.logger, while access-log entries get the full HTTP semantic-convention attribute set.
OTLP keySource field
http.request.method.request.method
url.path.request.uri
server.address.request.host
client.address.request.remote_ip
client.port.request.remote_port (parsed to int)
network.protocol.namefirst half of .request.proto split on /
network.protocol.versionsecond half of .request.proto
user_agent.originalfirst element of .request.headers."User-Agent"
http.response.status_code.status
http.response.body.size.size
caddy.logger.logger (e.g., http.log.access, http.log.error)
log.file.pathVector’s .file source attribute
timeUnixNano is derived from Caddy’s .ts (float seconds since epoch); if the field is missing or unparseable, Vector’s read time is used as a fallback. The final . = { resourceLogs: [...] } reassignment replaces the event entirely, so the OTLP sink sees only the envelope.

Useful searches

Run these in the Logwiz search box against otel-logs-v0_9. Every 5xx response Caddy returned, across all sites:
service.name:caddy AND attributes.http.response.status_code:>=500
All 4xx responses for one URL prefix — useful for spotting bad clients hammering one endpoint:
service.name:caddy AND attributes.url.path:/api/* AND attributes.http.response.status_code:>=400
Activity from a single source IP — pivot from a suspect address back to everything it touched:
attributes.client.address:1.2.3.4
Failed POSTs only:
service.name:caddy AND attributes.http.request.method:POST AND attributes.http.response.status_code:>=400
Access-log entries only (skip Caddy’s runtime/error logger):
service.name:caddy AND attributes.caddy.logger:http.log.access

Troubleshooting

  • permission denied reading /var/log/caddy/access.log — Vector’s vector user is not in the caddy group. Run sudo usermod -aG caddy vector and restart Vector. If that’s not available, sudo chmod a+r /var/log/caddy/*.log works but loosens permissions for every user on the host.
  • Records arrive but attributes.http.* are missing — the entry came from error.log (no .request block), by design. Filter access-log entries with attributes.caddy.logger:http.log.access.
  • body looks like raw JSON instead of just the message — the access log is using a non-default format directive in the Caddyfile. The parser expects Caddy’s default JSON encoder. Either remove the format directive or extend the parse_caddy remap to handle the alternate shape.
  • severity_text is always INFO even for 5xx responses — by design. Caddy emits every HTTP access at level: info regardless of status code; this parser trusts the source. Filter on attributes.http.response.status_code:>=500 to surface server errors.
  • 401, 403, 413, 415 from Logwiz — same response codes as a misconfigured Vector setup of any kind. See Send logs with Vector for full diagnoses.
  • Send logs with Vector — generic Vector setup for tailing files on a host.
  • OTLP reference — endpoint URL, response codes, body limits.
  • Indexes — the otel-logs-v0_9 schema, so you know what you can search.