Triage Agent Specification

Specification · agent repository · v3.1.82 · 58b8139

Triage Agent

The collection and aggregation layer of Triage: an agent on Kubernetes clusters, Linux hosts and cloud provider connectors. The agent discovers virtual apps from workloads, measures them with eBPF, cgroup and logs-based metrics as USE, RED, SIG and LOG frameworks, and streams the result to the core over one outbound secure WebSocket (WSS). It sends signals only; drift, issues and root cause are decided in the core.

KERNEL AND HOST DAEMONSET · PER NODE CONTROLLER CORE cgroup v2 and PSIhost files, read only eBPF probesuprobes, kprobes, socket filter Container logs/var/log/containers resource metricsUSE · CPU, memory, PSI OBI · traffic metricsRED · SIG · L7/L4 topology logs-based metricsLOG · error and warn counts sensorstream :9470merged across replicas Collectorsdeltas, baseline gate Per-tenant queuespush over WSS Kubernetes API / Linux systemdnamespaces, workloads, pods, events · systemd units, journald Kubernetes watchesvirtual apps, Warning events Events APIHTTPS, bearer + X-Tenantgcp-cost · gcp-alertgcp-status · prometheus429 when queue is full Cloud providersbilling, alerts, health Connectorsgcpevents · promevents · wiz Event routerframes + events, per tenant Event processingclassifies frames + events Signal data (TLS 1.3, WSS) Snapshot request on graph load Host reads, connector posts Outside the agent
Figure 1. Components and boundaries.
01

Architecture

Data crosses three boundaries: from kernel and host files into the sensor roles, from the node into sensorstream over gRPC, and from the cluster to the core over a secure WebSocket (WSS) the agent opens. Nothing outside the cluster can open a connection into it.

The sensor roles send one OTLP snapshot per role every --sensor-tick, default 15 s. sensorstream keeps the latest per node and serves it to the collectors through metricset.Source. Collectors are manager.Runnables that run only on the leader, and only the leader holds the connection to the core. The controller can run several replicas: every replica takes sensor streams and gossips what its own sensors send to the others over mutual TLS (headless Service <fullname>-peers, port 9471, pinned to the same sensor certificate), keeping the newest snapshot per node. The leader therefore sees every node whichever replica a sensor connected to, and a newly elected leader already holds the state.

The agent is one container image, run in two shapes. The DaemonSet hosts the sensor roles; the controller Deployment hosts everything that talks to the Kubernetes API or the core. A namespace is observed only when it is annotated with a tenant or listed in --target-namespaces, and every frame leaves stamped with that tenant.

Collectors are off by default. Virtual apps and Kubernetes Warning events flow as soon as push mode is configured; RED, USE, log and topology collectors each need their own flag and the sensor stream.

Out of scope for the agent: comparing the observed topology with the declared graph, opening issues, and any model call. The core does those.

High availability

The controller runs as several replicas behind one leader. Leader election is on whenever replicaCount is above 1, through a Lease named triage-agent in the agent's namespace. Every replica serves the sensor stream and the OTLP receiver; only the leader runs the collectors and holds the connection to the core.

ConcernBehaviour
IntakeSensors and OTLP senders reach any replica through the <fullname>-sensors Service; losing a replica drops only the streams it held, which reconnect to another within the 1 to 30 s backoff
Shared stateReplicas gossip every sensor snapshot and OTLP source over mutual TLS on 9471, newest wins, so a new leader already holds the latest data from every node and sender
Failovercontroller-runtime defaults: a 15 s lease, renewed every 10 s, retried every 2 s. The lease is not released on shutdown, so a new leader takes over when it expires: within about 15 s for a crash or a rollout alike
Core linkThe new leader opens the push connection with the same join identity, kept in --join-secret, so no new approval is needed, and resends each tenant's virtual-app snapshot
BaselinesHeld in the leader's memory and not gossiped. After a failover every service is cold until it has the minimum windows again; the cold floors keep reporting a clear breach in that time
DisruptionsA PodDisruptionBudget with maxUnavailable: 1 when replicas are above 1, so a drain takes one controller at a time
PlacementNo anti-affinity by default; set affinity to spread replicas across nodes or zones

A single replica runs without leader election and without a PodDisruptionBudget; a restart is a gap in intake and frames until the pod is back.

Linux host mode

