Every application built on cli-framework with the telemetry feature gets
OpenTelemetry instrumentation for free: a level a person controls, a catalogue
of named probes that says what each level sends in words, a redacting export
boundary, and a telemetry command group for reading and changing all of it.
This document is written for three readers, in this order: the person running the app, the author building one, and the operator running a fleet of them.
Nothing.
On an end-user installation — a CLI on somebody's laptop — the telemetry level
defaults to off. Nothing is collected, nothing is exported, no network
connection is opened, and no identifier is generated. Turning it on is a
deliberate act performed by the person at the keyboard.
The first time such an app runs interactively it prints one two-line notice to stderr and never mentions it again:
demo: usage statistics are off.
Turn them on with `demo telemetry set usage`; see what would be sent with `demo telemetry info`.
The notice is suppressed when stderr is not a terminal, on non-interactive
surfaces, when a kill switch is set, and once the person has chosen a level
themselves. An app that declares a privacy page gets Details: <url> appended
to the second line.
A service deployment is the other case: a long-running server started by an
operator who configured a collector endpoint. There, the level defaults to
diagnostic when an endpoint exists, and there is no notice — the operator
already knows, because they configured it.
Even with a level set, nothing leaves the process without an endpoint.
Export requires a level above off and a configured OTLP endpoint.
There are four telemetry levels, ordered:
| Level | Meaning |
|---|---|
off |
Collect nothing, export nothing. The end-user default. |
usage |
That the app ran and what was asked of it. No arguments, no messages, no addresses. |
diagnostic |
Adds error kinds, argument names, configuration and secret-store activity, outbound request shape. |
debug |
Adds the text of things: panic messages, the token that failed to parse, allowlisted argument values. Meant for a support session, not for a fleet. |
A probe is a named unit of instrumentation with a minimum level, a one-line
summary, and a plain sentence saying what it sends. A probe is effective when
the telemetry level is at least its minimum and the probe and all of its
ancestors are enabled. Dotted ids are hierarchical: cli.command.args is a
child of cli.command, and disabling the parent disables the child.
The framework ships twenty built-in probes:
| Probe | Minimum level | What it sends |
|---|---|---|
cli.process |
usage | That the app ran, its version, and the exit status class |
cli.command |
usage | The registered command path, the invocation surface, duration and status |
cli.command.args |
diagnostic | Argument names and how many there were, never their values |
cli.command.arg_values |
debug | Values of arguments the author explicitly allowlisted, and no others |
cli.usage_error |
usage | The kind of mistake: unknown command, unknown flag, missing argument, invalid value or failed validation |
cli.usage_error.token |
debug | The offending token itself |
cli.panic |
usage | That the app panicked and the source location, never the message |
cli.panic.message |
debug | The panic message text |
cli.help |
usage | Which command's help was asked for |
cli.feature |
usage | The name of a feature the author registered, and nothing else |
cli.auth |
usage | That a login, logout, refresh or failure happened, never a credential |
cli.config |
diagnostic | Which setting was touched and whether it succeeded, never the value |
cli.secrets |
diagnostic | Which backend was used and whether it succeeded, never a secret |
cli.doctor |
usage | Which checks ran and how severe their findings were |
cli.plugin |
diagnostic | Which plugin loaded or failed to load |
cli.chat |
usage | That a chat session ran and how long it lasted, never prompt text |
http.client |
diagnostic | Method, status and duration, never the URL path or query |
http.client.server_address |
diagnostic | The destination host name, on the span only |
http.server |
usage | The matched route template, method, status and duration |
mcp.session |
usage | That an MCP session ran, which tools it called and how long it lasted |
Applications register probes of their own with
with_telemetry_ops; those appear in the same catalogue and
get the same switches.
The telemetry command group is present on end-user deployments. The output
below was captured from a real application called demo; only the settings-file
path is substituted, because the capture ran against a temporary directory and
the real one is platform-dependent.
$ demo telemetry status
telemetry level: off (source: default)
endpoint: none configured; nothing exports
attribution: pseudonymous
install id: none (anonymous)
organisation policy: not managed
settings file: /home/you/.config/demo/telemetry.json
probes:
cli.auth (min level usage): enabled, not currently effective -- Sign-in activity
cli.chat (min level usage): enabled, not currently effective -- Chat sessions
cli.command (min level usage): enabled, not currently effective -- Which command ran
... one line per probe, alphabetically ...
source: names the layer the level came from — default, config_file,
environment, flags, or an organisation policy. install id: none (anonymous) is literal: at level off no identifier exists to send.
Add --json for a machine-readable form with the same information. The keys
are level, level_source, attribution, install_id, endpoint,
endpoint_source, policy, kill_switch, probes and store:
$ demo telemetry status --json
{"level":"usage","level_source":"config_file","attribution":"pseudonymous","install_id":"b1e111de-41ec-493e-90fb-f1f3285b941a","endpoint":null,"endpoint_source":null,"policy":"organisation policy: not managed","kill_switch":null,"probes":[{"id":"cli.auth","min_level":"usage","enabled":true,"effective":true,"summary":"Sign-in activity"}, ...],"store":"/home/you/.config/demo/telemetry.json"}
$ demo telemetry set usage
telemetry level set to usage
Accepts off, usage, diagnostic and debug. The choice is written to the
settings file and takes effect on the next run. Raising the level above off
for the first time is also what generates the install id.
status says what is on. info says what that means, probe by probe,
including the sends: sentence:
$ demo telemetry info
telemetry probe catalog:
cli.auth (min level usage): effective -- Sign-in activity
sends: That a login, logout, refresh or failure happened, never a credential
cli.chat (min level usage): effective -- Chat sessions
sends: That a chat session ran and how long it lasted, never prompt text
cli.command (min level usage): effective -- Which command ran
sends: The registered command path, the invocation surface, duration and status
... one entry per probe ...
$ demo telemetry disable cli.command.args
probe cli.command.args disabled
$ demo telemetry enable cli.command.args
probe cli.command.args enabled
Disabling a parent disables its children. This is how to keep a level while excluding one specific thing.
$ demo telemetry reset
telemetry settings deleted; the next run starts over as a new install, with a new id
reset deletes the framework's telemetry file outright: level, install id,
first-run notice marker and probe switches go together. That is deliberate —
see ADR 0077. The next run
behaves exactly like a fresh installation, including the notice.
The framework owns one file, separate from the application's own configuration:
<config dir>/<app>/telemetry.json # or telemetry.toml, matching the app's config format
It holds the level, the install id, the notice marker and the probe switches, and it never roams to another machine: consent is granted on the machine it applies to. If the configuration directory cannot be found, telemetry does not fail the app — it degrades to anonymous, reports a doctor finding, and carries on.
Three environment variables disable telemetry completely, before any configuration is read. They are checked in this order:
| Variable | Value | Scope |
|---|---|---|
<APP>_TELEMETRY_DISABLED |
1 |
This application. <APP> is the app name upper-cased, with - and . replaced by _. |
OTEL_SDK_DISABLED |
true |
Every OpenTelemetry SDK in the process. |
DO_NOT_TRACK |
1 |
Every tool on the machine that honours the convention. |
DO_NOT_TRACK and OTEL_SDK_DISABLED are honoured because other tools
honour them. Someone who sets DO_NOT_TRACK=1 in their shell profile has
made a general statement about their machine, and an app that quietly exempted
itself would be defeating the point. OTEL_SDK_DISABLED is the OpenTelemetry
specification's own switch, and a process where the SDK is disabled must not
have a second telemetry pipeline running beside it.
A kill switch is absolute: no provider is built, no exporter is created, and
telemetry status reports which switch fired. Nothing in configuration, no
organisation policy, and no command-line flag can re-enable it.
Some values are excluded by construction, not by policy — no telemetry level, no allowlist and no application code can bring them back.
Never, at any level:
- host names, user names, the current directory,
$HOME - environment variable values
- IP addresses
- raw URLs, URL paths and query strings
- file paths and file names taken from arguments
- prompt text and message content
Attribute keys that are always dropped: url.full, url.path,
url.query, host.name, process.command_line.
Key fragments that are always dropped, wherever they appear in a key:
password, secret, token, authorization, cookie, api_key. An
application can add more of its own with
with_telemetry_never; it cannot remove any of these.
Metric labels are allowlisted, not filtered. Only command, surface,
status, kind, feature, check, severity, tool, plugin,
http.route, http.request.method and http.response.status_code may appear
on a metric, and command only for command paths the application registered —
never a string a person typed. This bounds metric cardinality as much as it
protects privacy.
exception.message is a debug-only attribute; error.type — the kind of
error, not its text — appears from diagnostic.
Telemetry is on the telemetry feature, which implies observability,
config and doctor:
cli-framework = { git = "https://github.com/aroff/cli-framework", features = ["telemetry"] }An app that does nothing else already gets the whole catalogue, the command group, the notice and the doctor checks. The builder API is for the parts only the author knows.
use cli_framework::app::AppBuilder;
use cli_framework::Deployment;
let builder = AppBuilder::new()
.with_version("demo", "0.1.0")
.with_deployment(Deployment::EndUser {
privacy_url: Some("https://example.com/privacy".into()),
});Deployment::EndUser is the default and is the one that gets the off
default, the notice, the telemetry command group, and the end-user clamp —
environment variables and flags may lower the telemetry level on an
installation but never raise it above what the person chose.
Deployment::Service is the fleet case; a standalone ApiServerBuilder
defaults to it.
A binary that is both — a service where an operator runs it, and a program a
person installs and runs themselves — picks its deployment per process.
Deployment::Service on the server keeps its telemetry; on the person's
machine, EndUser plus without_end_user_telemetry() ships none: no
telemetry command group, no telemetry doctor checks, no telemetry variables
in root help, no notice, no export, and stored consent from an earlier version
is not read. The end-user
commands, such as self, stay.
let builder = AppBuilder::new()
.with_version("demo", "0.1.0")
.with_deployment(if std::env::var_os("DEMO_INSTANCE").is_some() {
Deployment::Service
} else {
Deployment::EndUser { privacy_url: None }
})
.without_end_user_telemetry();use cli_framework::TelemetryDefaults;
let builder = builder.with_telemetry_defaults(TelemetryDefaults {
endpoint: Some("http://collector:4318".into()),
headers: Some("api-key=...".to_string().into()),
arg_value_allowlist: vec!["region".into()],
sample_ratio: Some(0.1),
});These are defaults, not decisions: OTEL_EXPORTER_OTLP_ENDPOINT,
OTEL_EXPORTER_OTLP_HEADERS and OTEL_TRACES_SAMPLER_ARG each win over the
corresponding field, because where a fleet sends and how much it samples are
the operator's call. headers is a SecretString and lives off the
configuration tree entirely — it is never written to a file, never roamed, and
never printed. arg_value_allowlist names the arguments whose values may be
recorded at debug, under the cli.command.arg_values probe; every other
argument records its name only.
use cli_framework::telemetry::{ProbeSpec, TelemetryLevel};
static OPS_PROBES: &[ProbeSpec] = &[ProbeSpec {
id: "sync.batch",
min_level: TelemetryLevel::Diagnostic,
summary: "Batch synchronisation",
sends: "How many records were synchronised and whether the batch succeeded",
}];
let builder = builder.with_telemetry_ops(OPS_PROBES);The slice is 'static because a probe is a declaration: telemetry info lists
it, the configuration manifest grows a telemetry.sync.batch.enabled switch
for it, and the summary has to outlive any one invocation. Ids are validated at
build time — a malformed id, a collision with a reserved first segment
(level, attribution, install_id, notice_shown, endpoint, traces,
metrics, logs), or a duplicate fails build() rather than disappearing
quietly. A probe may not use enabled as any segment after the first either:
the framework owns telemetry.<probe>.enabled as every probe's switch.
Write the sends sentence for the person who will read it in
telemetry info. It is the app's promise about that probe.
Framework attributes carry their own minimum level. An application attribute has none, so it is dropped at the export boundary unless its exact key is allowlisted:
let builder = builder
.with_telemetry_attrs(vec!["app.tenant_kind".into()])
.with_telemetry_never(vec!["employee_id".into()]);The never-list always wins: allowlisting app.api_key does not bring it back.
use std::sync::Arc;
use cli_framework::Identity;
let builder = builder.with_telemetry_identity(Arc::new(|_ctx| {
Some(Identity { enduser_id: Some("u-123".into()), tenant: Some("acme".into()) })
}));A closure, not a value: identity is not known at build time — a CLI resolves it
after authentication, a service per request. The framework calls it only when
the resolved attribution permits attaching an identity at all. The three
attribution modes are anonymous (no identifier), pseudonymous (a random
per-install UUID; the default) and identified (the resolver's answer).
An application that wants its own logging alongside telemetry calls
install_default_logging() and holds the returned guard:
let _guard = cli_framework::telemetry::install_default_logging();The guard reserves a slot for the OpenTelemetry layer, which the framework
attaches during startup once the policy is resolved. Hold it for the life of
the process. If the application installs an unrelated global subscriber
instead, the framework cannot attach the layer: it prints one warning, records
a telemetry.subscriber doctor finding, and exports metrics only.
A long-running server declares Deployment::Service; a standalone
ApiServerBuilder defaults to it. With an endpoint configured a Service
starts at diagnostic (an end-user install starts at off), and the operator
sets the level through the environment or the configuration file — there is no
telemetry command group and no clamp. Every HTTP request is wrapped in an
http.request server span named from the matched route pattern, never the
concrete path, so one resource id does not become one operation name; a
request that matched nothing reports the method alone. Do not open a request
span of your own in a handler: it nests inside the framework's instead of
replacing it. Attach detail with tracing::info!; the event lands on the
enclosing span.
Inbound traceparent/tracestate headers are extracted automatically, so a
call from another cli-framework service continues that trace. A request with
no header gets a fresh root. Outbound propagation is explicit, because the
framework does not own your HTTP client:
use cli_framework::telemetry::propagation::TracedRequestBuilder as _;
let resp = client.get(url).with_trace_context().send().await?;Without that call on every outbound request, A → B → C is three traces, not
one — and nothing errors when it is missing. Baggage is never propagated; it
would carry caller-supplied attributes into every downstream service's
telemetry.
ctx.telemetry() is the app-level handle. It exists on every context, in
every build, and is a no-op when telemetry is off:
ctx.telemetry().counter("myapp.widgets_created").add(1, &[]);
ctx.telemetry().histogram("myapp.render_ms").record(elapsed_ms, &[]);
ctx.telemetry().gauge("myapp.last_sync_unix").record(now_unix, &[]);A gauge exports the last value recorded, and exports it again on every
collection until a new one is recorded, so its series stays present between
rare events. That is what an alert on "the last good run is too old" needs: a
counter that has not moved since the process started exports nothing, and
reads the same as a process that is not running. gauge has a default no-op
implementation, so a Telemetry of your own (a test double, say) does not have
to write one.
Instruments emitted this way ride the same pipeline as the built-in metrics.
SpanHandle::set_attr records only keys declared at the span's callsite —
tracing fixes a span's fieldset at compile time, so an undeclared key is
dropped — and record_error sets the span's OpenTelemetry status to Error.
Two knobs exist for tests and nothing else:
AppBuilder::with_telemetry_config_dir(dir)points the settings file at a temporary directory, so a test never reads or writes the developer's real consent file and never has to touchXDG_CONFIG_HOME.CliTestHarness::with_interactive_stderr(bool)declares whether stderr is a terminal, which is what decides whether the first-run notice prints. It defaults tofalse, so a notice test cannot pass in a terminal and fail in CI.
Set DO_NOT_TRACK=1 in a test that must be sure nothing is sent; it is
honoured before any file is read or any id minted. A test that exercises real
export must be its own [[test]] binary: the tracer and meter providers are
process-global, so a second export test in the same binary reports into the
first one's collector. tests/integration/telemetry_end_to_end.rs is the
shape — a wiremock server stubbing POST /v1/traces and /v1/metrics, a
flush, then assertions on the bytes the collector received. Go through
AppBuilder for real: a subscriber assembled by hand in a test can pass while
the binary never installs one.
AppBuilder::with_telemetry(TelemetryConfig) and TelemetryConfig::from_env()
are deprecated in v0.6.0 and removed in v0.8.0. Replace them with
with_deployment(Deployment::Service) plus with_telemetry_defaults; the
framework reads OTEL_* itself. Until then the old call still works, with two
things to know: an app that calls it and declares no deployment is treated as a
Service (every existing caller configured a collector by hand, which is a
server), and it exports through the pre-v0.6.0 pipeline, bypassing the
redacting boundary — nothing in section 5 applies until the app migrates.
Six doctor checks report on telemetry, and doctor runs them like any other:
| Check | Reports |
|---|---|
telemetry.subscriber |
Whether the framework's tracing subscriber was installed |
telemetry.store |
Whether the telemetry settings file could be opened |
telemetry.endpoint |
Whether the configured OTLP collector can be reached, bounded at 2 seconds |
telemetry.policy |
Whether an organisation policy client is managing this install |
telemetry.identity |
The attribution mode in effect, never the identifier itself |
telemetry.env |
Whether every set <APP>_TELEMETRY_* variable matched a known setting |
Telemetry is one section of the application's published configuration manifest, generated from the probe registry. All keys are machine-scoped:
| Key | Manageable | Enforceable | Notes |
|---|---|---|---|
telemetry.level |
yes | no | Consent may be recommended, never enforced. A stored policy that tries fails validation. |
telemetry.attribution |
yes | no | anonymous, pseudonymous, identified. |
telemetry.endpoint |
yes | yes | Where OTLP goes. |
telemetry.<probe>.enabled |
yes | yes | One per probe, default true. |
telemetry.install_id |
local only | — | Never roams, never managed. |
telemetry.notice_shown |
local only | — | Never roams, never managed. |
There is deliberately no telemetry.enabled — the level is the switch, and
two switches would let them disagree.
Every key under telemetry. has an environment form: the app name and the key
path, upper-cased, with . and - becoming _.
DEMO_TELEMETRY_LEVEL=diagnostic
DEMO_TELEMETRY_ENDPOINT=http://collector:4318
DEMO_TELEMETRY_CLI_COMMAND_ARGS_ENABLED=false
A <APP>_TELEMETRY_* variable that matches no key is not silently ignored: it
produces a warning and a telemetry.env doctor finding, because a typo in a
fleet-wide variable is otherwise invisible.
The standard OpenTelemetry variables are honoured too:
OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS,
OTEL_TRACES_SAMPLER_ARG, OTEL_SERVICE_NAME, OTEL_RESOURCE_ATTRIBUTES and
OTEL_SDK_DISABLED.
All of these are listed in the application's root --help, under Environment
Variables, beside the variables the application declares itself. The probe
switches appear as one pattern row, <APP>_TELEMETRY_<PROBE>_ENABLED; the
probe ids come from telemetry info or the config manifest. An application
that registers one of these names with register_env_var keeps its own
wording.
On a Service deployment these take effect directly. On an EndUser
installation the clamp applies: the effective telemetry level is the lower of
the full resolution and the resolution without environment variables, flags
or builder overrides — an operator can turn an installation down, never up.
OTLP over http/protobuf only, default port 4318. grpc is rejected at
initialisation rather than failing silently later. Set headers through
OTEL_EXPORTER_OTLP_HEADERS; they are held as a secret and never written to
disk.
An EndUser installation always samples everything: there is one process, and
a dropped trace is the whole story. A Service uses
ParentBased(TraceIdRatioBased(r)), where r comes from
OTEL_TRACES_SAMPLER_ARG, falling back to the author's
TelemetryDefaults::sample_ratio. An absent or out-of-range ratio means full
sampling — never zero, so a misconfigured value cannot silently stop the
traces. Telemetry level debug forces AlwaysOn regardless.
A service flushes fully on shutdown, including on SIGTERM. An end-user
installation gets a 500 ms budget and no more: telemetry never delays somebody's
prompt, and the exit code is never changed by an export failure.
Two resources, deliberately different:
- Metrics carry
service.name,service.version,cli.deployment,cli.telemetry.level,os.type,host.archand thetelemetry.sdk.*attributes. No identifier — metrics are aggregates and must not become a per-install series. - Traces and logs carry the same set plus
cli.install.id,session.idandos.version, so a support conversation about one installation can find its traces.
Spans are named from a fixed set: cli.command is the root, with
cli.config.load, cli.config.migrate, cli.config.policy_refresh,
cli.secrets.op, cli.plugin.load, http.client.request and http.request
as children. Metric instruments are likewise fixed: cli.command.invocations,
cli.command.duration_ms, cli.process.duration_ms, cli.usage_errors,
cli.panics, cli.help.shown, cli.feature.uses, cli.auth.events,
cli.doctor.findings, cli.plugin.loads, cli.chat.turns,
cli.chat.sessions, http.client.request.duration,
http.server.request.duration and mcp.tool.calls.
If nothing arrives, run the app's doctor. telemetry.endpoint opens a TCP
connection to the configured collector with a two-second budget and says which
half failed; telemetry.subscriber says whether the OpenTelemetry layer could
be attached at all, which is the difference between "no traces" and "no
telemetry"; telemetry.env catches the misspelled <APP>_TELEMETRY_* variable
that would otherwise look like a setting that simply did not take.
telemetry status is the faster first answer: it prints the resolved level,
the layer it came from, and the endpoint in one screen.
- OTLP
http/protobufis the only wire protocol.OTEL_EXPORTER_OTLP_PROTOCOL=grpcis rejected at initialisation and telemetry stays off, with the reason on stderr. - There is no OTLP logs pipeline.
TelemetryConfig::logs_enabled,record_arg_valuesandarg_value_allowlistare reserved;traces_enabledandmetrics_enabledare honoured, and disabling traces still creates spans so propagation keeps working. - Fewer probes emit than the catalogue declares.
telemetry infolists every probe the build can send; some are registered but not yet wired to an emission site. The wired share is pinned by a test and grows per release.