Pipeline Model

Both tools run the same upstream code for OTLP processing. They differ in how you describe a pipeline: a YAML list of named pipelines, or a graph of components that reference each other.

The OTel Collector model

Building block Role Examples
Receiver Gets data in (push or pull) otlp, prometheus, filelog, k8s_events
Processor Changes data in flight memory_limiter, batch, filter, transform, k8sattributes, tail_sampling
Exporter Sends data out otlp, otlphttp, prometheusremotewrite, loadbalancing
Connector Exporter of one pipeline and receiver of another spanmetrics, routing, count
Extension Not in the data path health_check, pprof, file_storage, bearertokenauth

Wiring lives in service.pipelines. A component defined but not listed there does nothing — silently.

extensions:
  health_check: {}
  file_storage: { directory: /var/lib/otelcol/queue }

service:
  extensions: [health_check, file_storage]   # defined above AND enabled here
  pipelines:
    traces:
      receivers:  [otlp]
      processors: [memory_limiter, batch]     # order = execution order
      exporters:  [otlp/tempo]
  • Extensions are listed twice — once defined, once enabled in service.extensions. Defining without enabling is not an error.
  • A pipeline is per signal. One otlp receiver can feed a traces, a metrics and a logs pipeline.
  • Processors in one pipeline run in list order. The same processor name in two pipelines is two independent instances.

The Alloy component model

Every block is a component: namespace.kind "label" { arguments }. It has arguments (inputs you set) and exports (values others read). A pipeline is nothing more than references between exports and arguments.

otelcol.receiver.otlp "default" {
  grpc { endpoint = "0.0.0.0:4317" }
  output {
    traces = [otelcol.processor.batch.default.input]   // reference: namespace.kind.label.export
  }
}

otelcol.processor.batch "default" {
  output { traces = [otelcol.exporter.otlp.tempo.input] }
}

otelcol.exporter.otlp "tempo" {
  client { endpoint = "tempo-distributor.monitoring.svc.cluster.local:4317" }
}
Property Consequence
Declarative graph Block order in the file does not matter; references define the flow
Reactive When an export changes (a file read by local.file, a discovered target list), only dependents re-evaluate — no restart
Fan-out by listing traces = [a.input, b.input] sends a copy to both
Reference checked at load A typo in a reference stops the process at startup (see First pipeline exercise)
One file or a directory A directory is merged into one namespace — labels must be unique across files

The name “River” is retired; the language is the Alloy configuration syntax. Grafana Agent reached end of life on 2025-11-01.

Data types do not mix

Alloy carries the Prometheus, Loki and Pyroscope native pipelines alongside the OTel one. They use different types and different wiring:

Family Type passed Wiring
prometheus.* MetricsReceiver forward_to = [prometheus.remote_write.x.receiver]
loki.* LogsReceiver forward_to = [loki.write.x.receiver]
pyroscope.* ProfilesReceiver forward_to = [pyroscope.write.x.receiver]
otelcol.* otelcol.Consumer output { traces/metrics/logs = [x.input] }
discovery.* Targets targets = discovery.kubernetes.pods.targets

Converters are the only bridge between families. Despite the names, they send nothing over the network:

Component Direction
otelcol.exporter.prometheus OTLP metrics → Prometheus pipeline
otelcol.exporter.loki OTLP logs → Loki pipeline
otelcol.receiver.prometheus Prometheus pipeline → OTLP
otelcol.receiver.loki Loki pipeline → OTLP

Our gateway does exactly this for OTel Demo metrics: otelcol.receiver.otlpotelcol.processor.batch "metrics"otelcol.exporter.prometheusprometheus.remote_write.

Two relabel components look alike and work at different stages:

Component Works on Sees __meta_*
discovery.relabel Targets, before the scrape — who and how to scrape Yes
prometheus.relabel Samples, after the scrape — what to keep No; sees __name__

Processor order

Order changes both correctness and cost.

Position Processor Why here
1 memory_limiter Must refuse data before anything allocates for it. Anywhere else it drops data that was already processed
2 filter / sampling Drop early; every later step processes less
3 k8sattributes, resource, transform Enrich what survived
last batch Batches the final shape; filter after batch produces uneven, smaller batches

Neither of our Alloy releases runs a memory_limiter today (lesson 06 covers what that costs).

Enrichment traps

  • otelcol.exporter.loki does not turn attributes into labels on its own. It reads the hint attributes loki.resource.labels and loki.attribute.labels. Without them, logs arrive in Loki with no useful labels. Our gateway sets them explicitly:

    otelcol.processor.attributes "otel_logs" {
      action {
        key    = "loki.resource.labels"
        action = "insert"
        value  = "service.name, service.namespace, host.name"
      }
      // ...
    }
    

    Loki 3.x also has a native OTLP endpoint (/otlp), which maps a fixed set of resource attributes to labels and the rest to structured metadata. It removes the need for hints.

  • k8sattributes associates by connection IP by default. On a gateway fed by another collector, the connection comes from the collector pod, so every record gets the collector’s namespace. Enrich where the source is visible (the node agent), or associate by an attribute the agent already set (k8s.pod.uid).
  • File-tailed logs have no pod IP. Associate by k8s.pod.uid taken from the file path.
  • A default routing branch must check for the missing attribute. A transform that sets a value where attributes["x"] == nil is a default; without the condition it overwrites.

💪Exercise💪 (self-guided): First pipeline — build OTLP → debug → Prometheus in your own Alloy release, then break a reference and watch it refuse to start.

results matching ""

    No results matching ""