With --mode=host the agent runs as one systemd service on a VM or bare-metal host, with no Kubernetes API. The host reports the same frames as a cluster, so the core and the protocol are unchanged: a host is a cluster of one with one synthetic namespace. There is no controller Deployment, no DaemonSet, no sensor stream and no listener; the sensor roles run in the agent's own process and the collectors read them directly.

In a clusterOn a host
ClusterThe OS host name; --cluster-name overrides it
NamespaceOne synthetic namespace, --host-namespace, default host. Hosts running the same services share one value, so their services merge in the core graph
Tenant--target-tenant, required; tenant mode only
Virtual appA systemd unit selected by --host-units globs, default every enabled service except system units; Ready when ActiveState=active and SubState=running, through the same flap damping
USEThe unit's cgroup under system.slice, with the same readers, PSI included; limits from the unit's own cpu.max and memory.max
LOGjournald by _SYSTEMD_UNIT, through journalctl --follow; priority 0 to 3 is an error, 4 a warning, then the same regexes and redaction
REDOBI with Kubernetes off, one discovery entry per unit selecting every PID in its cgroup; series are stamped with the unit and the host namespace from /proc/<pid>/cgroup. A new PID in a unit restarts the instrumenter, a short RED gap
TopologyA callee is named only by server.address, so every edge goes to an external host, even another unit; L4 flows carry addresses only
IdentityThe same join approval; key and certificate kept in --join-state-dir
HANone: one agent per host, and a second one would duplicate every frame

Install is a binary or a deb or rpm package with triage-agent.service, which reads its flags from /etc/triage-agent/env and runs as root with the same six capabilities as the DaemonSet. The hello carries the capability host. The acceptance steps have not yet been run on a real host.

02

Scope and tenants

Each item the agent sees, whether a workload, an event or a metric series, is resolved to a tenant through its namespace before it is queued. An annotated namespace always goes to its annotation's tenant. Tenant mode adds a primary tenant for namespaces listed in --target-namespaces; cluster mode works from annotations alone.

TENANT MODE · NAMESPACES AND PLAIN LINUX CLUSTER MODE · ANNOTATED NAMESPACES Namespaces in scopelist + annotated Linux hostwhole host annotatedlisted only Annotation's tenanttriageagent.dev/tenant Primary tenant--target-tenant Per-tenant queues Namespaces in scopeannotated namespaces Annotation's tenanttriageagent.dev/tenant Per-tenant queues
Figure 2. Tenant resolution. An annotated namespace always goes to its annotation's tenant. Items opted out with triageagent.dev/triage: "false" are never sent. A Linux host has no namespaces, so --target-tenant sets its tenant. In cluster mode a namespace listed in --target-namespaces without the annotation has no tenant; its items are skipped and counted.
Tenant modeCluster mode
Selected by--target-tenant set--target-tenant empty; --cluster-name is the identity
AuthenticationPer cluster, by join certificate; the hello names the primary tenantPer cluster, by join certificate; the hello names no tenant
Frame stampPrimary tenant unstamped; other annotated tenants stampedEvery frame stamped
Scope--target-namespaces plus annotated namespacesAnnotated namespaces
Listed, not annotatedPrimary tenantSkipped and counted
CoversNamespaces from the list and annotations; plain Linux hosts (--mode=host)Annotated namespaces only

Annotations

KeyOnEffect
triageagent.dev/tenantNamespacePuts the namespace in scope and names its tenant
triageagent.dev/triage: "false"Workload, pod template, namespaceNo virtual app, events, metrics or topology for it
triageagent.dev/instrumentPodExcludes the pod from eBPF instrumentation
triageagent.dev/virtual-app, -group, -identityWorkload, namespaceNames and groups the virtual app; namespace values are inherited
triageagent.dev/tier, weight, domain, descriptionWorkload, namespaceDescribes it; weight is 0.1 to 10
03

Objects and signals

Objects

Objects the chart installs in the agent namespace.

ObjectKindPurpose
triage-agentDeploymentController; one or more replicas, the leader pushes to the core
<fullname>-ebpfDaemonSetSensor roles; hostPID; hostNetwork only with network flows
<fullname>-peersService, headlessPort 9471, gossip between controller replicas, mutual TLS
PodDisruptionBudgetPodDisruptionBudgetKeeps a controller replica up during voluntary disruptions, when replicas are above 1
<fullname>-sensorsService, ClusterIPPort 9470
OBI configConfigMapMounted as a file; not read through the API
<fullname>-sensor-caConfigMap, created emptyController writes ca.crt; sensors pin it
<fullname>-sensor-tlsSecret, created emptyController certificate and key
<fullname>-joinSecretEd25519 key, issued certificate, core CA

