External Service Logs & Metrics
Push logs and Prometheus metrics into KubeSense from services running outside the cluster, using the aggregator and metrics-scraper load balancers.
Overview
Managed SaaS platforms, third-party APIs, and applications running on VMs sit outside your Kubernetes cluster, so no KubeSense agent can be installed next to them. These services instead push their telemetry to an endpoint you provide, or expose it for you to pull and forward.
KubeSense exposes two ingestion surfaces for this. Both are ordinary Kubernetes Services, so you publish them by turning them into internal load balancers (an NLB on AWS):
| Data | Component | Under the hood | Default port |
|---|---|---|---|
| Logs | aggregator | Vector | 30055 OTLP HTTP · 30054 file log |
| Metrics (Prometheus format) | metrics-scraper | vmagent | 30060 |
Which log path should I use?: Prefer OTLP when the sending side can speak OpenTelemetry. It carries structured resource attributes, so logs are correctly attributed to a cluster, service, and environment. Use the file log path when the source can only POST plain JSON — it is simpler, but the field mapping is fixed and flatter.
Part 1 — Expose the endpoints
Aggregator (logs)
Set the aggregator service to LoadBalancer and add your cloud's annotations:
aggregator:
service:
type: LoadBalancer
annotations:
service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
service.beta.kubernetes.io/aws-load-balancer-internal: "true"
service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: ip
service.beta.kubernetes.io/aws-load-balancer-subnets: subnet-aaa, subnet-bbb, subnet-ccc
service.beta.kubernetes.io/aws-load-balancer-cross-zone-load-balancing-enabled: "true"
service.beta.kubernetes.io/aws-load-balancer-target-group-attributes: preserve_client_ip.enabled="true"
sources:
otel:
enabled: true # OTLP logs — off by default
file_logs:
enabled: true # JSON HTTP — off by defaultThe listener ports stay in the 30000 range: The aggregator Service defines port from the global.nodePort.* values regardless of service type. Switching to LoadBalancer does not move ingestion onto 80/443 — the NLB listens on 30052, 30054, 30055, 30056, 30059, and 8686. Send traffic to http://<nlb-dns>:30055, not http://<nlb-dns>.If you need conventional ports, use aggregator.additionalservice, which publishes a second Service with freely chosen port numbers.
Metrics-scraper (metrics)
The metrics-scraper Service is disabled by default and must be switched on as well as exposed:
metrics-scraper:
service:
enabled: true # false by default
type: LoadBalancer
servicePort: 30060
annotations:
service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
service.beta.kubernetes.io/aws-load-balancer-internal: "true"
service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: ip
service.beta.kubernetes.io/aws-load-balancer-subnets: subnet-aaa, subnet-bbb, subnet-cccApply with:
helm upgrade kubesense kubesense/kubesense \
-f values.yaml --namespace kubesenseThen collect the two hostnames:
kubectl -n kubesense get svc kubesense-aggregator kubesense-metrics-scraper \
-o custom-columns=NAME:.metadata.name,LB:.status.loadBalancer.ingress[0].hostnameKeep these load balancers private: The aggregator Service also publishes the Vector admin API on port 8686, which is unauthenticated. Always set aws-load-balancer-internal: "true", and restrict the source range:aggregator: service: loadBalancerSourceRanges: - 10.0.0.0/8 # your VPC, a peered VPC, or the vendor's egress CIDRsNeither ingestion endpoint performs authentication. Reach them over private connectivity — VPC peering, PrivateLink, or Transit Gateway — rather than the public internet. If a vendor can only reach a public address, terminate it on a proxy that enforces authentication and forwards to the NLB.
Part 2 — Pushing logs
Option A — OTLP logs (recommended)
Enable aggregator.sources.otel and send OTLP to the NLB:
| Protocol | Endpoint |
|---|---|
| OTLP/HTTP | http://<aggregator-nlb>:30055/v1/logs |
| OTLP/gRPC | <aggregator-nlb>:30056 |
For a service outside Kubernetes, the aggregator reads these resource attributes to attribute the log:
| Resource attribute | Becomes | Required |
|---|---|---|
kubesense.env_type | env_type | Yes — set to a value other than k8s or lambda |
kubesense.cluster | cluster | Yes — groups the data in the UI |
service.name | workload, instance, container_name | Yes |
service.namespace | namespace | Recommended |
Severity comes from the OTLP severity_text field; when it is empty, KubeSense
falls back to regex-matching the message body for a level.
A collector forwarding logs from an external service would be configured like this:
processors:
resource/kubesense:
attributes:
- { key: kubesense.env_type, value: external, action: upsert }
- { key: kubesense.cluster, value: prod-observability, action: upsert }
- { key: service.name, value: payments-api, action: upsert }
- { key: service.namespace, value: billing, action: upsert }
exporters:
otlphttp/kubesense:
logs_endpoint: http://<aggregator-nlb>:30055/v1/logs
tls: { insecure: true }
service:
pipelines:
logs:
processors: [resource/kubesense]
exporters: [otlphttp/kubesense]Do not send OTLP logs from inside the cluster: Port 30055 is for external sources only. In-cluster pod logs are already collected by the logsensor, and sending them again over OTLP produces duplicates.
Option B — File log (plain JSON POST)
Enable aggregator.sources.file_logs and POST JSON to
http://<aggregator-nlb>:30054. The body may be a single JSON object or an
array of objects, in which case each element becomes one log record.
The aggregator maps a fixed set of keys:
| JSON field you send | Becomes in KubeSense |
|---|---|
timestamp | timestamp — RFC 3339, e.g. 2026-07-23T10:15:00.000Z |
message | body, the searchable log line |
service | workload and instance |
application | namespace |
host | node_name |
Records ingested this way are always tagged source = "file" and
env_type = "legacy".
curl -X POST http://<aggregator-nlb>:30054 \
-H 'Content-Type: application/json' \
-d '[
{
"timestamp": "2026-07-23T10:15:00.000Z",
"service": "payments-api",
"application": "billing",
"host": "vm-payments-01",
"message": "WARN upstream connection reset, retrying in 2s"
}
]'Extra fields are dropped silently: The ClickHouse sink runs with skip_unknown_fields: true. Any key that does not match a column in the logs table is discarded without an error — so a typo in a field name looks exactly like success. Verify in the Log Explorer that the value actually landed, rather than trusting the 200 OK.
Part 3 — Pushing Prometheus metrics
The metrics-scraper is a vmagent instance. Besides scraping, it accepts pushed metrics on two paths:
| Path | Format | Use when |
|---|---|---|
/api/v1/write | Prometheus remote write (snappy protobuf) | The sender is Prometheus, an OTel collector, or another vmagent |
/api/v1/import/prometheus | Prometheus text exposition | You have a /metrics-style payload to forward as-is |
Remote write
Point any Prometheus-compatible sender at the metrics-scraper NLB:
remote_write:
- url: http://<metrics-scraper-nlb>:30060/api/v1/writeFrom an OpenTelemetry Collector:
exporters:
prometheusremotewrite/kubesense:
endpoint: http://<metrics-scraper-nlb>:30060/api/v1/write
tls: { insecure: true }
external_labels:
cluster: prod-observabilityPushing exposition-format text
If you can already fetch a Prometheus-format payload, forward it unchanged:
curl -s https://<source>/metrics \
| curl -X POST --data-binary @- \
'http://<metrics-scraper-nlb>:30060/api/v1/import/prometheus?extra_label=cluster=prod-observability'The extra_label parameter attaches a label to every series in the request —
use it to tag the origin, since pushed metrics carry no cluster attribution of
their own.
Alternative — let the metrics-scraper pull instead
Many providers do not push at all. They expose a metrics export endpoint that already returns OpenMetrics/Prometheus format and expects you to poll it, usually behind an API key. When the endpoint is reachable from the cluster, scraping it is simpler than building a push relay — and needs no load balancer at all:
metrics-scraper:
extraScrapeConfigs:
- job_name: external-provider
scrape_interval: 60s
scheme: https
metrics_path: /v1/metrics/export
params:
resource_id: ["<RESOURCE_ID>"]
basic_auth:
username: <API_KEY>
password: <API_SECRET>
static_configs:
- targets: ["metrics.provider.example.com"]Scrape when you can, push when you must: Check the provider's minimum sampling interval before lowering scrape_interval — polling faster than they publish returns the same data points and burns API quota. Use the push paths above only for sources the cluster cannot reach, such as a Prometheus in a remote network that can make outbound connections but accept none.
Verification
-
kubectl -n kubesense get svc kubesense-aggregatorshows an external address - The sender can reach the port:
nc -vz <aggregator-nlb> 30055 - Logs appear in the Log Explorer, filtered by the
service.nameorservicevalue you set - The
clusterandenv_typevalues are populated, not blank - Timestamps reflect event time, not ingest time — a wall of identical
timestamps means the
timestampfield was not parsed - Metrics appear in the Data Explorer; confirm the series name and that
your
clusterlabel is attached - Check for records the pipeline rejected:
kubectl -n kubesense logs -l app=aggregator | grep -i dropped
Troubleshooting
| Symptom | Likely cause | Action |
|---|---|---|
| Connection refused / timeout | Source not enabled, so nothing listens | Set aggregator.sources.otel.enabled or file_logs.enabled and upgrade |
| Connection times out from outside the VPC | Internal NLB, by design | Use private connectivity, or check loadBalancerSourceRanges |
200 OK but no logs in the UI | Field names do not match the mapping; unknown fields are dropped silently | Compare your payload against the field table above |
| Logs arrive with blank cluster | kubesense.cluster resource attribute not set | Add it in the collector's resource processor |
| Logs land with the wrong workload | service.name missing, or kubesense.env_type set to k8s | Set env_type to an external value such as external |
| All timestamps equal ingest time | timestamp not RFC 3339 | Emit e.g. 2026-07-23T10:15:00.000Z |
| Duplicate log lines | In-cluster logs sent over OTLP as well as by the logsensor | Use 30055 for external sources only |
| Metrics rejected | Wrong path | Remote write is /api/v1/write; exposition text is /api/v1/import/prometheus |
| Metrics have no cluster label | Pushed metrics carry no attribution | Add extra_label= or external_labels |
Best practices
- Keep both load balancers internal and source-range restricted; neither endpoint authenticates callers.
- Set
kubesense.clusterandkubesense.env_typeon every external source so the data is filterable and does not blend into in-cluster telemetry. - Give each external source a distinct
service.name— it becomes the workload in the UI and is the field you will filter on. - External sources often need a different retention policy from in-cluster logs — some are low-volume but must be kept for a long time. Set this deliberately in Settings → Data Retention.
- Watch cardinality after adding a push source. Vendor metrics often carry high-cardinality identifiers; review Settings → Metrics afterwards.