Skip to main content

Documentation Index

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

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

Caddy writes structured JSON logs by default. Vector parses each line with parse_json, maps Caddy’s level field to OTel severity, and ships the result to Rootprint’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. Two setup paths are documented below. Bare-metal runs Vector as a systemd service that tails /var/log/caddy/*.log — pick this if Caddy is installed via your distro’s package manager. Docker runs Vector as a sidecar container that reads Caddy’s stdout via the Docker socket — pick this if Caddy runs as a container.
Bare-metal paths and systemctl invocations below assume Debian or Ubuntu. The Docker setup assumes Docker Engine + Docker Compose. Other runtimes (Kubernetes, Podman) are not covered.

Setup (bare-metal)

1

Pick the target index and create an ingest API key

The default index for OTLP traffic is otel-logs-v0_9 — see Indexes for its schema. In Settings → API keys, click Create API key, give it a name, pick otel-logs-v0_9, and choose the Ingest role. The value is shown once — copy it before clicking Done. API keys are scoped to one index — you cannot reuse one 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-rootprint> with your Rootprint base URL and <your-ingest-token> with the API key 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)
      }

      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 {
            proto_name = downcase(string!(parts[0]))
            proto_version = string!(parts[1])
            attrs = push(attrs, { "key": "network.protocol.name",    "value": { "stringValue": proto_name } })
            attrs = push(attrs, { "key": "network.protocol.version", "value": { "stringValue": proto_version } })
          }
        }
        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:
  rootprint:
    type: opentelemetry
    inputs: [to_otlp]
    protocol:
      type: http
      uri: https://<your-rootprint>/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 Rootprint

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

Setup (Docker)

1

Pick the target index and create an ingest API key

Same as the bare-metal setup — see step 1 above. The API key works identically across both paths.
2

Enable access logs if needed

If docker logs caddy already shows the request/access logs you want to ship, skip this step. If you only see startup logs and runtime errors, add the following log block to each site whose access logs you want shipped. Leave the default JSON encoder in place.
example.com {
    log {
        output stdout
    }
    # ... rest of the site config
}
If you change the Caddyfile, restart the Caddy container in step 4 below.
3

Add the Vector sidecar to your compose file

Drop this service alongside your existing ones. Vector reads every container’s logs through the Docker socket — no changes needed to your application services.
services:
  rootprint-vector:
    image: timberio/vector:0.54.0-alpine
    container_name: rootprint-vector
    restart: unless-stopped
    volumes:
      - ./vector.yaml:/etc/vector/vector.yaml:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
The include_containers value in vector.yaml (next step) is set to caddy. Either set container_name: caddy on your Caddy compose service, or update include_containers to match the actual container name shown by docker ps --format '{{.Names}}'.
4

Save the Vector config

Save the following as vector.yaml next to your compose.yml. Replace <your-rootprint> with your Rootprint base URL and <your-ingest-token> with the API key you copied in step 1.
sources:
  caddy:
    type: docker_logs
    include_containers:
      - caddy

transforms:
  parse_caddy:
    type: remap
    inputs: [caddy]
    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) ?? ""

      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)
      }

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

      attrs = [
        { "key": "container.runtime", "value": { "stringValue": "docker" } }
      ]
      if exists(.container_id) {
        attrs = push(attrs, { "key": "container.id", "value": { "stringValue": string!(.container_id) } })
      }
      if exists(.container_name) {
        cname = replace(string!(.container_name), r'^/', "")
        attrs = push(attrs, { "key": "container.name", "value": { "stringValue": cname } })
      }
      if exists(.image) {
        attrs = push(attrs, { "key": "container.image.name", "value": { "stringValue": string!(.image) } })
      }
      if exists(.stream) {
        attrs = push(attrs, { "key": "log.iostream", "value": { "stringValue": string!(.stream) } })
      }
      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 {
            proto_name = downcase(string!(parts[0]))
            proto_version = string!(parts[1])
            attrs = push(attrs, { "key": "network.protocol.name",    "value": { "stringValue": proto_name } })
            attrs = push(attrs, { "key": "network.protocol.version", "value": { "stringValue": proto_version } })
          }
        }
        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:
  rootprint:
    type: opentelemetry
    inputs: [to_otlp]
    protocol:
      type: http
      uri: https://<your-rootprint>/v1/logs
      method: post
      encoding:
        codec: otlp
      compression: gzip
      request:
        headers:
          Authorization: "Bearer <your-ingest-token>"
      batch:
        timeout_secs: 1
        max_bytes: 8388608
5

Start Vector and restart Caddy if needed

Bring the Vector sidecar up first so it’s already streaming before Caddy restarts on the new logging config.
docker compose up -d rootprint-vector

# If you changed the Caddyfile in step 1, also restart Caddy:
docker compose restart <your-caddy-service-name>
Replace <your-caddy-service-name> with the Compose service key for Caddy. That’s the name under services:, which can differ from container_name. If you skipped step 1 because access logs are already enabled, you only need to start Vector here.
6

Send a test request

curl -i http://localhost/
The command above assumes your Caddy container publishes port 80 on the host. Substitute the port you’ve mapped, or run curl from inside the compose network.
7

Verify in Rootprint

Open Search, pick otel-logs-v0_9 from the index selector, and query service.name:caddy. Records typically appear within 5–10 seconds. attributes.container.name reads whatever Docker reports for your Caddy container. If you set container_name: caddy, it reads caddy. attributes.container.image.name reads whichever Caddy image you’re running.

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 Rootprint’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. The Docker setup additionally attaches Vector’s docker_logs metadata as container.id, container.name, container.image.name, container.runtime: "docker", and log.iostream (stdout/stderr) — log.file.path is omitted because the source is the Docker daemon, not a file on disk.
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
container.id, container.name, container.image.name, container.runtime, log.iostreamVector’s docker_logs source (Docker setup only)
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 Rootprint 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
Filter to one Caddy container by name — handy when multiple Caddy instances ship to the same index from different hosts. Replace <your-caddy-container-name> with the actual name from docker ps --format '{{.Names}}':
service.name:caddy AND attributes.container.name:<your-caddy-container-name>
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, 415 from Rootprint — same response codes as a misconfigured Vector setup of any kind. See Send logs with Vector for full diagnoses.
  • Docker setup ships zero logsinclude_containers does not match the Caddy container’s actual name. Run docker ps --format '{{.Names}}' to see the real names. Either set container_name: caddy on your Caddy compose service or update include_containers in vector.yaml.
  • Vector container exits with permission denied on /var/run/docker.sock — rootless Docker or SELinux. Either run Docker rootful, add :z to the socket mount on SELinux hosts, or grant the Vector image’s user access to the docker group inside the image.
  • host.name always reads the Vector container ID — by default get_hostname!() returns the container’s hostname, which Docker sets to the container ID. Set hostname: <your-host> on the rootprint-vector compose service to override.
  • 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.