Watched API types

The controller follows these Kubernetes API types through informers. All access is get, list and watch.

API typeDefaultUsed forReady when
apps/v1 deployments, statefulsetsYesVirtual-app discoveryreadyReplicas ≥ spec.replicas
apps/v1 daemonsetsYesVirtual-app discoverynumberReady ≥ desiredNumberScheduled
argoproj.io/v1alpha1 rolloutsYesVirtual-app discoveryreadyReplicas ≥ spec.replicas
kagent Agent (AI agents)As its DeploymentThe kagent controller runs each Agent as a Deployment; the Agent resource itself is not watchedAs a Deployment
Any namespaced workload kindOpt-inVirtual-app discovery for custom types and vendor CRDsA Ready or Available condition
apps/v1 replicasets, batch/v1 jobs, cronjobsYesOwner walk from a pod to the workload that names it
v1 podsYesPod to workload identity for resource metrics
v1 namespacesYesScope and tenant annotations
v1 services, networking.k8s.io/v1 ingressesYesService index and entry points
v1 eventsYesWarning events, filtered server-side to type=Warning

Custom types are added with workloadResources (flag --workload-resources), one <group>/<version>/<resource> per entry, and a matching read rule in rbac.extraWorkloadRules. A non-empty list replaces the defaults, so keep the ones still needed. A type the API server does not serve is skipped at startup with one log line.

workloadResources:
  - apps/v1/deployments
  - apps/v1/statefulsets
  - apps/v1/daemonsets
  - argoproj.io/v1alpha1/rollouts
  - helm.toolkit.fluxcd.io/v2/helmreleases   # Flux HelmRelease
rbac:
  extraWorkloadRules:
    - apiGroups: ["helm.toolkit.fluxcd.io"]
      resources: ["helmreleases"]
      verbs: ["get", "list", "watch"]

Signals

Five sources feed the agent. RED and topology come from OBI (OpenTelemetry eBPF Instrumentation, vendored at third_party/obi), which chooses its own hooks; the agent loads no eBPF program of its own.

SourceRead byMethodYields
Kernel hooksebpfOBI uprobes and kprobes for HTTP and gRPC; socket filter for L4 when ebpf.networkFlows.enabledRED per service; L7 and L4 edges
cgroup v2, PSInode-exporterHost files, read onlyUSE per container
Container log fileslogs-based metricsTails from file end, polls every 250 ms; partial lines over 1 MiB dropped and countedlog.errors, log.warns, log.lines
Kubernetes APIcontrollerInformers on namespaces and workloads; events filtered server-side to type=WarningVirtual apps, Warning events
OpenTelemetry SDKs and CollectorscontrollerOTLP/gRPC push to port 4317, when otlp.enabled; routed by metric nameRED, SIG and L7 topology from semantic-convention histograms or span metrics; USE, with PSI, from kubelet cAdvisor or use.*; LOG from log.*

OTLP receiver

A cluster that already runs OpenTelemetry, or cannot run the DaemonSet, pushes OTLP metrics to the controller. The receiver keeps each resource's newest cumulative point per series and hands it to sensorstream, so the same collectors, baseline gates and replica gossip apply. Metric names route it: the HTTP, gRPC and database histograms and obi.network.flow.bytes go to RED and topology, use.* to USE, log.errors, log.warns and log.lines to LOG. Where a sensor and an OTLP sender report the same service or pod, the sensor's numbers are used and the OTLP ones dropped, so nothing is counted twice.

A Collector that has only traces, kubelet access and logs covers all three frameworks without the DaemonSet. The receiver translates two inputs it would otherwise ignore:

Collector inputTranslated toNotes
Traces through the spanmetrics connectorHTTP and gRPC server histograms (RED, SIG), client histograms (L7 edges)Read from traces.span.metrics.duration by span.kind and rpc.system; milliseconds become seconds; a span without a status code counts as 200 or OK, an error span as 500 or UNKNOWN; internal spans and the calls counter are dropped. Needs add_resource_attributes: true so the service, not the Collector, owns the series
Kubelet /metrics/cadvisor through the prometheus receiveruse.* per pod, node cores and memoryPod-level cgroup series only. PSI is the rate of container_pressure_*_waiting_seconds as a percentage, the same meaning as the DaemonSet's some avg; it needs Kubernetes 1.34 or later and a ClusterRole with get on nodes/metrics
Logs through the count connectorLOGCount by severity into log.errors, log.warns and log.lines

