> ## 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.

# Send Caddy logs

> Ship Caddy's JSON access and error logs to Rootprint over OTLP with the OpenTelemetry Collector or Vector, with access fields mapped to OpenTelemetry HTTP semantic conventions.

[Caddy](https://caddyserver.com) writes structured JSON logs by default. This page ships them to Rootprint's OTLP endpoint with either the **OpenTelemetry Collector** or **Vector**. Both land records 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.

## Prerequisites

* A running Rootprint instance and its base URL — you'll substitute it for `<your-rootprint>`.
* A host running Caddy that writes its logs to files. Stock Caddy logs to `journald`; this page enables file logging below.
* A log agent that can read those files — the [OpenTelemetry Collector](/send-logs/log-agents/otel-collector) or [Vector](/send-logs/log-agents/vector).
* An **ingest API key** scoped to your target index. See [API keys](/api/overview).

<Tabs>
  <Tab title="OpenTelemetry Collector">
    <Note>
      This config assumes the agent runs where it can read `/var/log/caddy`.
    </Note>

    <Steps>
      <Step title="Enable Caddy file logging">
        Stock Caddy logs to `journald`. Add a `log` block per site so Caddy writes JSON to file, then `sudo systemctl reload caddy`. Keep Caddy's default JSON encoder; do not add a `format` directive.

        ```caddyfile theme={"theme":"github-light"}
        # 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
        }
        ```

        ```bash theme={"theme":"github-light"}
        sudo systemctl reload caddy
        ```
      </Step>

      <Step title="Configure the Collector">
        Replace `<your-rootprint>` with your Rootprint base URL and `<your-ingest-token>` with the key from above.

        <Expandable title="config.yaml">
          ```yaml theme={"theme":"github-light"}
          receivers:
            filelog:
              include:
                - /var/log/caddy/access.log
                - /var/log/caddy/error.log
              start_at: end
              operators:
                - type: json_parser
                  parse_to: body
                  timestamp:
                    parse_from: body.ts
                    layout_type: epoch
                    layout: s
                  severity:
                    parse_from: body.level
                    mapping:
                      debug: debug
                      info: info
                      warn: warn
                      error: error
                      fatal:
                        - panic
                        - fatal

          processors:
            resource:
              attributes:
                - key: service.name
                  value: caddy
                  action: upsert
            transform:
              log_statements:
                - context: log
                  statements:
                    - set(attributes["caddy.logger"], body["logger"]) where body["logger"] != nil
                    - set(attributes["http.request.method"], body["request"]["method"]) where body["request"]["method"] != nil
                    - set(attributes["url.path"], body["request"]["uri"]) where body["request"]["uri"] != nil
                    - set(attributes["server.address"], body["request"]["host"]) where body["request"]["host"] != nil
                    - set(attributes["client.address"], body["request"]["remote_ip"]) where body["request"]["remote_ip"] != nil
                    - set(attributes["http.response.status_code"], Int(body["status"])) where body["status"] != nil
                    - set(attributes["http.response.body.size"], Int(body["size"])) where body["size"] != nil
                    - set(attributes["client.port"], Int(body["request"]["remote_port"])) where body["request"]["remote_port"] != nil
                    - set(attributes["network.protocol.name"], ConvertCase(Split(body["request"]["proto"], "/")[0], "lower")) where body["request"]["proto"] != nil
                    - set(attributes["network.protocol.version"], Split(body["request"]["proto"], "/")[1]) where body["request"]["proto"] != nil
                    - set(attributes["user_agent.original"], body["request"]["headers"]["User-Agent"][0]) where body["request"]["headers"]["User-Agent"] != nil
                    - set(body, body["msg"]) where body["msg"] != nil
            batch: {}

          exporters:
            otlphttp:
              logs_endpoint: https://<your-rootprint>/v1/logs
              compression: gzip
              headers:
                Authorization: "Bearer <your-ingest-token>"

          service:
            pipelines:
              logs:
                receivers: [filelog]
                processors: [resource, transform, batch]
                exporters: [otlphttp]
          ```
        </Expandable>
      </Step>

      <Step title="Grant read access to /var/log/caddy">
        ```bash theme={"theme":"github-light"}
        sudo usermod -aG caddy otelcol-contrib
        ```

        Add the user the Collector runs as to the `caddy` group that owns the logs (the Debian/RPM Collector package creates `otelcol-contrib`).
      </Step>

      <Step title="Start the Collector">
        Start (or restart) the Collector so it loads the config. Startup logs should show no config-parse or exporter errors.
      </Step>

      <Step title="Send a test request">
        ```bash theme={"theme":"github-light"}
        curl -i http://localhost/
        ```
      </Step>

      <Step title="Verify in Rootprint">
        Open Search, pick `otel-logs-v0_9`, and query `service_name:caddy`. Records typically appear within 5–10 seconds.
      </Step>
    </Steps>
  </Tab>

  <Tab title="Vector">
    <Note>
      This config assumes the agent runs where it can read `/var/log/caddy`.
    </Note>

    <Steps>
      <Step title="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.

        ```caddyfile theme={"theme":"github-light"}
        # 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
        }
        ```

        ```bash theme={"theme":"github-light"}
        sudo systemctl reload caddy
        ```
      </Step>

      <Step title="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 above.

        <Expandable title="vector.yaml">
          ```yaml theme={"theme":"github-light"}
          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
          ```
        </Expandable>

        `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.
      </Step>

      <Step title="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:

        ```bash theme={"theme":"github-light"}
        sudo usermod -aG caddy vector
        ```
      </Step>

      <Step title="Restart Vector">
        ```bash theme={"theme":"github-light"}
        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.
      </Step>

      <Step title="Send a test request">
        Hit any site Caddy is serving so it writes a fresh access-log line:

        ```bash theme={"theme":"github-light"}
        curl -i http://localhost/
        ```

        Replace `http://localhost/` with whichever site URL you configured the `log` block on.
      </Step>

      <Step title="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.
      </Step>
    </Steps>
  </Tab>
</Tabs>

## What you get in Rootprint

Two `remap` transforms (Vector) or OTTL `transform` statements (OTEL Collector) map Caddy's JSON fields to OpenTelemetry semantic conventions. Both agents produce the same attributes in `otel-logs-v0_9`.

`service.name` is hard-coded to `caddy` (resource attribute). The `if exists(.request)` / `where body["request"] != nil` guard makes one config handle both access and error logs cleanly: error-log entries (no `.request` block) get only `log.file.path`/`caddy.logger`, while access-log entries get the full HTTP attribute set.

| OTLP key                    | Source 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.name`     | first half of `.request.proto` split on `/`           |
| `network.protocol.version`  | second half of `.request.proto`                       |
| `user_agent.original`       | first 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.path`             | file path (Vector file source only)                   |

`timeUnixNano` is derived from Caddy's `.ts` (float seconds since epoch); if the field is missing or unparseable, the agent's read time is used as a fallback.

## Useful searches

Run these in the Rootprint search box against `otel-logs-v0_9`.

Every 5xx response Caddy returned, across all sites:

```text theme={"theme":"github-light"}
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:

```text theme={"theme":"github-light"}
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:

```text theme={"theme":"github-light"}
attributes.client.address:1.2.3.4
```

Failed POSTs only:

```text theme={"theme":"github-light"}
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):

```text theme={"theme":"github-light"}
service_name:caddy AND attributes.caddy.logger:http.log.access
```

## Troubleshooting

* **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 config 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; both agents trust the source. Filter on `attributes.http.response.status_code:>=500` to surface server errors.
* **Ingest transport errors (`401`, `403`, `413`, `415`)**: see [OTLP reference → Response codes](/send-logs/otlp#response-codes).

## Related

* [Send Nginx logs](/send-logs/web-servers/nginx): sibling page for Nginx.
* [Send logs with Vector](/send-logs/log-agents/vector): generic Vector setup for tailing files on a host.
* [OTLP reference](/send-logs/otlp): endpoint URL, response codes, body limits.
* [Indexes](/indexes): the `otel-logs-v0_9` schema, so you know what you can search.
