v2 · Collect — the one agent¶
In plain words
v2 has a single agent — the OpenTelemetry Collector — where v1 ran three (vmagent, Fluent Bit, Vector). It does three jobs at once: tails app-log files, receives Envoy's request logs, and scrapes the machine's CPU/memory/network meters. It tags every row with which customer it belongs to and ships it to ClickHouse.
It runs in two tiers. On every node a light agent collects and stamps identity. In the region a gateway batches everything and is the only thing that writes ClickHouse — many tiny inserts choke ClickHouse ("too many parts"), so the gateway holds them and writes in bulk (the job Vector held in v1). App logs and request logs enter the gateway through separate doors, so each lands in its own table.
flowchart LR
classDef built fill:#3434DC22,stroke:#3434DC,color:#5B5EE8;
classDef third fill:transparent,stroke:#808080,color:#808080;
classDef store stroke-dasharray:4 3,stroke:#808080,color:#808080;
L["app logs"]:::third
A["Envoy access logs"]:::third
M["cAdvisor meters"]:::third
AG["OTel agent<br/>per node · stamps identity"]:::built
GW["OTel gateway<br/>on the telemetry droplet · batches"]:::built
CH[("ClickHouse")]:::store
L --> AG
A --> AG
M --> AG
AG -->|per-cluster token · two doors| GW --> CH
Three signals, one agent¶
| Signal | Source | How it's received | Gateway door | Lands in |
|---|---|---|---|---|
| App logs | pod stdout files | filelog (tail) |
:4317 |
otel_logs |
| L7 request logs | Envoy Gateway | otlp (EG's OTel access-log sink) |
:4319 |
otel_http |
| Resource meters | cAdvisor / kubelet | prometheus (scrape) |
:4317 |
otel_metrics_* alpha exporter |
How to build it
Deploy two things — the per-node agent and the regional gateway — then wire identity and auth.
1 · The node agent — three receivers. filelog tails the container log files — the container
operator parses the runtime format, stitches split lines back together, and extracts
k8s.namespace.name / k8s.pod.name from the file path (that's how a file gets matched to its
pod). The exclude list keeps the agent from tailing platform/system pods — otherwise a ClickHouse
outage makes the agent ship its own error logs in a feedback loop. otlp receives Envoy's request
logs; prometheus scrapes cAdvisor — over HTTPS with the ServiceAccount token, pinned to its own
node (without the keep relabel, every agent scrapes every node → N× duplicate rows):
receivers:
filelog: # tail app-pod stdout → otel_logs
include: [/var/log/pods/*/*/*.log]
exclude: # never tail platform/system pods (self-logging feedback loop)
- /var/log/pods/kube-system_*/*/*.log
- /var/log/pods/observability_*/*/*.log # this collector itself
- /var/log/pods/starform-system_*/*/*.log # Shuttle · Envoy Gateway · Alloy
include_file_path: true
operators:
- type: container # parse runtime format + stamp k8s.pod.name / k8s.namespace.name from the path
otlp: # receive Envoy access logs → otel_http
protocols: { grpc: { endpoint: 0.0.0.0:4317 } }
prometheus: # scrape cAdvisor CPU/mem/net → otel_metrics_*
config:
scrape_configs:
- job_name: cadvisor
scheme: https
authorization: { credentials_file: /var/run/secrets/kubernetes.io/serviceaccount/token }
tls_config: { insecure_skip_verify: true } # or pin the kubelet CA
kubernetes_sd_configs: [{ role: node }]
relabel_configs:
- source_labels: [__meta_kubernetes_node_name] # scrape ONLY this node —
regex: ${env:NODE_NAME} # else every agent scrapes every node
action: keep
metrics_path: /metrics/cadvisor
2 · Stamp identity, then forward — three pipelines, two doors. For pod-origin data (app logs,
meters), k8sattributes looks the pod up by the name/namespace the container parser extracted
(file logs have no network connection to match on) and copies its starform.io/* labels onto every
row. Access logs skip k8sattributes — their identity rides the route (step 3) — and a small filter
drops rows for requests that matched no route (health probes, 404s, IP scans). Each pipeline exports
to its own gateway door, carrying the per-cluster token
(FR-066):
processors:
k8sattributes:
pod_association: # match file logs to their pod by parsed name/namespace (no connection IP exists)
- sources:
- { from: resource_attribute, name: k8s.pod.name }
- { from: resource_attribute, name: k8s.namespace.name }
extract:
labels: # pod starform.io/* labels → tenant identity
- { tag_name: project_id, key: starform.io/project-id }
- { tag_name: service_id, key: starform.io/service-id }
- { tag_name: environment, key: starform.io/environment }
- { tag_name: cluster_id, key: starform.io/cluster-id }
filter/unrouted: # drop access-log rows with no route (health probes, 404s, scans)
logs:
log_record:
- 'attributes["route_name"] == ""'
batch: {}
exporters:
otlp/app: # app logs + meters → gateway :4317, over the VPC
endpoint: otel-gateway.region.internal:4317
headers: { authorization: "Bearer ${env:CLUSTER_BEARER}" } # per-cluster ingest token (FR-066)
tls: { insecure: false }
otlp/access: # Envoy request logs → gateway :4319 (their own door → their own table)
endpoint: otel-gateway.region.internal:4319
headers: { authorization: "Bearer ${env:CLUSTER_BEARER}" }
tls: { insecure: false }
service:
pipelines:
logs/app: { receivers: [filelog], processors: [k8sattributes, batch], exporters: [otlp/app] }
logs/access: { receivers: [otlp], processors: [filter/unrouted, batch], exporters: [otlp/access] } # no k8sattributes — identity rides the route
metrics: { receivers: [prometheus], processors: [k8sattributes, batch], exporters: [otlp/app] }
3 · Identity on request logs — from the route, not a label. The access log is written by
Envoy, not a pod, so it can't be label-stamped. Envoy Gateway exposes the HTTPRoute's name and
namespace as route metadata (it does not propagate arbitrary labels), and our naming already
encodes identity there: name = <project32><service32>-<env>
(§20.2), namespace = proj-<project_uuid>.
Read them with the CEL operator; the Store DDL splits the name into the tenant
columns once, at insert:
spec:
telemetry:
accessLog:
settings:
- format:
type: JSON # structured fields, nothing to re-parse
json:
duration: "%DURATION%" # → latency
response_code: "%RESPONSE_CODE%" # → error rate
response_flags: "%RESPONSE_FLAGS%" # → debugging (UH, LR, …)
bytes_sent: "%BYTES_SENT%" # → throughput (egress)
bytes_received: "%BYTES_RECEIVED%" # → throughput (ingress)
method: "%REQ(:METHOD)%"
path: "%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%"
# tenant identity from the HTTPRoute's route metadata (name/namespace), NOT labels:
route_name: "%CEL(xds.route_metadata.filter_metadata['envoy-gateway']['resources'][0]['name'])%"
namespace: "%CEL(xds.route_metadata.filter_metadata['envoy-gateway']['resources'][0]['namespace'])%"
sinks:
- type: OpenTelemetry # → the node agent's otlp receiver, on :4317
openTelemetry: { host: otel-agent.observability.svc.cluster.local, port: 4317 }
Verify once at build (5 minutes): EG's docs don't pin (a) whether the JSON fields land in the
OTLP attributes or the body, or (b) the exact CEL path on your pinned EG version. Send one
request and look at the row: fields in LogAttributes → done; in Body → flip the Store expressions
to JSONExtract(Body, …) (Store gotcha). Envoy's own logger maps a JSON struct to
attributes, so attributes-first is the likely answer.
4 · The regional gateway — the sole writer. Two receivers (one per door) that validate the
per-cluster tokens via the bearertokenauth extension; an on-disk queue (file_storage) so a
ClickHouse outage or gateway restart doesn't drop data; batching (the "too many parts" defence); and
one ClickHouse exporter per table. create_schema: true on the app exporter creates only the
missing otel_metrics_* tables — our pre-created tables win (IF NOT EXISTS):
extensions:
bearertokenauth: # validates per-cluster ingest tokens (FR-066)
scheme: Bearer
filename: /etc/otel/cluster-tokens # one token per line — one per cluster
file_storage: # on-disk queue: survives ClickHouse outages + gateway restarts
directory: /var/lib/otelcol/queue
receivers:
otlp/app: # app logs + resource meters
protocols: { grpc: { endpoint: 0.0.0.0:4317, auth: { authenticator: bearertokenauth } } }
otlp/access: # Envoy request logs
protocols: { grpc: { endpoint: 0.0.0.0:4319, auth: { authenticator: bearertokenauth } } }
processors:
batch: { timeout: 5s, send_batch_size: 10000 } # bulk inserts → avoids too-many-parts
exporters:
clickhouse: # app logs → otel_logs · meters → otel_metrics_*
endpoint: tcp://clickhouse.region.internal:9000
database: otel
username: otel_ingest
password: ${env:CH_INGEST_PASSWORD}
async_insert: true
create_schema: true # creates ONLY the missing otel_metrics_* tables (IF NOT EXISTS)
logs_table_name: otel_logs
sending_queue: { storage: file_storage }
clickhouse/http: # request logs → otel_http
endpoint: tcp://clickhouse.region.internal:9000
database: otel
username: otel_ingest
password: ${env:CH_INGEST_PASSWORD}
async_insert: true
create_schema: false # otel_http is ours — declared on the Store page
logs_table_name: otel_http
sending_queue: { storage: file_storage }
service:
extensions: [bearertokenauth, file_storage]
pipelines:
logs/app: { receivers: [otlp/app], processors: [batch], exporters: [clickhouse] }
logs/access: { receivers: [otlp/access], processors: [batch], exporters: [clickhouse/http] }
metrics: { receivers: [otlp/app], processors: [batch], exporters: [clickhouse] }
5 · Verify. After one request and one log line, rows land in the right tables — run against the
telemetry droplet's ClickHouse:
SELECT count() FROM otel.otel_http WHERE Timestamp > now() - 60 returns > 0, and the same on
otel.otel_logs — and not vice versa (the doors are split).
Gotchas & what to verify
- Attribution is the route name, read cleanly — not the v1 parse. No per-scrape
envoy_cluster_nameregex; the HTTPRoutenameis a stable Gateway-API field, split once in the Store DDL. Confirm the CEL path on your pinned EG version (step 3's 5-minute test). - ID forms match at the store. Route names are hyphen-stripped 32-hex; pod labels carry
hyphenated UUIDs — the Store expressions strip hyphens so
otel_httpandotel_logsshare the sameproject_idform. - Alpha/beta — run, watch, fall back. L7 metrics ride the logs path (beta, run in production by ClickStack/SigNoz); only the cAdvisor meters ride the alpha metrics path. The ladder if it misbehaves: pin the last-known-good Collector → switch to SigNoz's production-hardened exporter fork (Apache-2.0) → worst case swap only the writer (Vector's ClickHouse sink as the gateway; the agents don't change).
- Outage behavior, in order. ClickHouse down → the gateway queues on disk (
file_storage) → agents back off; app logs survive naturally (the files sit on the node until rotation), but Envoy's access-log buffer is the smallest — L7 rows drop first in a long outage. Size/var/lib/otelcol/queuefor the outage window you want to ride out. - A pod that dies fast can miss its stamp. If
k8sattributeshasn't seen the pod yet, rows arrive withoutproject_id— invisible to tenant queries, expired by the 90-day default. Watch the unattributed-row rate (Bootstrap · watchdogs). - No per-tenant log limit at MVP. One tenant logging at full speed fills the shared disk — watch
per-project bytes/day (one SQL on
otel_logs) and alert; a hard quota is post-MVP. - Long-lived connections (websockets/SSE) log at close — an hour-long connection shows up as an hour of "latency." Exclude/flag upgrades in the latency panel (Read & query).
Reference
Where it lands: Store & retention · read it back: Read & query.
HTTPRoute name contract §20.2; label set
§24.1; ingest auth
FR-066. Design record:
specs/2026-07-07-single-store-clickhouse-telemetry-design.md.