Points need service.name and k8s.namespace.name and cumulative temporality. Older conventions are converted: http.server.duration in milliseconds, http.status_code, a numeric rpc.grpc.status_code. The workload comes from k8s.owner.name or the k8s.deployment.name family that the k8sattributes processor adds. Delta temporality and exponential histograms are refused and reported in the response. Limits: 5000 resources, 20000 series each; a resource silent for 10 minutes is dropped.

The receiver serves the controller's certificate, which senders pin from -sensor-ca. A sender authenticates with a client certificate that chains to otlp.clientCASecret, which is the OTel exporter's own tls.cert_file and key_file, or with a projected ServiceAccount token, audience triage-agent-otlp, of an account in otlp.allowedServiceAccounts, checked by TokenReview.

exporters:
  otlp/triage:
    endpoint: <fullname>-sensors.<namespace>.svc:4317
    tls:
      ca_file: /etc/otel/triage/ca.crt       # from -sensor-ca
      cert_file: /etc/otel/certs/client.crt  # chains to otlp.clientCASecret
      key_file: /etc/otel/certs/client.key

Virtual apps come from workloads only, synthesized by workload name or by label when a namespace opts in. The workload kinds and their readiness rules are under Watched API types above.

Sandboxed runtimes. eBPF works on the host kernel, so sandboxes limit it. The agent cannot run under gVisor, Kata or user-namespaced pods, which have no host kernel or host PID namespace to probe. Workloads that run under them are mostly invisible to the traffic role: their traffic passes through the sandbox's own kernel, so RED, SIG and L7 topology are missing and at most L4 flows at the sandbox's interface are seen.

04

Data flow and frames

Gated frames carry only services that crossed their baseline in the window. Summary frames carry every service, ungated, so the core can see quiet services too.

Sensor rolesOTLP snapshot sensorstreamlatest per node Collectorsdelta, gate, cap Per-tenant queuespush over WSS Coreingest snapshot request on graph load every 15 s--sensor-tick stale after 2 mnode dropped from reads Δ ≥ 10, ≥ 3× medianof 5+ windows (logs) round-robinone WSS ≤ 512 KiB inper inbound message PIPELINE CADENCE AND LIMITS
Figure 3. Pipeline with its cadences. Reconnect backoff is 1 s to 30 s on both links.
FrameContentEnabled by
snapshot, app_deltaVirtual-app statusPush configured
k8s_eventKubernetes Warning eventsPush configured
red_metrics / red_summaryRED from OBI, gated / every serviceredMetrics.enabled
use_metrics / use_summaryUSE from cgroup v2 and PSI, gated / every serviceuseMetrics.enabled
log_metricsError and warn deltas per service, one sample line--log-metrics-enabled
topology_snapshotObserved L7 calls and optional L4 flows--topology-enabled
sensor_stateSensor health every 60 s, unstamped, not tenant data. Lists each OTLP source with its last push and per-framework counts: RED and LOG data points, USE podsAny collector
resync (inbound)Snapshot request on graph load. Core asks the agent to resend one tenant's virtual-app snapshot after that tenant's service graph reloads. The agent answers with snapshot frames and an end marker; no metrics or topology are resentAlways

Wire formats

Inside the cluster the agent speaks native OpenTelemetry. OBI emits OTel metrics under the semantic conventions, and the sensor roles forward them unchanged as OTLP ResourceMetrics with cumulative temporality. No custom metric model sits between OBI and the collectors.

OTel metricTypeUsed for
http.server.request.durationHistogramRED and SIG for HTTP; status from http.response.status_code
rpc.server.call.durationHistogramRED and SIG for gRPC; status from rpc.response.status_code
http.client.request.duration, rpc.client.call.duration, db.client.operation.durationHistogramL7 topology edges
obi.network.flow.bytesCounterL4 topology edges
k8s.namespace.name, service.nameResource attributesIdentity of every series

The sensor stream wraps those metrics in a small protobuf envelope. Each tick a sensor sends one Snapshot per role and one SensorState; the controller acknowledges each message by sequence number.

