How to expose tailscaled metrics¶
This is the operator how-to for getting per-node Tailscale metrics out of tailscaled and into the optional node_metrics scraper. It is the companion to the Metrics & Logs Reference - that page documents every signal tailscale2otel emits (including the Node metrics scraper section that describes how forwarded series look once they land in Grafana Cloud); this page covers the upstream side: how a node publishes its metrics, what the metrics are, the access control you need, and how to point the scraper at them.
These per-node metrics are separate from the tailnet-wide signals tailscale2otel derives from the Tailscale API/log stream. They come straight off each host's tailscaled daemon and describe that node's own data plane (throughput, dropped packets, routes, DERP/peer-relay state). The scraper forwards them verbatim - they are not renamed into the curated tailscale.* namespace.
Setup summary¶
- On each node you want to monitor, enable the client metrics endpoint (Tailscale v1.78.0+):
Open TCP port 5252 to your monitoring host with a tailnet ACL grant (see Access control).
Point the scraper at
http://<node-tailscale-ip>:5252/metricsincollectors.node_metrics. Native endpoints are plain HTTP with no token and no TLS, so you configure nothing else.
What endpoints tailscaled can expose¶
Client (host) metrics - the supported path¶
Since Tailscale v1.78.0, the client can serve its own metrics in Prometheus text format. Enable it once per node:
Once enabled, the same /metrics payload is reachable two ways:
| Scope | URL | Reachable from |
|---|---|---|
| Local (on the node itself) | http://100.100.100.100/metrics | the node only - 100.100.100.100 is the Tailscale "quad-100" local address |
| Over the tailnet | http://<node-tailscale-ip>:5252/metrics | any tailnet peer allowed by ACL to reach TCP 5252 |
Both are plain HTTP. There is no bearer token and no TLS on the native endpoint - see Access control for how reachability is actually gated.
For the scraper you want the tailnet form (:5252), because tailscale2otel runs on a different host from the nodes it scrapes. The quad-100 local form is handy for a quick curl http://100.100.100.100/metrics sanity check while you are logged into the node.
CLI alternatives (no listener)¶
If you cannot or do not want to open a port - for example you already run a Prometheus node_exporter textfile collector on the host - tailscale can print or dump the same metrics locally:
tailscale metrics print # write the Prometheus-text metrics to stdout
tailscale metrics write <file> # write them to a file (e.g. a textfile-collector .prom)
These feed an existing scrape pipeline on the node rather than being scraped by tailscale2otel directly.
The tailscaled debug server - avoid for monitoring¶
tailscaled --debug=localhost:8080 exposes /debug/metrics. This is localhost-only, unauthenticated, and an UNSTABLE superset of the client metrics whose names and contents can change between releases. Prefer the documented client metrics (--webclient → :5252) for anything you intend to alert or build dashboards on; treat /debug/metrics as an interactive debugging aid only.
Documented metric families¶
These are the stable client-metric families exposed on :5252/metrics. Names are emitted as-is by tailscaled; the scraper forwards them verbatim (Grafana Cloud's standard OTLP→Prometheus normalization still applies on ingest - see the reference).
Counters¶
| Metric | Meaning |
|---|---|
tailscaled_inbound_packets_total / tailscaled_outbound_packets_total | Packets received / sent, by path. |
tailscaled_inbound_bytes_total / tailscaled_outbound_bytes_total | Bytes received / sent, by path. |
tailscaled_inbound_dropped_packets_total / tailscaled_outbound_dropped_packets_total | Packets dropped on the inbound / outbound path. |
tailscaled_peer_relay_forwarded_packets_total / tailscaled_peer_relay_forwarded_bytes_total | Packets / bytes this node forwarded while acting as a peer relay. |
Gauges¶
| Metric | Meaning |
|---|---|
tailscaled_advertised_routes | Number of subnet routes this node advertises. |
tailscaled_approved_routes | Number of its advertised routes that are approved. |
tailscaled_peer_relay_endpoints | Number of peer-relay endpoints currently configured. |
tailscaled_health_messages | Count of active client health-warning messages. |
tailscaled_home_derp_region_id | The node's current home DERP region ID. |
The path label and cardinality¶
Cardinality warning. The throughput counters (
tailscaled_{inbound,outbound}_{packets,bytes}_total) carry apathlabel whose values aredirect_ipv4,direct_ipv6,derp,peer_relay_ipv4, andpeer_relay_ipv6. Series count therefore scales as nodes ×path× metric. Scraping a large fleet multiplies quickly: budget for it, and prefer scraping a representative subset (e.g. relays and exit nodes) over every node if you only need fleet-level throughput. Use the cardinality controls in Configuration and monitor the scraper and active-series signals in the metrics catalog when sizing DPM and ingest cost.Version note. Nodes older than v1.78.0 do not serve
:5252/metrics. The scraper marks such a target as down (tailscale.node.up→0) rather than emitting node series for it.
Access control - the only auth you need¶
The native client-metrics endpoint has no application-layer auth. Access is gated entirely at the tailnet layer: a peer can only reach :5252 on a node if your tailnet policy grants it. This is the standard Tailscale model - reachability is the authorization.
Open TCP port 5252 from your monitoring identity to the nodes you want scraped with a grant in your tailnet policy file. For example, allow hosts tagged tag:monitoring (where tailscale2otel runs) to reach port 5252 on hosts tagged tag:server:
Tighten src/dst to match your tagging scheme. Because there is no token to manage and no TLS to terminate, the grant is the access-control boundary - keep it as narrow as possible.
Wiring up the scraper¶
The scraper lives under collectors.node_metrics and is off by default.
Static targets and per-target labels/headers must be set via a config file, not environment variables. The
targetsfield is a list of structs; flatTS2OTEL_*env vars can only override scalar fields. Scalar scraper settings (enabled, interval, timeout, metric_allow, etc.) can be overridden via env as usual - e.g.TS2OTEL_COLLECTORS__NODE_METRICS__INTERVAL=30s.
A minimal static-target config for native endpoints needs nothing more than the URL:
collectors:
node_metrics:
enabled: true
interval: 60s
timeout: 10s
max_response_bytes: 4194304 # per-target response cap (4 MiB)
max_samples: 50000 # per-target sample cap per scrape
targets:
- url: "http://100.64.0.10:5252/metrics"
instance: "relay-1" # defaults to the URL host:port if omitted
labels: { role: "relay" } # extra static labels merged onto every series
Native tailscaled endpoints are plain HTTP - leave all the auth/TLS fields unset and the scrape is a plain GET with no added headers.
Dynamic discovery first run¶
For a fleet that changes over time, the scraper can discover targets from the Tailscale devices API instead of requiring one static entry per node. Static targets remain supported; discovery is an additional path and can be used with them or on its own. This is a complete discovery-only configuration (the explicit empty list makes it clear that no static endpoint is required):
collectors:
node_metrics:
enabled: true
targets: [] # discovery-only; omitting this has the same empty default
discovery:
enabled: true
collectors.node_metrics.enabled is the master switch and discovery.enabled turns on the device-inventory refresh. The discovery block's defaults are deliberately conservative:
| Setting | Default | What it does |
|---|---|---|
online_only | true | Keeps only devices currently connected to the control plane. |
exclude_external | true | Skips shared/external devices. |
include_tags | [] | An empty list matches every device; otherwise a device needs any listed tag. |
exclude_tags | [] | A device with any excluded tag is skipped; exclusions win over include_tags. |
address_order | ipv4 | Prefers a Tailscale IPv4 address and falls back to IPv6. Set ipv6 to reverse that preference. |
scheme | http | Builds plain-HTTP client-metrics URLs by default. |
port | 5252 | Uses the Tailscale client-metrics listener. |
port_overrides | {} | Optional YAML map from tag strings to non-empty port lists. Matching tags replace port; multiple matching tags produce a deduplicated, sorted union. Devices with no matching tag use port. This is file-only. |
path | /metrics | Uses the documented client-metrics path. |
max_targets | 1000 | Caps the number of discovered targets emitted on each refresh; static targets are not counted against this cap. |
instance_source | name | Uses the MagicDNS short name for the tailscale.node identity label. address uses the host:port, while hostname uses the OS hostname with the node address appended for stable uniqueness. |
Discovery selects only usable Tailscale addresses (IPv4 in 100.64.0.0/10 or IPv6 in fd7a:115c:a1e0::/48); a device with no such address is skipped. Discovered targets also carry host.name/host.id and tailscale.tags by default, which makes them joinable with device metrics. Set include_tags: ["tag:server"] to limit discovery to tagged nodes, for example.
Before the first refresh and scrape, make sure both sides are ready:
- On every candidate node, run
tailscale set --webclienton Tailscale v1.78.0 or newer. The scraper then requestshttp://<node-tailscale-ip>:5252/metrics, the client-metrics endpoint described above. - Grant the exporter host/peer access to TCP port 5252 on those nodes in the tailnet ACL. The endpoint has no bearer-token or TLS authentication; the ACL grant is its access boundary. See Access control for the grant shape.
- Give the exporter’s Tailscale API credential
devices:core:readfor the devices inventory used by discovery (GET /devices). Use the broader read-onlyall:readonly when the same credential also serves other enabled collectors that require additional API surfaces. Discovery does not evaluate ACLs or pre-probe reachability: an API-visible but blocked node becomes a target and reportstailscale.node.up=0when its scrape fails.
The first collection tick performs a discovery refresh immediately; later refreshes use discovery.interval (default 5m), independently of the scrape interval. The active set is the union of static and discovered targets. Static entries are kept first, and a discovered URL that is already present in targets is dropped, so the static target's labels, credentials, and TLS settings win and the endpoint is not scraped twice. If a refresh fails, the previous active set is retained and discovery retries on the next collection tick. With the discovery-only YAML above, tailscale2otel.nodemetrics.discovery.targets is therefore the number of discovered targets after a successful refresh.
After the first successful refresh and scrape, use this Grafana Explore query to require a successful discovery, a non-empty discovered target set, target health, and at least one forwarded raw tailscaled_* series per discovered node:
(
(tailscale_node_up_ratio == 1)
and on (tailscale_tailnet, tailscale_node)
(count by (tailscale_tailnet, tailscale_node) ({__name__=~"tailscaled_.+"}) > 0)
)
* scalar(min(tailscale2otel_nodemetrics_discovery_success_ratio))
* scalar(sum(tailscale2otel_nodemetrics_discovery_targets))
> 0
Each returned tailscale_node is an active target that answered and forwarded at least one raw family; when static targets are also configured, the result can include them. The min(...) term requires the last discovery refresh to have succeeded for every tailnet in a multi-tailnet deployment, while the sum(...) term separately proves a non-zero discovered target set; both are reduced to one scalar before filtering the per-node results. If a node exposes only cumulative counters, run the query after the second scrape: the first counter observation establishes its delta baseline, while gauges and tailscale.node.up are available on the first scrape.
Tailscale Kubernetes operator¶
Operator proxy pods are ordinary discovered tailnet devices, but their metrics listener is on
9002, not standalonetailscaled's5252.Enable metrics with
ProxyClass.spec.metrics.enable, then setProxyClass.spec.statefulSet.pod.tailscaleContainer.envto bind the metrics listener on the tailnet address:
spec:
metrics:
enable: true
statefulSet:
pod:
tailscaleContainer:
env:
TS_LOCAL_ADDR_PORT: "[::]:9002"
The default cluster-IP bind is invisible from the tailnet. Keep 9002 because the operator hardcodes it in the metrics Service.
The tailnet ACL must grant the scraping host's tag to the proxy tags on
tcp:9002.For diagnosis, a missing ACL is a timeout (
curlexit 28); a missing listener is connection refused (curlexit 7). Tailscale silently filters traffic that the ACL does not grant.kube-apiserverProxyGroups cannot be scraped via the tailnet:cmd/k8s-proxyignoresTS_LOCAL_ADDR_PORTand bindsPOD_IPunconditionally. An in-cluster ServiceMonitor is supported for those pods; their tailnet unreachability is not a defect in this scraper.Use
port_overridesfor the operator ports while leaving devices without a matching override ondiscovery.port(5252). For example:
collectors:
node_metrics:
enabled: true
discovery:
enabled: true
port: 5252
port_overrides:
"tag:k8s": [9002]
"tag:k8s-egress": [9002]
"tag:k8s-apiserver": [9002]
A device carrying more than one listed tag gets the sorted, deduplicated union of those port lists. The tag:k8s-apiserver targets remain down when reached through the tailnet, as described above.
What the scraper emits¶
- Verbatim series. Each scraped
tailscaledmetric is re-emitted with its original name and labels preserved (not renamed intotailscale.*). - Counters → deltas, gauges → gauges. Cumulative counters (and histogram/summary components) are converted to monotonic deltas across scrapes; gauges are forwarded as gauges.
- Per-target
tailscale.node.up. A health gauge (→tailscale_node_up_ratio) is emitted per target reporting whether the last scrape of that node succeeded. - Node identity = the
tailscale_nodelabel. Every forwarded series carries atailscale.nodeattribute (Prometheus-normalized totailscale_node) set to yourinstance:value, or the URLhost:portif omitted - identity is a label, not an OTEL Resource, and deliberately not calledinstance: Grafana Cloud's OTLP→Prometheus translation promotes the resource attributeservice.instance.idto theinstancelabel, and that resource value would win, collapsing every node onto a single series.instance:in config only names the operator-facing field you set per target - it does not name the resulting attribute key. See the reference for how these query in Grafana.
Curated metrics¶
On top of the verbatim forward, the scraper emits a small set of curated, first-class metrics derived from specific tailscaled families. These are the catalog-documented tailscale.node.* series that dashboards and alerts build on, with stable OTel-native attributes instead of the raw per-node label spellings. Curation is purely additive: the raw tailscaled_* series is still forwarded byte-for-byte exactly as above - the curated series are emitted in addition, never instead.
| Source family | Curated metric | Attributes |
|---|---|---|
tailscaled_inbound_bytes_total / tailscaled_outbound_bytes_total | tailscale.node.io (By) | network.io.direction (receive/transmit), tailscale.path |
tailscaled_inbound_packets_total / tailscaled_outbound_packets_total | tailscale.node.packets ({packet}) | network.io.direction, tailscale.path |
tailscaled_inbound_dropped_packets_total / tailscaled_outbound_dropped_packets_total | tailscale.node.packets.dropped ({packet}) | network.io.direction, tailscale.drop.reason |
tailscaled_peer_relay_forwarded_bytes_total | tailscale.node.peer_relay.io (By) | - |
tailscaled_peer_relay_forwarded_packets_total | tailscale.node.peer_relay.packets ({packet}) | - |
tailscaled_peer_relay_endpoints | tailscale.node.peer_relay.endpoints | - |
tailscaled_health_messages | tailscale.node.health_messages | tailscale.health.type |
tailscaled_home_derp_region_id | tailscale.node.derp.home_region (value = region ID) | - |
Every curated series also carries the tailscale.node identity label, exactly like tailscale.node.up.
Attribute folding (deliberate cardinality reduction). tailscale.path collapses the raw path label's IP-version split - direct_ipv4/direct_ipv6 → direct, peer_relay_ipv4/peer_relay_ipv6 → peer_relay, derp stays derp - halving the per-node path fan-out; any unrecognized value folds to other. tailscale.drop.reason is a bounded admit-set (acl, error) with any other value folded to other, since scraped labels come from semi-trusted tailnet-member nodes. tailscale.health.type is passed through as-is (the value set is code-defined in tailscaled, not free text). The raw forwarded series keep their original, unfolded path/reason values - folding applies only to the curated copy.
Counters share the delta. A curated counter (tailscale.node.io, .packets, .packets.dropped, .peer_relay.*) consumes the same per-scrape delta the raw forward computes for its source series - including counter-reset detection and stale-baseline eviction - so there is no second baseline and no double-counting.
Filters bypass curation. The metric_allow / metric_deny / drop_labels passthrough filters apply only to the raw forward. Curated metrics are catalog metrics, not passthrough, so they are emitted regardless of those filters - denying tailscaled_.* still yields the curated tailscale.node.* series.
Scrape and discovery safety limits¶
The scraper enforces bounded work per node. max_response_bytes caps the number of bytes read from one target response (default 4194304, 4 MiB), and max_samples caps the number of valid samples accepted from one scrape (default 50000). A target that exceeds either limit is treated as a failed scrape and reports tailscale.node.up=0 for that collection.
When dynamic discovery is enabled, discovery.max_targets caps the number of discovered targets emitted in one refresh (default 1000), not the number of devices. Use include_tags/exclude_tags together with this cap to keep automatic discovery scoped to the nodes you intend to scrape. Both accept comma-separated values as env vars:
TS2OTEL_COLLECTORS__NODE_METRICS__DISCOVERY__INCLUDE_TAGS=tag:server,tag:relay
TS2OTEL_COLLECTORS__NODE_METRICS__DISCOVERY__EXCLUDE_TAGS=tag:exit-node
Optional per-target auth & TLS (proxied / HTTPS targets only)¶
You only need these fields if you front a node's metrics behind an HTTPS reverse proxy or an authenticating gateway - for example a Prometheus-compatible proxy that fans several nodes out behind one TLS endpoint. Native tailscaled endpoints need none of them.
targets:
- url: "https://relay-2.ts.net:5252/metrics"
bearer_token_file: "/run/secrets/scrape-token" # read fresh each scrape (rotation-safe)
# bearer_token: "..." # inline alternative; bearer_token_file wins
headers: { X-Scope-OrgID: "1" } # extra request headers (e.g. tenant routing)
tls:
ca_file: "/etc/ssl/ca.pem" # custom CA to trust
cert_file: "/etc/ssl/client.pem" # client cert (mTLS)
key_file: "/etc/ssl/client-key.pem" # client key (mTLS)
server_name: "relay-2.ts.net" # SNI / cert name override
insecure_skip_verify: false # DEFAULT false — leave it false
Field notes:
| Field | Purpose |
|---|---|
bearer_token | Static Authorization: Bearer <token> header. |
bearer_token_file | Path read fresh on every scrape (rotation-safe); takes precedence over bearer_token. |
headers | Arbitrary extra request headers (e.g. tenant routing). |
tls.ca_file | Custom CA bundle to trust for the HTTPS target. |
tls.cert_file / tls.key_file | Client certificate / key for mutual TLS. |
tls.server_name | Overrides the SNI / expected certificate name. |
tls.insecure_skip_verify | Defaults to false - an HTTPS target is verified unless you explicitly opt out. Setting true disables verification and is a deliberate footgun; avoid it. |
A target with a non-empty tls block gets its own dedicated HTTP client/transport; targets without one share the default client.
Quick verification¶
From the node itself (local quad-100 address):
From the monitoring host (over the tailnet, after the ACL grant is in place):
You should see Prometheus-text output beginning with # HELP / # TYPE lines and the tailscaled_* families above. If the tailnet curl hangs or is refused, the grant for port 5252 is missing or too narrow. If it returns 404 / empty, --webclient is not enabled on that node, or the node predates v1.78.0.
Once the scraper is running, confirm each target is healthy in Grafana:
A value of 1 per tailscale_node means the last scrape of that node succeeded.
Sources¶
- Tailscale client metrics reference - https://tailscale.com/kb/1482/client-metrics
- Tailscale KB 1192 - ACL syntax / grants - https://tailscale.com/kb/1192/acl-tags
- Tailscale KB 1278 - tailnet policy file (grants) - https://tailscale.com/kb/1278/tailnet-policy-syntax
- Tailscale CLI -
tailscale set,tailscale metrics- https://tailscale.com/kb/1080/cli
See also the Metrics & Logs Reference for the full catalog of signals tailscale2otel emits and how the forwarded node series appear in Grafana Cloud.