> ## Documentation Index
> Fetch the complete documentation index at: https://docs.aiaxoniq.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Send data from Docker Compose

> Add a collector service to an existing Compose stack, reach it by service name from every other container, and keep the license key out of your application services.

If your application already runs under Docker Compose, adding telemetry is one
service and one config file. Compose gives you the piece that is fiddly by hand:
a stable DNS name every other service can export to.

<Info>
  **Before you start, you need two values.**

  1. **Your base endpoint** — shown on **Get Started** in the dashboard. These
     pages write it as `$OIQ_ENDPOINT`.
  2. **A license key** — created in **Settings → License Keys**, starting
     `oiq_`. Requires the Admin role. See
     [Create a license key](/get-started/license-keys). These pages write it as
     `$OIQ_LICENSE_KEY`.

  Export both before running anything below:

  ```bash theme={null}
  export OIQ_ENDPOINT="https://app.aiaxoniq.com/otlp"   # or your own
  export OIQ_LICENSE_KEY="oiq_..."
  ```
</Info>

You also need an existing `docker-compose.yml` and Docker Compose v2 — `docker
compose version` should succeed. Note the space; `docker-compose` v1 is
end-of-life and its `env_file` handling differs.

## Why a service rather than a sidecar per container

One collector serves the whole stack. Compose puts every service on a shared
network with DNS, so `http://collector:4318` resolves from any container without
publishing a single port to the host.

That matters more than the convenience: your application services never receive
the license key. Only the collector does. When you rotate the key, you change
one service definition instead of auditing every container's environment.

## 1. Write the config

Save this next to your `docker-compose.yml`.

```yaml config.yaml theme={null}
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318
  hostmetrics:
    collection_interval: 30s
    scrapers:
      cpu:
      memory:
      disk:
      filesystem:
      network:
      load:

processors:
  # Order matters and is not alphabetical. memory_limiter can only shed load it
  # sees before anything has buffered it, so it goes first; batch should group
  # records after every other processor has finished changing them, so it goes
  # last. A pipeline that batches first and limits afterwards will still run out
  # of memory under exactly the load the limiter was added for.
  memory_limiter:
    check_interval: 1s
    limit_percentage: 80
    spike_limit_percentage: 25
  resourcedetection:
    detectors: [env, system]
    system:
      hostname_sources: [os]
  batch:
    timeout: 5s
    send_batch_size: 1000

exporters:
  otlphttp/aiaxoniq:
    endpoint: ${env:OIQ_ENDPOINT}
    headers:
      X-License-Key: ${env:OIQ_LICENSE_KEY}
    compression: gzip

service:
  telemetry:
    metrics:
      readers:
        - pull:
            exporter:
              prometheus:
                host: 0.0.0.0
                port: 8888
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, resourcedetection, batch]
      exporters: [otlphttp/aiaxoniq]
    metrics:
      receivers: [otlp, hostmetrics]
      processors: [memory_limiter, resourcedetection, batch]
      exporters: [otlphttp/aiaxoniq]
    logs:
      receivers: [otlp]
      processors: [memory_limiter, resourcedetection, batch]
      exporters: [otlphttp/aiaxoniq]
```

## 2. Add the collector service

```yaml docker-compose.yml theme={null}
services:
  collector:
    image: otel/opentelemetry-collector-contrib:0.115.1
    container_name: aiaxoniq-collector
    restart: unless-stopped
    command: ["--config=/etc/otelcol-contrib/config.yaml"]
    volumes:
      - ./config.yaml:/etc/otelcol-contrib/config.yaml:ro
      # Real host metrics rather than the container's own filesystem.
      - /:/hostfs:ro
    environment:
      OIQ_ENDPOINT: ${OIQ_ENDPOINT:?set OIQ_ENDPOINT in .env}
      OIQ_LICENSE_KEY: ${OIQ_LICENSE_KEY:?set OIQ_LICENSE_KEY in .env}
    # No ports published. Every other service reaches it by name on the
    # Compose network, which is both simpler and not exposed to the host.

  checkout:
    image: your-image:tag
    depends_on:
      - collector
    environment:
      OTEL_EXPORTER_OTLP_ENDPOINT: "http://collector:4318"
      OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf"
      OTEL_SERVICE_NAME: "checkout"
      OTEL_RESOURCE_ATTRIBUTES: "deployment.environment=production"
```

<Note>
  **The `:?` in `${OIQ_ENDPOINT:?…}` is doing real work.** Without it, an unset
  variable becomes an empty string and Compose starts a collector that exports
  to nowhere, logging retries you have to go looking for. With it, `docker
      compose up` refuses to start and tells you which variable is missing.
</Note>

Add the config to `root_path` so the mounted host filesystem is used:

```yaml config.yaml theme={null}
receivers:
  hostmetrics:
    root_path: /hostfs
```

## 3. Provide the two values

Compose reads a `.env` file beside `docker-compose.yml` automatically:

```bash .env theme={null}
OIQ_ENDPOINT=https://app.aiaxoniq.com/otlp
OIQ_LICENSE_KEY=oiq_4f3c2b1a9e8d7c6b5a4f3e2d1c0b9a8e
```

<Warning>
  Add `.env` to `.gitignore`. A license key is an ingest credential for your
  whole organization, and it is not scoped to a service or an environment — so a
  key committed to a repository is a key that has to be revoked, not rotated at
  leisure.
</Warning>

## 4. Start it

```bash theme={null}
docker compose up -d collector
docker compose logs collector | tail -20
```

**Expected output** — the last line is the success signal:

```text theme={null}
info  service@v0.115.0/service.go:166   Setting up own telemetry...
info  telemetry/metrics.go:70           Serving metrics  {"address": "0.0.0.0:8888"}
info  service@v0.115.0/service.go:238   Starting otelcol-contrib...  {"Version": "0.115.0"}
info  otlpreceiver@v0.115.0/otlp.go:112 Starting GRPC server  {"endpoint": "0.0.0.0:4317"}
info  otlpreceiver@v0.115.0/otlp.go:169 Starting HTTP server  {"endpoint": "0.0.0.0:4318"}
info  service@v0.115.0/service.go:261   Everything is ready. Begin running and processing data.
```

Then bring up the rest of the stack:

```bash theme={null}
docker compose up -d
```

## Verify

The collector's metrics port is not published, so read it from inside the
Compose network rather than from your shell:

```bash theme={null}
docker compose exec collector \
  wget -qO- http://127.0.0.1:8888/metrics \
  | grep -E 'otelcol_(receiver_accepted|exporter_sent|exporter_send_failed)'
```

**Expected output:**

```text theme={null}
otelcol_receiver_accepted_spans{receiver="otlp"} 148
otelcol_exporter_sent_spans{exporter="otlphttp/aiaxoniq"} 148
otelcol_exporter_send_failed_spans{exporter="otlphttp/aiaxoniq"} 0
```

`send_failed` at zero with `sent` climbing is a working stack. Then open **Logs**
in the product, set the range to the last 15 minutes, and search for one of your
service names.

## Troubleshooting

<AccordionGroup>
  <Accordion title="'required variable OIQ_ENDPOINT is missing a value'" icon="circle-exclamation">
    Working as intended — the `:?` guard caught an unset variable before starting
    a collector that would have exported nowhere.

    Confirm `.env` sits **beside `docker-compose.yml`**, not beside `config.yaml`
    if those are different directories. Compose looks next to the Compose file,
    or wherever `--env-file` points.
  </Accordion>

  <Accordion title="Services cannot resolve 'collector'" icon="circle-nodes">
    Compose DNS uses the **service key**, not `container_name`. In the file
    above the service is `collector`, so the name is `http://collector:4318` even
    though the container is called `aiaxoniq-collector`.

    Services in different Compose projects are on different networks and cannot
    see each other without an explicit shared external network.
  </Accordion>

  <Accordion title="Application containers start before the collector is ready" icon="hourglass-start">
    `depends_on` waits for the container to start, not for it to be listening.
    In practice this is harmless: OpenTelemetry SDKs buffer and retry, so early
    spans are not lost — they arrive once the collector accepts connections.

    If early data genuinely matters, add a healthcheck and
    `depends_on: {collector: {condition: service_healthy}}`.
  </Accordion>
</AccordionGroup>

<AccordionGroup>
  <Accordion title="The collector starts, then logs 'Exporting failed. Will retry'" icon="arrows-rotate">
    Read the `error` field on that line — it names the cause exactly.

    ```text theme={null}
    Exporting failed. Will retry the request after interval.
      {"kind": "exporter", "name": "otlphttp/aiaxoniq",
       "error": "failed to make an HTTP request: Post \"…/v1/metrics\":
                 dial tcp: lookup … : no such host", "interval": "5.2s"}
    ```

    * `no such host` — the endpoint hostname does not resolve. Check
      `$OIQ_ENDPOINT` against the value on **Get Started**.
    * `connection refused` — the host resolves but nothing is listening on that
      port.
    * `401 Unauthorized` — the key is missing, malformed or revoked.
    * `404` — the endpoint already ends in `/v1/…`. It must be the **base** URL;
      the collector appends the signal path itself.

    The collector retries with backoff and does not drop data while it retries,
    so a transient failure here is not a loss.
  </Accordion>

  <Accordion title="Nothing at all in the logs after 'Everything is ready'" icon="ear-listen">
    The collector is running and receiving nothing. That is an application-side
    problem, not a collector one — your services are not exporting to it.

    Check that your application's `OTEL_EXPORTER_OTLP_ENDPOINT` points at the
    collector's OTLP port (`4318` for HTTP, `4317` for gRPC), not at aiAxonIQ.
  </Accordion>

  <Accordion title="401 with a key you know is correct" icon="key">
    Two causes that are not about the key's value:

    * **A trailing newline.** A key read from a file created by a shell heredoc,
      or a Kubernetes Secret made with `--from-file`, carries the newline as
      part of the value. Use `--from-literal`, or `printf` rather than `echo`.
    * **A validation outage.** If the receiver cannot reach the service that
      validates keys it fails closed and returns the same `401`. A sudden `401`
      across every service at once, with a key you have not changed, is far more
      likely to be this. Check `$OIQ_ENDPOINT/health` first.
  </Accordion>

  <Accordion title="Config changes appear to do nothing" icon="file-pen">
    The collector reads its configuration only at startup. Restart it after any
    edit, and confirm the file you edited is the one mounted into the process —
    a bind mount pointing at a path that does not exist silently yields the
    image's default config rather than an error.
  </Accordion>
</AccordionGroup>

## Next

<CardGroup cols={3}>
  <Card title="Instrument your application" icon="code" href="/send-data/otel/zero-code">
    Zero-code setup per language.
  </Card>

  <Card title="Kubernetes" icon="dharmachakra" href="/send-data/platforms/kubernetes">
    When the stack moves to a cluster.
  </Card>

  <Card title="Verify your data" icon="circle-check" href="/get-started/verify-data">
    Confirm it landed.
  </Card>
</CardGroup>