service SensorService { rpc Stream(stream StreamRequest) returns (stream StreamResponse); }

message StreamRequest { uint64 seq; Timestamp time; oneof { Hello | Snapshot | SensorState } }
message Snapshot {
  string role = 1;
  repeated opentelemetry.proto.metrics.v1.ResourceMetrics resource_metrics = 2;
}
message StreamResponse { Ack { uint64 seq } }

The collectors turn the cumulative histograms into per-window request count, error count and p99 per service, then gate them. What leaves the cluster is one JSON frame per WebSocket text message: a shared envelope with exactly one payload field named after the kind. The red_metrics example below uses illustrative values.

{ "v": 1, "kind": "red_metrics", "cluster": "<cluster>", "tenant": "<tenant>",
  "redMetrics": {
    "windowStart": 1759834800, "windowSec": 60,
    "services": [{
      "namespace": "shop", "service": "checkout",
      "requestCount": 1840, "errorCount": 41,
      "sample": "GET /api/orders -> 502 x41",
      "p99Ms": 812, "baselineP99Ms": 140, "latencyBreach": true,
      "breach": "op-latency", "operation": "POST /api/orders",
      "observed": 812, "baseline": 140, "evidenceSec": 180
    }]
  } }

The payload fields are hello, snapshot, appDelta, k8sEvent, logMetrics, redMetrics, redSummary, useMetrics, useSummary, topologySnapshot and sensorState. Field names are a pinned contract with the core and must stay byte-identical on both sides; fields added later are optional.

Wire caps

CapValue
Services per metrics frame100, larger frames chunked
Sample text256 bytes
Operation name128 bytes
Topology200 nodes, 500 edges
External address128 bytes
05

Log samples

A log_metrics frame carries one sample line per service so the core can see what the errors say. The fingerprint that groups errors is masked; the sample that leaves the cluster is the line with secrets and email addresses replaced by ***, then cut to length.

FROM ERROR LINE TO log.sample Error lineregex: level, panic: Extractmessage, msg, error, err Strip noisetimestamp, level tokens FingerprintUUIDs, hex, digits → # Exemplarredacted, 160 B per slot On the wirecut to 256 B series identityand dedup only Secrets and PII redacted before truncation
Figure 4. Masking applies to the fingerprint; redaction applies to the exemplar. The collector picks the sample of the error series with the largest positive delta in the window. Defaults: 160 bytes and 8 fingerprints per service.
ControlStatus
Length cap, 160 B exporter / 256 B wirepresent
Slot cap, 8 fingerprints per service; overflow counted with no samplepresent
Fingerprint maskingidentity only
Namespace scope and opt-outpresent
Secret and PII redaction of the sample: URI credentials, JWTs, Bearer and Basic credentials, secret-named keys, AWS key IDs, email addressespresent
URI credential scrub (user:***@host)present
Prompt guardrails before a model callcore-side
06

Connections and trust

Both links are encrypted and authenticated in both directions, and node roles open no port. Between the controller and the core, each side proves itself with a certificate inside the WebSocket: the core first, by signing a challenge nonce with its agent CA, then the agent, with its admin-approved Ed25519 certificate and a signature over the same nonce. This is mutual certificate authentication over TLS 1.3, carried in the WebSocket rather than as TLS client certificates.

Between a sensor and the controller, one-way TLS plus a token gives mutual authentication with nothing extra to run. The controller holds a self-signed ECDSA P-256 certificate that each sensor pins from the -sensor-ca ConfigMap, so the sensor knows it reaches the real controller. The sensor presents no client certificate. It sends its projected ServiceAccount token, audience triage-agent-sensors, and the controller checks it with a TokenReview, a call to the Kubernetes API server that verifies the token's signature, expiry, audience and bound pod and returns the ServiceAccount and node it was issued for. The controller needs only create on tokenreviews for this. The token must belong to the -ebpf ServiceAccount, and the node named in the token must match the node in the sensor's hello. The kubelet issues the token per pod, valid for one hour and rotated automatically, so there is no client CA, no per-node certificate and no shared client key. A compromised node cannot send data as another node.

Sensor podebpf, node-exporter,logs-based metrics Controllersensorstream :9470WSS client CoreWSS serveragent CA gRPC · TLS 1.3 only wss snapshot request only SENSOR → SENSORSTREAM Server: self-signed ECDSA P-256, pinned via -sensor-ca Client: projected SA token, audience triage-agent-sensors Checked by TokenReview; node name must match the hello CONTROLLER → CORE Core signs a nonce with its agent CA; agent pins the CA Agent answers with its approved Ed25519 cert and signature Mutual auth inside WSS, not TLS client certificates TRUST PER LINK
Figure 5. Authentication on each link.
  • Listeners. Only the controller listens: 9470 for the sensor stream when --sensor-listen is set, and 4317 for OTLP when --otlp-listen is set. Metrics on 8080 are off by default (--metrics-addr=0). No pprof in the repository.
  • Keepalive. 30 s with a 10 s timeout on both ends of the gRPC stream.
  • Certificate life. The sensor certificate is valid for 10 years and regenerated 30 days before expiry. The join certificate renews with less than a third of its life left.
07

RBAC

RBAC is read-only on workloads. The agent has no cluster-wide Secret or ConfigMap access and updates three named objects in its own namespace, each by resourceNames.

RoleGrants
ClusterRole, controllerget, list, watch on namespaces, events, pods, services, ingresses, deployments, statefulsets, daemonsets, replicasets, jobs, cronjobs, rollouts; get on endpoints. No Secrets, ConfigMaps or wildcards
ClusterRole, ebpfget, list, watch on pods, services, nodes, namespaces and workload kinds; pod informer limited to its node
Role -sensor-streamget, update on -sensor-tls and -sensor-ca
Role -joinget, update on -join
Role leader-electionLeases; create and patch events
ClusterRole -sensor-tokenreviewcreate tokenreviews

Process privilege

Both workloads run the same image, whose default user is nonroot (UID 65532, Chainguard static base). The controller keeps that user and needs no host access. The DaemonSet overrides it with runAsUser: 0 in its pod spec, in both modes below. It runs all sensor roles in one container, agent, so the traffic, resource and logs-based metrics roles share one security context. The chart offers two privilege modes for it, selected by ebpf.privileged.

Unprivileged (default)Privileged fallback
ebpf.privilegedfalsetrue
privilegedfalsetrue
CapabilitiesDrop ALL; add BPF, PERFMON, NET_RAW, SYS_PTRACE, DAC_READ_SEARCH, CHECKPOINT_RESTORE, plus ebpf.extraCapabilitiesAll
Privilege escalationBlockedNot restricted
SeccompRuntimeDefaultNot set
Root filesystemRead-onlyRead-only
User0 (root), overrides the image's nonroot0 (root), overrides the image's nonroot
Use whenKernels 5.11 and later, which load eBPF under this capability setOlder or unusual kernels that cannot load eBPF under the capability set

Root scope. UID 0 is what makes the six capabilities effective: Kubernetes has no field for ambient capabilities, and file capabilities on the binary are blocked by no_new_privs. Root here is not full privilege; it is UID 0 with every other capability dropped, privileged: false, no_new_privs, seccomp and a read-only root filesystem. hostPID already requires the privileged Pod Security level, with or without root. Comparable eBPF agents such as Beyla, Cilium, Falco and Tetragon also run as root, most with wider capabilities or full privilege.

Extra capabilities

CapabilityNeeded for
SYS_RESOURCEKernels before 5.11, to lift BPF verifier instruction and memory limits
SYS_ADMINGo services on GKE COS 6.12: without it every Go uprobe fails with permission denied and Go services report no RED; the sensor status points to this capability only when the failing executable is Go. Some kernels also need it for Go trace-context propagation

Both widen the container beyond the audited default; the chart advises trying without them first.

Host access

ItemWhenWhy
hostPIDTraffic metrics onOBI hooks other containers' processes; a namespace share has no capability substitute
hostNetworkebpf.networkFlows.enabledThe socket filter captures L4 flows in the host network namespace
/sys/fs/bpfAlwaysBPF filesystem for OBI's maps and programs
/sys/fs/cgroup, read-onlyResource metrics oncgroup v2 and PSI files
/var/log, read-onlyLogs-based metrics onContainer log files
08

Topology and drift

With --topology-enabled, the RED collector builds a topology_snapshot from OBI's client-side metrics and, when enabled, L4 flows. Counts accumulate across RED ticks and flush every 600 s as one full snapshot, so a lost frame heals on the next flush. The snapshot keeps the top 500 edges by calls and the first 200 nodes, and is skipped when every scrape in the tick failed.

frontend checkout cart payment cache catalog recommendation shipping currency email ad pay.example.com:443 smtp.example.com:587 10.4.0.12:5432 not declared workload external host drift
Figure 6. A sample topology_snapshot drawn as a graph. Nodes are workloads, never pods, sized by traffic. Each edge is one caller, callee and protocol, carrying the call count for the window, or bytes for an L4 flow. A destination outside scope is sent as an external host. The core flags an observed edge that the declared graph lacks, here checkout to cache, as drift.

The agent has no drift comparator. The core compares the snapshot with the declared service graph. A snapshot request does not resend topology; the next flush carries it.

09

Connectors

Each connector is its own Go module, deployed apart from the agent, that turns a provider's signals into events for the core events API.

ConnectorRuns asInputAuth to coreDelivery
gcpeventsCloud Run functionBilling cost anomalies and Monitoring alerts via Pub/Sub push; Service Health polled every 15 minBearer from Secret Manager, X-Tenant3 attempts, 4xx dropped
promeventsDeployment, port 8080Alertmanager webhook POST /alertmanagerProjected SA token (audience triage-core, 1 h) or static token, X-Tenant per alert15 s timeout, 3 attempts, 4xx except 408 and 429 dropped, duplicates suppressed 2 min

promevents runs non-root with a read-only root filesystem and all capabilities dropped; its Role in the core namespace grants verb ingest on named tenants only.

10

Configuration

Requirements

ItemRequirement
Kernel5.8 or later, 4.18 on RHEL-based distros; OBI refuses to start below that. The unprivileged default is documented for 5.11 and later; 5.8 to 5.10 need SYS_RESOURCE
BTFKernel built with CONFIG_DEBUG_INFO_BTF; OBI refuses to start without it
cgroupcgroup v2, for resource metrics
Linux hostsystemd 240 or later (D-Bus for unit discovery, journalctl for logs), cgroup v2, and the kernel and BTF above. Root, with the six capabilities the unit file grants
Controller resourcesRequests 50m CPU, 64Mi memory; limits 200m CPU, 512Mi memory
DaemonSet resourcesRequests 50m CPU, 128Mi memory; limits 300m CPU, 700Mi memory. Memory grows with the number of instrumented pods; CPU peaks near 130m during the attach burst after a restart

Flags and values

Flag or valueDefaultMeaning
--target-tenantemptyPrimary tenant; empty selects cluster mode
--target-namespacesemptyStatic scope list beside annotated namespaces
--workload-resourcesDeploy, STS, DS, RolloutWorkload kinds watched
--virtual-appsonSynthesize virtual apps
--sensor-listenemptySensor stream address; empty disables it and its collectors
otlp.enabledfalseOTLP/gRPC receiver on 4317; turns on the sensor stream without the DaemonSet
otlp.clientCASecretemptySecret with ca.crt for mutual TLS
otlp.allowedServiceAccountsemptynamespace/name or namespace/* allowed to send with a token
--sensor-tick15sSensor send interval
--log-metrics-enabledfalseLog collector; needs push
--log-metrics-interval-seconds60Window
--log-metrics-error-threshold10Error or warn delta that qualifies a window
--log-baselinetrueMedian gate: ×3, ≥ 5 windows
--log-exporter-sample-max-len160Bytes kept for fingerprint and sample
--log-exporter-max-samples-per-service8Distinct fingerprints per service
--topology-enabledfalseTopology snapshots
--topology-interval-seconds600Snapshot flush
--push-allow-plaintextfalseAllow ws:// beyond localhost and .svc
--metrics-addr0Self-metrics listener; 0 disables
ebpf.privilegedfalseFully privileged fallback for older kernels
ebpf.networkFlows.enabledfalseL4 flows; DaemonSet on hostNetwork
replicaCount1; production runs 3Controller replicas; above 1 turns on leader election, replica gossip and a PodDisruptionBudget
11

OBI note

third_party/obi is an owned fork of OBI v0.12.2 with 11 local commits, including eBPF C changes, and is not tracked upstream; Apache-2.0 attribution stays in NOTICE. Its Go offsets table, used for binaries without debug info, covers Go up to 1.26.4 and grpc-go up to 1.81.1. Refresh it with OBI's tracker when new Go or grpc-go releases land, and keep scanning its go.mod for CVEs.

12

FAQ

What is the difference between the eBPF and OTLP modes?

Both feed the same collectors, gates and frames; they differ in where the numbers come from. In eBPF mode the DaemonSet reads them on each node: OBI hooks see every HTTP and gRPC call without code changes, cgroup files give USE and PSI, container log files give LOG, and a socket filter adds L4 flows. In OTLP mode the triage DaemonSet does not run; an OpenTelemetry Collector pushes metrics to the controller on port 4317. RED and L7 edges come from semantic-convention histograms or span metrics, LOG from the count connector, and USE with PSI from each node's kubelet, which reads the cgroup pressure files itself and serves them on /metrics/cadvisor. The Collector still has to reach every node: as a DaemonSet each pod scrapes its own node's kubelet, or one Deployment discovers the nodes through the Kubernetes API and scrapes them all. The two can run side by side; where both report the same service or pod, the sensor's numbers are used.

eBPFOTLP
On each nodeTriage DaemonSet with hostPID, root, six capabilities, reading /sys/fs/cgroupThe kubelet's own /metrics/cadvisor, scraped by the Collector (a DaemonSet, or one Deployment for all nodes) with get on nodes/metrics
CoverageEvery process on the node, any languageOnly what is traced or scraped
Service graphL7 calls and L4 flowsL7 edges from client spans that name the server; no L4
NeedsKernel 5.8+, BTF, cgroup v2A Collector with spanmetrics, a kubelet cAdvisor scrape and the count connector; Kubernetes 1.34+ for PSI
Sandboxed runtimesMostly invisibleSeen if instrumented

Measured side by side on one cluster, OTLP alone matched eBPF for USE and for RED on traced services, and kept about a tenth of the graph's edges once L4 flows were gone.

Can the agent run outside a cluster in OTLP-only mode?

Yes, as long as it can reach a Kubernetes API. The controller loads a kubeconfig when it is not in a pod, so it runs on a laptop or a VM against any cluster it can reach. Set POD_NAMESPACE to the namespace that holds its Secrets, turn leader election off for a single copy, and give the kubeconfig identity the agent's RBAC, including create on TokenReviews when senders use tokens. Senders must reach that machine on 4317 and verify it by name, so add that name to the serving certificate with --sensor-extra-names; a Collector next to it is the simple setup, and every point still needs k8s.namespace.name to land in a tenant. A host with no Kubernetes API at all runs the Linux host mode (see the last question).

What sensitive information leaves the cluster?

Only the frames in section 04, over the one outbound WebSocket connection to the core. They carry:

  • names: cluster, namespace, workload, service, and pod names in USE and events;
  • aggregates per window: request, error and log counts, p99 latency, CPU, memory and PSI percentages;
  • operation names: HTTP route templates, RPC methods and the status codes seen;
  • topology edges between workloads, and the host name of a destination outside scope;
  • the message of Kubernetes Warning events;
  • one log line per service, redacted for secrets and email addresses and cut to 160 bytes (section 05);
  • sensor health and the OTLP source list; a tenant sees only its own OTLP sources, and role error text only reaches the all-tenant view.

Request and response bodies, headers, raw logs, traces, environment variables and Secrets never leave. Namespaces without a tenant and objects opted out with triageagent.dev/triage: "false" are not sent at all.

Why are connectors needed?

The agent sees what runs in the cluster. Some failures start outside it: a cost anomaly or a Service Health incident in the cloud provider, an alert from an existing Prometheus rule, a security finding. Connectors bring those into the same tenants and issues as events, so a cloud outage and the errors it causes in a service are correlated in one place. Each connector is a small separate deployment that posts to the core events API with its own credentials (section 09), so the agent itself needs no cloud permissions.

How can a Linux host be instrumented?

With --mode=host, the agent runs as one systemd service on a VM or bare-metal host, with no Kubernetes API. The host is a cluster of one: --cluster-name (default the host name) names it, --target-tenant sets the tenant since a host has no namespaces (figure 2), and every selected systemd unit is a virtual app in one synthetic namespace (--host-namespace, default host). USE comes from each unit's cgroup, LOG from journald and log files, and RED from OBI in a build with the obi tag, which the release binaries are. The agent joins like a cluster: it prints a fingerprint, an admin runs triagectl agent approve, and the key then stays in /var/lib/triage-agent/join. The release carries the binaries, the unit file with the same six capabilities as the DaemonSet and a deb and rpm package; the README has the steps. Host containers (Docker, Podman) are not covered yet. Alerts from a host's existing Prometheus still arrive through promevents.