Add shared OTLP telemetry and propagate command traces across agents
This commit is contained in:
29 files changed
+1223
-209
No files matched your search
Generated
+25
-56
@@ -1740,16 +1740,16 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry-http"
|
name = "opentelemetry-appender-tracing"
|
||||||
version = "0.31.0"
|
version = "0.31.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d"
|
checksum = "ef6a1ac5ca3accf562b8c306fa8483c85f4390f768185ab775f242f7fe8fdcc2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
|
||||||
"bytes",
|
|
||||||
"http",
|
|
||||||
"opentelemetry",
|
"opentelemetry",
|
||||||
"reqwest 0.12.28",
|
"tracing",
|
||||||
|
"tracing-core",
|
||||||
|
"tracing-opentelemetry",
|
||||||
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1760,15 +1760,12 @@ checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"http",
|
"http",
|
||||||
"opentelemetry",
|
"opentelemetry",
|
||||||
"opentelemetry-http",
|
|
||||||
"opentelemetry-proto",
|
"opentelemetry-proto",
|
||||||
"opentelemetry_sdk",
|
"opentelemetry_sdk",
|
||||||
"prost",
|
"prost",
|
||||||
"reqwest 0.12.28",
|
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tonic",
|
"tonic",
|
||||||
"tracing",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1797,8 +1794,6 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rand 0.9.4",
|
"rand 0.9.4",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
|
||||||
"tokio-stream",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2183,40 +2178,6 @@ version = "0.8.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "reqwest"
|
|
||||||
version = "0.12.28"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
|
||||||
dependencies = [
|
|
||||||
"base64",
|
|
||||||
"bytes",
|
|
||||||
"futures-channel",
|
|
||||||
"futures-core",
|
|
||||||
"futures-util",
|
|
||||||
"http",
|
|
||||||
"http-body",
|
|
||||||
"http-body-util",
|
|
||||||
"hyper",
|
|
||||||
"hyper-util",
|
|
||||||
"js-sys",
|
|
||||||
"log",
|
|
||||||
"percent-encoding",
|
|
||||||
"pin-project-lite",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"serde_urlencoded",
|
|
||||||
"sync_wrapper",
|
|
||||||
"tokio",
|
|
||||||
"tower",
|
|
||||||
"tower-http",
|
|
||||||
"tower-service",
|
|
||||||
"url",
|
|
||||||
"wasm-bindgen",
|
|
||||||
"wasm-bindgen-futures",
|
|
||||||
"web-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.2"
|
version = "0.13.2"
|
||||||
@@ -3258,8 +3219,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec"
|
checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"axum",
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
@@ -3268,6 +3231,7 @@ dependencies = [
|
|||||||
"hyper-util",
|
"hyper-util",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project",
|
"pin-project",
|
||||||
|
"socket2",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
@@ -3653,15 +3617,13 @@ dependencies = [
|
|||||||
"http",
|
"http",
|
||||||
"macaddr",
|
"macaddr",
|
||||||
"nix 0.31.2",
|
"nix 0.31.2",
|
||||||
"reqwest 0.13.2",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"time",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite 0.29.0",
|
"tokio-tungstenite 0.29.0",
|
||||||
"toml",
|
"toml",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
|
||||||
"url",
|
"url",
|
||||||
"wakey",
|
"wakey",
|
||||||
"wakey-core",
|
"wakey-core",
|
||||||
@@ -3678,20 +3640,14 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"macaddr",
|
"macaddr",
|
||||||
"nix 0.31.2",
|
"nix 0.31.2",
|
||||||
"opentelemetry",
|
"reqwest",
|
||||||
"opentelemetry-otlp",
|
|
||||||
"opentelemetry_sdk",
|
|
||||||
"reqwest 0.13.2",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"time",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-opentelemetry",
|
|
||||||
"tracing-subscriber",
|
|
||||||
"uuid",
|
"uuid",
|
||||||
"wakey-agent",
|
"wakey-agent",
|
||||||
"wakey-core",
|
"wakey-core",
|
||||||
@@ -3701,12 +3657,25 @@ dependencies = [
|
|||||||
name = "wakey-core"
|
name = "wakey-core"
|
||||||
version = "0.3.6"
|
version = "0.3.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
"macaddr",
|
"macaddr",
|
||||||
|
"opentelemetry",
|
||||||
|
"opentelemetry-appender-tracing",
|
||||||
|
"opentelemetry-otlp",
|
||||||
|
"opentelemetry-proto",
|
||||||
|
"opentelemetry_sdk",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_with",
|
"serde_with",
|
||||||
"strum",
|
"strum",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
|
"tonic",
|
||||||
|
"tracing",
|
||||||
|
"tracing-opentelemetry",
|
||||||
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ ExecStart=/opt/wakey/bin/wakey-control-plane serve --config-file /etc/wakey-cont
|
|||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
Environment=RUST_LOG=info
|
Environment=RUST_LOG=info
|
||||||
|
EnvironmentFile=-/etc/wakey-control-plane/environment
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# OpenWrt UCI for Wakey
|
||||||
|
|
||||||
|
Wakey's OpenWrt init script reads `/etc/config/wakey` for the log filter and
|
||||||
|
OTLP authorization header, then passes them to the agent through procd.
|
||||||
|
The agent's application settings remain in `/etc/wakey-agent/config.toml`.
|
||||||
|
The previous `/etc/wakey-agent/environment` file is no longer read.
|
||||||
|
|
||||||
|
## The three names
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uci set wakey.main.otlp_authorization='Basic BASE64_OF_USERNAME_COLON_PASSWORD'
|
||||||
|
```
|
||||||
|
|
||||||
|
| Part | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `wakey` | Package: `/etc/config/wakey` |
|
||||||
|
| `main` | Named section inside that package |
|
||||||
|
| `otlp_authorization` | Option inside the section |
|
||||||
|
|
||||||
|
Quotes keep the complete `Basic ...` value together despite its space.
|
||||||
|
The credential is the Base64 encoding of `username:password`.
|
||||||
|
It is not the htpasswd hash, and Base64 is not encryption.
|
||||||
|
|
||||||
|
## Create and save
|
||||||
|
|
||||||
|
Run as root on the router. `touch` does not truncate an existing file:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
touch /etc/config/wakey
|
||||||
|
chown root:root /etc/config/wakey
|
||||||
|
chmod 600 /etc/config/wakey
|
||||||
|
|
||||||
|
uci set wakey.main=service
|
||||||
|
uci set wakey.main.log_filter='info'
|
||||||
|
uci set wakey.main.otlp_authorization='Basic BASE64_OF_USERNAME_COLON_PASSWORD'
|
||||||
|
uci commit wakey
|
||||||
|
chmod 600 /etc/config/wakey
|
||||||
|
```
|
||||||
|
|
||||||
|
`wakey.main=service` creates a named section of type `service`. The type is our
|
||||||
|
chosen label, not a built-in Wakey feature. Existing options are not cleared.
|
||||||
|
`set` stages changes; `commit wakey` writes that package to disk. Committing does
|
||||||
|
not restart a service.
|
||||||
|
|
||||||
|
The resulting file looks like:
|
||||||
|
|
||||||
|
```text
|
||||||
|
config service 'main'
|
||||||
|
option log_filter 'info'
|
||||||
|
option otlp_authorization 'Basic BASE64_OF_USERNAME_COLON_PASSWORD'
|
||||||
|
```
|
||||||
|
|
||||||
|
The example above uses a placeholder. Avoid typing real credentials into a
|
||||||
|
command that your shell will save in history. On OpenWrt's ash, you can instead
|
||||||
|
read the Basic header without echoing it and send it to UCI over stdin:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
read -r -s -p 'Complete Basic authorization header: ' authorization
|
||||||
|
printf '\n'
|
||||||
|
printf "set wakey.main.otlp_authorization='%s'\ncommit wakey\n" "$authorization" | uci batch
|
||||||
|
unset authorization
|
||||||
|
chmod 600 /etc/config/wakey
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a normal `Basic <base64>` header here, without quotes/newlines inside the value.
|
||||||
|
The shell's builtin `printf` avoids passing the secret in `uci`'s command arguments.
|
||||||
|
UCI is not a secret vault: committed files, staged changes, router backups, and
|
||||||
|
root access can expose these credentials. Do not paste their output into logs/chat.
|
||||||
|
|
||||||
|
## Inspect, change, delete
|
||||||
|
|
||||||
|
Inspect the non-secret option:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uci get wakey.main.log_filter
|
||||||
|
```
|
||||||
|
|
||||||
|
`uci show wakey`, `uci export wakey`, `uci changes wakey`, and
|
||||||
|
`uci get wakey.main.otlp_authorization` **print the credential**. Use them only
|
||||||
|
when you deliberately want to see it.
|
||||||
|
|
||||||
|
Change an option:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uci set wakey.main.log_filter='wakey_agent=debug,wakey=debug,opentelemetry=warn'
|
||||||
|
uci commit wakey
|
||||||
|
```
|
||||||
|
|
||||||
|
Delete just the authorization option:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uci -q delete wakey.main.otlp_authorization
|
||||||
|
uci commit wakey
|
||||||
|
```
|
||||||
|
|
||||||
|
If TOML still requires `authorization_env`, removing the variable makes startup
|
||||||
|
fail. Remove that TOML field too only when the receiver no longer requires auth.
|
||||||
|
|
||||||
|
Discard all uncommitted changes to this package:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uci revert wakey
|
||||||
|
```
|
||||||
|
|
||||||
|
## How the service reads it
|
||||||
|
|
||||||
|
`config_load` and `config_get` are shell helpers from `/lib/functions.sh`, available
|
||||||
|
in the normal `/etc/rc.common` init-script environment. They are not standalone
|
||||||
|
commands to run directly in your SSH shell.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
config_load wakey
|
||||||
|
config_get authorization main otlp_authorization
|
||||||
|
```
|
||||||
|
|
||||||
|
`config_get` arguments are **destination variable, section, option, optional default**.
|
||||||
|
The line above puts the option into the shell variable `authorization`. It does
|
||||||
|
not export it to the agent automatically; procd must receive it explicitly.
|
||||||
|
|
||||||
|
The checked-in [init script](../scripts/init/openwrt/wakey) uses these helpers.
|
||||||
|
Its configuration handling is:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
start_service() {
|
||||||
|
local authorization='' log_filter='info'
|
||||||
|
if [ -f /etc/config/wakey ]; then
|
||||||
|
config_load wakey || return 1
|
||||||
|
config_get authorization main otlp_authorization
|
||||||
|
config_get log_filter main log_filter 'info'
|
||||||
|
fi
|
||||||
|
|
||||||
|
procd_open_instance
|
||||||
|
procd_set_param command "$BIN" serve --config "$CONFIG"
|
||||||
|
procd_set_param file "$CONFIG"
|
||||||
|
[ ! -f /etc/config/wakey ] || procd_append_param file /etc/config/wakey
|
||||||
|
procd_set_param env "RUST_LOG=$log_filter"
|
||||||
|
[ -z "$authorization" ] || procd_append_param env "WAKEY_OTLP_AUTHORIZATION=$authorization"
|
||||||
|
procd_set_param respawn 5 1 0
|
||||||
|
procd_set_param stdout 1
|
||||||
|
procd_set_param stderr 1
|
||||||
|
procd_close_instance
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep these fields in the agent's TOML:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[telemetry]
|
||||||
|
otlp_endpoint = "http://10.5.0.1:4317"
|
||||||
|
authorization_env = "WAKEY_OTLP_AUTHORIZATION"
|
||||||
|
```
|
||||||
|
|
||||||
|
UCI stores the header; procd passes the environment variable; Rust reads it using
|
||||||
|
the name in TOML. The application does not parse UCI itself.
|
||||||
|
|
||||||
|
After installing the updated init script and committing settings:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
/etc/init.d/wakey restart
|
||||||
|
```
|
||||||
|
|
||||||
|
Use **restart**, not the existing HUP-based reload: a running process cannot receive
|
||||||
|
changed environment variables that way. UCI is only for the router; the CC's
|
||||||
|
systemd environment file remains unchanged. See [telemetry.md](telemetry.md).
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [OpenWrt UCI CLI source and command usage](https://github.com/openwrt/uci/blob/master/cli.c)
|
||||||
|
- [OpenWrt config_load/config_get helpers](https://github.com/openwrt/openwrt/blob/main/package/base-files/files/lib/functions.sh)
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Telemetry
|
||||||
|
|
||||||
|
CC and the agent daemon share the optional `wakey-core/telemetry` module.
|
||||||
|
Local logs remain enabled. Configure an OTLP **gRPC** receiver to additionally
|
||||||
|
export traces, structured logs, and metrics; HTTP/4318 is not supported in this slice.
|
||||||
|
|
||||||
|
CC configuration:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[telemetry]
|
||||||
|
otlp_endpoint = "http://127.0.0.1:4317"
|
||||||
|
authorization_env = "WAKEY_OTLP_AUTHORIZATION"
|
||||||
|
service_name = "wakey-control-plane"
|
||||||
|
json_logs = true
|
||||||
|
```
|
||||||
|
|
||||||
|
Agent configuration (replace the example with the collector's WireGuard address):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[telemetry]
|
||||||
|
otlp_endpoint = "http://10.5.0.1:4317"
|
||||||
|
authorization_env = "WAKEY_OTLP_AUTHORIZATION"
|
||||||
|
service_name = "wakey-agent"
|
||||||
|
json_logs = false
|
||||||
|
```
|
||||||
|
|
||||||
|
Omit `otlp_endpoint` to disable exporting. Omit `authorization_env` for a receiver
|
||||||
|
that requires no authentication. Existing configs need no changes.
|
||||||
|
An empty agent service name uses `wakey-agent`; its enrolled agent ID becomes
|
||||||
|
the resource's `service.instance.id`, not a metric label. Telemetry config changes
|
||||||
|
require a daemon restart, not SIGHUP. Short-lived enroll/reload/observe commands
|
||||||
|
only log locally and do not initialize exporters.
|
||||||
|
|
||||||
|
## Receiver and filtering
|
||||||
|
|
||||||
|
The collector must enable OTLP receivers in **traces, logs, and metrics** pipelines.
|
||||||
|
Loopback is sufficient for CC on the same host/network namespace. The router
|
||||||
|
needs a collector listener reachable over WireGuard; do not expose an unauthenticated
|
||||||
|
receiver on the public Internet. This initial setup assumes plaintext inside that
|
||||||
|
tunnel. `json_logs` changes local output,
|
||||||
|
not whether OTLP logs are exported.
|
||||||
|
|
||||||
|
## Credentials and services
|
||||||
|
|
||||||
|
The OpenWrt agent uses `/etc/config/wakey`; see the
|
||||||
|
[UCI setup guide](openwrt-uci.md) for commands and permissions.
|
||||||
|
The CC uses a systemd environment file as described below.
|
||||||
|
|
||||||
|
`authorization_env` names an environment variable containing the complete header:
|
||||||
|
`Basic <base64(username:password)>` or `Bearer <token>`. With exporting enabled,
|
||||||
|
missing/empty/invalid values fail startup; credentials are never written to TOML
|
||||||
|
or included in error messages. Metadata is marked sensitive for Debug output.
|
||||||
|
Base64 is not encryption: use the WireGuard/SSH tunnel or another protected transport.
|
||||||
|
|
||||||
|
The systemd CC reads the optional `/etc/wakey-control-plane/environment` file.
|
||||||
|
|
||||||
|
Use this portable literal format, **without quotes or `export`**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
# Replace this placeholder with the actual encoded credential locally.
|
||||||
|
WAKEY_OTLP_AUTHORIZATION=Basic BASE64_OF_USERNAME_COLON_PASSWORD
|
||||||
|
RUST_LOG=info
|
||||||
|
```
|
||||||
|
|
||||||
|
Protect the file with owner `root:root` and mode `0600`. systemd's manager reads it
|
||||||
|
before switching to the service user. Use the simple format above, with LF endings
|
||||||
|
and no inline comments or multiline values.
|
||||||
|
An absent file is allowed; configuring auth without providing its variable is not.
|
||||||
|
|
||||||
|
After changing the file, **restart** the service. On systemd, run `daemon-reload`
|
||||||
|
once after installing the updated unit. OpenWrt's HUP reload does not reload the
|
||||||
|
process environment. For a foreground WSL test, export the variable in that shell
|
||||||
|
and use `http://127.0.0.1:4317` with the SSH tunnel; do not put credentials in CLI args.
|
||||||
|
|
||||||
|
`RUST_LOG` overrides the verbosity defaults for both local output and exports.
|
||||||
|
For example, `RUST_LOG=warn` suppresses informational command spans as well as logs.
|
||||||
|
Use `wakey_agent=info,wakey=info,wakey_core=info,opentelemetry=warn` for the agent and
|
||||||
|
`wakey_control_plane=info,wakey_core=info,opentelemetry=warn` for CC. Exporter warnings
|
||||||
|
remain local to prevent export-failure feedback loops. Increasing verbosity exports the
|
||||||
|
existing debug events too; inspect them before enabling it broadly.
|
||||||
|
|
||||||
|
## Signals
|
||||||
|
|
||||||
|
- `relay_command` on CC propagates W3C context in an optional command field.
|
||||||
|
`execute_command` on the agent adopts it; older messages/agents remain compatible.
|
||||||
|
- `collect_device_snapshot` and `store_device_snapshot` measure collection and
|
||||||
|
persistence separately. Snapshot traces are not yet linked across the wire.
|
||||||
|
- Terminal create/attach/close/resume spans describe lifecycle operations, not
|
||||||
|
output chunks. Trace-linked OTLP logs use the SDK's tracing-context bridge.
|
||||||
|
|
||||||
|
Metrics are exported every 30 seconds and at orderly shutdown:
|
||||||
|
|
||||||
|
| Metric | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `wakey.operations` | Completed command/snapshot attempts, labeled by operation and outcome |
|
||||||
|
| `wakey.operation.duration` | Corresponding duration histogram in seconds |
|
||||||
|
| `wakey.snapshot.devices` | Cumulative devices in snapshots successfully sent by the agent |
|
||||||
|
| `wakey.terminal.active` | Agent PTY workers currently alive |
|
||||||
|
| `wakey.terminal.input.bytes` | Input bytes received by the terminal relay |
|
||||||
|
| `wakey.terminal.output.bytes` | Output bytes read from the PTY, including while detached |
|
||||||
|
| `wakey.terminal.backpressure` | Relay queue-full encounters, not dropped output |
|
||||||
|
|
||||||
|
Metric labels do not include request/session IDs, MACs, or IPs. New spans use
|
||||||
|
`skip_all` and explicitly select safe fields; terminal bytes and credentials are
|
||||||
|
not attached. Profiles, host CPU/RSS metrics, connected-agent gauges, and export-loss
|
||||||
|
dashboards are not part of this initial slice.
|
||||||
|
|
||||||
|
## Failure policy
|
||||||
|
|
||||||
|
Trace and log queues are capped at 256 records each, with export batches of 64
|
||||||
|
and a 3-second request timeout. Export happens off the application path. On an
|
||||||
|
outage, telemetry can be dropped rather than accumulating unbounded router RAM;
|
||||||
|
terminal output follows its existing lossless backpressure policy independently.
|
||||||
|
The bound is a record count, not an absolute byte limit on arbitrary log messages.
|
||||||
|
Providers flush on orderly daemon exit (Ctrl-C/SIGTERM), on a blocking worker while
|
||||||
|
Tokio remains alive. Only completed spans are exported; this is not graceful draining
|
||||||
|
of every in-flight request. SIGKILL/power loss cannot flush. This is not a persistent
|
||||||
|
telemetry spool.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test -p wakey-core --features telemetry --test telemetry
|
||||||
|
```
|
||||||
|
|
||||||
|
The test starts a temporary gRPC collector and verifies all three signals,
|
||||||
|
command parent/child IDs, log correlation, and shutdown delivery without sleeps.
|
||||||
@@ -7,12 +7,26 @@ USE_PROCD=1
|
|||||||
NAME=wakey
|
NAME=wakey
|
||||||
BIN=/root/.bin/wakey-agent
|
BIN=/root/.bin/wakey-agent
|
||||||
CONFIG=/etc/wakey-agent/config.toml
|
CONFIG=/etc/wakey-agent/config.toml
|
||||||
|
UCI_CONFIG=/etc/config/wakey
|
||||||
|
|
||||||
start_service() {
|
start_service() {
|
||||||
|
local authorization='' log_filter='info'
|
||||||
|
if [ -f "$UCI_CONFIG" ]; then
|
||||||
|
config_load wakey || return 1
|
||||||
|
config_get authorization main otlp_authorization
|
||||||
|
config_get log_filter main log_filter 'info'
|
||||||
|
fi
|
||||||
procd_open_instance
|
procd_open_instance
|
||||||
procd_set_param command "$BIN" serve --config "$CONFIG"
|
procd_set_param command "$BIN" serve --config "$CONFIG"
|
||||||
procd_set_param file "$CONFIG"
|
procd_set_param file "$CONFIG"
|
||||||
# procd_set_param env RUST_LOG=wakey_agent=debug,wakey=debug
|
if [ -f "$UCI_CONFIG" ]; then
|
||||||
|
procd_append_param file "$UCI_CONFIG"
|
||||||
|
fi
|
||||||
|
procd_set_param env "RUST_LOG=$log_filter"
|
||||||
|
# Pass the complete header as one argument; never log its value.
|
||||||
|
if [ -n "$authorization" ]; then
|
||||||
|
procd_append_param env "WAKEY_OTLP_AUTHORIZATION=$authorization"
|
||||||
|
fi
|
||||||
procd_set_param respawn 5 1 0
|
procd_set_param respawn 5 1 0
|
||||||
procd_set_param stdout 1
|
procd_set_param stdout 1
|
||||||
procd_set_param stderr 1
|
procd_set_param stderr 1
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Host-side contract test; OpenWrt's UCI and procd helpers are stubbed.
|
||||||
|
set -eu
|
||||||
|
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT HUP INT TERM
|
||||||
|
|
||||||
|
config_load() { [ "$1" = wakey ]; loaded=1; return "$load_status"; }
|
||||||
|
config_get() {
|
||||||
|
[ "$2" = main ]
|
||||||
|
case "$3" in
|
||||||
|
otlp_authorization) authorization=$test_authorization ;;
|
||||||
|
log_filter) log_filter=${test_log_filter:-$4} ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
procd_open_instance() { opened=1; }
|
||||||
|
procd_close_instance() { :; }
|
||||||
|
procd_set_param() {
|
||||||
|
if [ "$1" = env ]; then
|
||||||
|
[ "$#" -eq 2 ]
|
||||||
|
received_filter=$2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
procd_append_param() {
|
||||||
|
case "$1" in
|
||||||
|
env) [ "$#" -eq 2 ]; received_auth=$2 ;;
|
||||||
|
file) tracked_file=$2 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
. "$ROOT/scripts/init/openwrt/wakey"
|
||||||
|
UCI_CONFIG="$TMP/wakey"
|
||||||
|
touch "$UCI_CONFIG"
|
||||||
|
load_status=0
|
||||||
|
loaded=0
|
||||||
|
opened=0
|
||||||
|
test_authorization='Basic dGVzdDp0ZXN0=='
|
||||||
|
test_log_filter='wakey_agent=debug,opentelemetry=warn'
|
||||||
|
start_service
|
||||||
|
[ "$loaded" -eq 1 ]
|
||||||
|
[ "$opened" -eq 1 ]
|
||||||
|
[ "$received_auth" = "WAKEY_OTLP_AUTHORIZATION=$test_authorization" ]
|
||||||
|
[ "$received_filter" = "RUST_LOG=$test_log_filter" ]
|
||||||
|
[ "$tracked_file" = "$UCI_CONFIG" ]
|
||||||
|
|
||||||
|
# Missing options retain the default filter and do not export empty credentials.
|
||||||
|
test_authorization=''
|
||||||
|
test_log_filter=''
|
||||||
|
received_auth=''
|
||||||
|
start_service
|
||||||
|
[ "$received_auth" = '' ]
|
||||||
|
[ "$received_filter" = 'RUST_LOG=info' ]
|
||||||
|
|
||||||
|
load_status=1
|
||||||
|
opened=0
|
||||||
|
if start_service; then
|
||||||
|
echo 'failed UCI load was accepted' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[ "$opened" -eq 0 ]
|
||||||
|
|
||||||
|
UCI_CONFIG="$TMP/absent"
|
||||||
|
loaded=0
|
||||||
|
tracked_file=''
|
||||||
|
start_service
|
||||||
|
[ "$loaded" -eq 0 ]
|
||||||
|
[ "$tracked_file" = '' ]
|
||||||
|
[ "$received_filter" = 'RUST_LOG=info' ]
|
||||||
|
echo 'PASS: OpenWrt UCI environment forwarding and optional configuration'
|
||||||
@@ -32,13 +32,7 @@ tokio = { version = "1", features = [
|
|||||||
tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] }
|
tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] }
|
||||||
toml = "1.1"
|
toml = "1.1"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = [
|
|
||||||
"env-filter",
|
|
||||||
"fmt",
|
|
||||||
"time",
|
|
||||||
] }
|
|
||||||
time = { version = "0.3", features = ["formatting", "local-offset"] }
|
|
||||||
url = "2"
|
url = "2"
|
||||||
vt100 = { package = "wakey-vt100", path = "../vendor/vt100", registry = "gitea", version = "=0.16.2-wakey.2" }
|
vt100 = { package = "wakey-vt100", path = "../vendor/vt100", registry = "gitea", version = "=0.16.2-wakey.2" }
|
||||||
wakey = { path = "..", registry = "gitea", version = "0" }
|
wakey = { path = "..", registry = "gitea", version = "0" }
|
||||||
wakey-core = { path = "../wakey-core", registry = "gitea", version = "0" }
|
wakey-core = { path = "../wakey-core", registry = "gitea", version = "0", features = ["telemetry"] }
|
||||||
@@ -42,6 +42,8 @@ pub struct AgentConfig {
|
|||||||
pub observation_store_path: PathBuf,
|
pub observation_store_path: PathBuf,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub terminal: TerminalConfig,
|
pub terminal: TerminalConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub telemetry: wakey_core::telemetry::TelemetryConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -88,6 +90,7 @@ pub static DEFAULT_CONFIG: LazyLock<AgentConfig> = LazyLock::new(|| AgentConfig
|
|||||||
mac_name_cache_path: default_mac_name_cache_path(),
|
mac_name_cache_path: default_mac_name_cache_path(),
|
||||||
observation_store_path: default_observation_store_path(),
|
observation_store_path: default_observation_store_path(),
|
||||||
terminal: TerminalConfig::default(),
|
terminal: TerminalConfig::default(),
|
||||||
|
telemetry: wakey_core::telemetry::TelemetryConfig::default(),
|
||||||
});
|
});
|
||||||
|
|
||||||
impl fmt::Debug for AgentConfig {
|
impl fmt::Debug for AgentConfig {
|
||||||
@@ -111,6 +114,7 @@ impl fmt::Debug for AgentConfig {
|
|||||||
.field("mac_name_cache_path", &self.mac_name_cache_path)
|
.field("mac_name_cache_path", &self.mac_name_cache_path)
|
||||||
.field("observation_store_path", &self.observation_store_path)
|
.field("observation_store_path", &self.observation_store_path)
|
||||||
.field("terminal", &self.terminal)
|
.field("terminal", &self.terminal)
|
||||||
|
.field("telemetry", &self.telemetry)
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,6 +278,12 @@ mod tests {
|
|||||||
dhcp_leases_path: "/tmp/test-dhcp.leases".into(),
|
dhcp_leases_path: "/tmp/test-dhcp.leases".into(),
|
||||||
mac_name_cache_path: "/tmp/test-names.json".into(),
|
mac_name_cache_path: "/tmp/test-names.json".into(),
|
||||||
observation_store_path: "/tmp/test-observations.json".into(),
|
observation_store_path: "/tmp/test-observations.json".into(),
|
||||||
|
telemetry: wakey_core::telemetry::TelemetryConfig {
|
||||||
|
otlp_endpoint: Some("http://10.0.0.1:4317".into()),
|
||||||
|
authorization_env: Some("WAKEY_OTLP_AUTHORIZATION".into()),
|
||||||
|
service_name: "router-agent".into(),
|
||||||
|
json_logs: true,
|
||||||
|
},
|
||||||
terminal: TerminalConfig {
|
terminal: TerminalConfig {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
shell: "/bin/sh".into(),
|
shell: "/bin/sh".into(),
|
||||||
|
|||||||
@@ -151,6 +151,12 @@ mod tests {
|
|||||||
dhcp_leases_path: "/tmp/custom-dhcp.leases".into(),
|
dhcp_leases_path: "/tmp/custom-dhcp.leases".into(),
|
||||||
mac_name_cache_path: "/tmp/custom-names.json".into(),
|
mac_name_cache_path: "/tmp/custom-names.json".into(),
|
||||||
observation_store_path: "/tmp/custom-observations.json".into(),
|
observation_store_path: "/tmp/custom-observations.json".into(),
|
||||||
|
telemetry: wakey_core::telemetry::TelemetryConfig {
|
||||||
|
otlp_endpoint: Some("http://10.0.0.1:4317".into()),
|
||||||
|
authorization_env: Some("WAKEY_OTLP_AUTHORIZATION".into()),
|
||||||
|
service_name: "router-agent".into(),
|
||||||
|
json_logs: true,
|
||||||
|
},
|
||||||
terminal: crate::config::TerminalConfig {
|
terminal: crate::config::TerminalConfig {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
shell: "/bin/ash".into(),
|
shell: "/bin/ash".into(),
|
||||||
|
|||||||
+23
-1
@@ -15,9 +15,31 @@ use wakey_agent::protocol;
|
|||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
tracing::init(cli.verbose);
|
|
||||||
let global_config = cli.config.as_deref();
|
let global_config = cli.config.as_deref();
|
||||||
|
|
||||||
|
// Only the long-lived daemon exports: hotplug/enroll invocations stay cheap.
|
||||||
|
let daemon_config = if let Command::Serve(args) = &cli.command {
|
||||||
|
Some(config::load_config(global_config.unwrap_or(&args.config))?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let local_only = wakey_core::telemetry::TelemetryConfig::default();
|
||||||
|
let telemetry = tracing::init(
|
||||||
|
cli.verbose,
|
||||||
|
daemon_config
|
||||||
|
.as_ref()
|
||||||
|
.map(|c| &c.telemetry)
|
||||||
|
.unwrap_or(&local_only),
|
||||||
|
daemon_config.as_ref().map(|c| c.agent_id.as_str()),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let result = run(cli).await;
|
||||||
|
tokio::task::spawn_blocking(move || telemetry.shutdown()).await?;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(cli: Cli) -> Result<()> {
|
||||||
|
let global_config = cli.config.as_deref();
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Command::Serve(mut args) => {
|
Command::Serve(mut args) => {
|
||||||
if let Some(config) = global_config {
|
if let Some(config) = global_config {
|
||||||
|
|||||||
@@ -325,6 +325,8 @@ pub enum ServerMessage {
|
|||||||
Command {
|
Command {
|
||||||
request_id: RequestId,
|
request_id: RequestId,
|
||||||
command: AgentCommand,
|
command: AgentCommand,
|
||||||
|
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||||
|
trace_context: std::collections::BTreeMap<String, String>,
|
||||||
},
|
},
|
||||||
SyncDeviceSnapshot,
|
SyncDeviceSnapshot,
|
||||||
SyncTerminalSessions,
|
SyncTerminalSessions,
|
||||||
@@ -354,6 +356,7 @@ mod tests {
|
|||||||
command: AgentCommand::Leases(LeasesRequest {
|
command: AgentCommand::Leases(LeasesRequest {
|
||||||
include_state: true,
|
include_state: true,
|
||||||
}),
|
}),
|
||||||
|
trace_context: Default::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&msg).expect("serialize");
|
let json = serde_json::to_string(&msg).expect("serialize");
|
||||||
@@ -367,6 +370,36 @@ mod tests {
|
|||||||
assert!(err.contains("must not be empty"));
|
assert!(err.contains("must not be empty"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn old_commands_without_trace_context_remain_valid() {
|
||||||
|
let message: ServerMessage = serde_json::from_str(r#"{"type":"command","request_id":"req-old","command":{"kind":"leases","include_state":false}}"#).expect("old command");
|
||||||
|
let ServerMessage::Command { trace_context, .. } = message else {
|
||||||
|
panic!("command expected")
|
||||||
|
};
|
||||||
|
assert!(trace_context.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_trace_context_roundtrips() {
|
||||||
|
let message = ServerMessage::Command {
|
||||||
|
request_id: RequestId::try_from("req-traced".to_string()).unwrap(),
|
||||||
|
command: AgentCommand::Leases(LeasesRequest {
|
||||||
|
include_state: false,
|
||||||
|
}),
|
||||||
|
trace_context: [(
|
||||||
|
"traceparent".into(),
|
||||||
|
"00-0123456789abcdef0123456789abcdef-0123456789abcdef-01".into(),
|
||||||
|
)]
|
||||||
|
.into(),
|
||||||
|
};
|
||||||
|
let ServerMessage::Command { trace_context, .. } =
|
||||||
|
serde_json::from_str(&serde_json::to_string(&message).unwrap()).unwrap()
|
||||||
|
else {
|
||||||
|
panic!("command expected")
|
||||||
|
};
|
||||||
|
assert!(trace_context.contains_key("traceparent"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn terminal_id_deserialization_enforces_validation() {
|
fn terminal_id_deserialization_enforces_validation() {
|
||||||
let valid: TerminalId = serde_json::from_str(r#""term-1""#).expect("valid terminal id");
|
let valid: TerminalId = serde_json::from_str(r#""term-1""#).expect("valid terminal id");
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ pub async fn serve(args: ServeArgs) -> Result<()> {
|
|||||||
{
|
{
|
||||||
use tokio::signal::unix::{SignalKind, signal};
|
use tokio::signal::unix::{SignalKind, signal};
|
||||||
let mut hup = signal(SignalKind::hangup()).context("failed to install SIGHUP handler")?;
|
let mut hup = signal(SignalKind::hangup()).context("failed to install SIGHUP handler")?;
|
||||||
|
let mut terminate =
|
||||||
|
signal(SignalKind::terminate()).context("failed to install SIGTERM handler")?;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -37,9 +39,17 @@ pub async fn serve(args: ServeArgs) -> Result<()> {
|
|||||||
worker.abort();
|
worker.abort();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
_ = terminate.recv() => {
|
||||||
|
info!("SIGTERM received; shutting down wakey-agent");
|
||||||
|
worker.abort();
|
||||||
|
break;
|
||||||
|
}
|
||||||
_ = hup.recv() => {
|
_ = hup.recv() => {
|
||||||
match config::load_config(&args.config) {
|
match config::load_config(&args.config) {
|
||||||
Ok(new_cfg) => {
|
Ok(new_cfg) => {
|
||||||
|
if new_cfg.telemetry != cfg.telemetry {
|
||||||
|
warn!("telemetry configuration changed; restart the daemon to apply it");
|
||||||
|
}
|
||||||
cfg = new_cfg;
|
cfg = new_cfg;
|
||||||
info!(config_path = %args.config.display(), agent_id = %cfg.agent_id, "reload requested; restarting session with updated config");
|
info!(config_path = %args.config.display(), agent_id = %cfg.agent_id, "reload requested; restarting session with updated config");
|
||||||
worker.abort();
|
worker.abort();
|
||||||
@@ -67,6 +77,8 @@ pub async fn serve(args: ServeArgs) -> Result<()> {
|
|||||||
worker.abort();
|
worker.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Await the cancelled session task before flushing its telemetry.
|
||||||
|
let _ = worker.await;
|
||||||
let _ = remove_pid_file(&pid_file);
|
let _ = remove_pid_file(&pid_file);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,11 +195,13 @@ where
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "collect_device_snapshot", skip_all)]
|
||||||
async fn send_device_snapshot_ws<S>(sink: &mut S, config: &AgentConfig) -> Result<usize>
|
async fn send_device_snapshot_ws<S>(sink: &mut S, config: &AgentConfig) -> Result<usize>
|
||||||
where
|
where
|
||||||
S: SinkExt<Message> + Unpin,
|
S: SinkExt<Message> + Unpin,
|
||||||
<S as futures_util::Sink<Message>>::Error: std::error::Error + Send + Sync + 'static,
|
<S as futures_util::Sink<Message>>::Error: std::error::Error + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
|
let mut operation = wakey_core::telemetry::Operation::new("snapshot");
|
||||||
let req = crate::protocol::InventoryRequest {
|
let req = crate::protocol::InventoryRequest {
|
||||||
query: None,
|
query: None,
|
||||||
name: None,
|
name: None,
|
||||||
@@ -221,6 +223,11 @@ where
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
debug!(agent_id = %config.agent_id, devices = count, "sent device snapshot over websocket");
|
debug!(agent_id = %config.agent_id, devices = count, "sent device snapshot over websocket");
|
||||||
|
operation.outcome("ok");
|
||||||
|
wakey_core::telemetry::count(
|
||||||
|
wakey_core::telemetry::Counter::SnapshotDevices,
|
||||||
|
count as u64,
|
||||||
|
);
|
||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,11 +251,16 @@ where
|
|||||||
ServerMessage::Command {
|
ServerMessage::Command {
|
||||||
request_id,
|
request_id,
|
||||||
command,
|
command,
|
||||||
|
trace_context,
|
||||||
} => {
|
} => {
|
||||||
let kind = command_kind(&command);
|
let kind = command_kind(&command);
|
||||||
|
let span = info_span!("execute_command", command = kind, request_id = %request_id);
|
||||||
|
wakey_core::telemetry::set_remote_parent(&span, &trace_context);
|
||||||
|
let mut operation = wakey_core::telemetry::Operation::new(kind);
|
||||||
info!(request_id = %request_id, command = %kind, "received command from control-plane");
|
info!(request_id = %request_id, command = %kind, "received command from control-plane");
|
||||||
match dispatch_command(command, config).await {
|
match tracing::Instrument::instrument(dispatch_command(command, config), span).await {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
|
operation.outcome("ok");
|
||||||
info!(request_id = %request_id, command = %kind, "command execution completed");
|
info!(request_id = %request_id, command = %kind, "command execution completed");
|
||||||
send_command_result(sink, request_id, kind, result).await?;
|
send_command_result(sink, request_id, kind, result).await?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ impl RelayOutputBuffer {
|
|||||||
match sender.try_send(frame) {
|
match sender.try_send(frame) {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(mpsc::error::TrySendError::Full(frame)) => {
|
Err(mpsc::error::TrySendError::Full(frame)) => {
|
||||||
|
wakey_core::telemetry::count(
|
||||||
|
wakey_core::telemetry::Counter::TerminalBackpressure,
|
||||||
|
1,
|
||||||
|
);
|
||||||
self.pending.push_back(frame);
|
self.pending.push_back(frame);
|
||||||
}
|
}
|
||||||
Err(mpsc::error::TrySendError::Closed(_)) => self.disconnect(),
|
Err(mpsc::error::TrySendError::Closed(_)) => self.disconnect(),
|
||||||
@@ -169,6 +173,7 @@ impl TerminalManager {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "terminal_open", skip_all, fields(terminal_id = %terminal_id))]
|
||||||
pub fn open(
|
pub fn open(
|
||||||
&self,
|
&self,
|
||||||
config: &AgentConfig,
|
config: &AgentConfig,
|
||||||
@@ -214,6 +219,7 @@ impl TerminalManager {
|
|||||||
let active = Arc::downgrade(&self.active);
|
let active = Arc::downgrade(&self.active);
|
||||||
let events = self.events.clone();
|
let events = self.events.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
let _active_metric = wakey_core::telemetry::ActiveTerminal::new();
|
||||||
let result = run_terminal(
|
let result = run_terminal(
|
||||||
&config,
|
&config,
|
||||||
&terminal_id,
|
&terminal_id,
|
||||||
@@ -238,6 +244,7 @@ impl TerminalManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "terminal_close", skip_all, fields(terminal_id = %terminal_id))]
|
||||||
pub fn close(&self, terminal_id: &TerminalId) -> bool {
|
pub fn close(&self, terminal_id: &TerminalId) -> bool {
|
||||||
let mut active = self.active.lock().expect("terminal manager poisoned");
|
let mut active = self.active.lock().expect("terminal manager poisoned");
|
||||||
let Some(terminal) = active.get_mut(terminal_id.as_str()) else {
|
let Some(terminal) = active.get_mut(terminal_id.as_str()) else {
|
||||||
@@ -253,6 +260,7 @@ impl TerminalManager {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "terminal_resume", skip_all, fields(terminal_id = %terminal_id))]
|
||||||
pub fn resume(&self, terminal_id: &TerminalId, relay_token: String) -> Result<()> {
|
pub fn resume(&self, terminal_id: &TerminalId, relay_token: String) -> Result<()> {
|
||||||
let active = self.active.lock().expect("terminal manager poisoned");
|
let active = self.active.lock().expect("terminal manager poisoned");
|
||||||
let session = active
|
let session = active
|
||||||
@@ -453,6 +461,7 @@ async fn run_terminal(
|
|||||||
match read {
|
match read {
|
||||||
Ok(0) => break,
|
Ok(0) => break,
|
||||||
Ok(count) => {
|
Ok(count) => {
|
||||||
|
wakey_core::telemetry::count(wakey_core::telemetry::Counter::TerminalOutputBytes, count as u64);
|
||||||
terminal_state.process(&output[..count]);
|
terminal_state.process(&output[..count]);
|
||||||
relay_output.queue_live(Message::Binary(output[..count].to_vec().into()));
|
relay_output.queue_live(Message::Binary(output[..count].to_vec().into()));
|
||||||
}
|
}
|
||||||
@@ -575,6 +584,7 @@ where
|
|||||||
let Some(message) = incoming else { break; };
|
let Some(message) = incoming else { break; };
|
||||||
match message.context("terminal relay websocket receive failed")? {
|
match message.context("terminal relay websocket receive failed")? {
|
||||||
Message::Binary(bytes) => {
|
Message::Binary(bytes) => {
|
||||||
|
wakey_core::telemetry::count(wakey_core::telemetry::Counter::TerminalInputBytes, bytes.len() as u64);
|
||||||
pending_input = Some(RelayInput::Binary {
|
pending_input = Some(RelayInput::Binary {
|
||||||
generation,
|
generation,
|
||||||
bytes: bytes.to_vec(),
|
bytes: bytes.to_vec(),
|
||||||
|
|||||||
+16
-25
@@ -1,28 +1,19 @@
|
|||||||
use time::UtcOffset;
|
use anyhow::Result;
|
||||||
use time::format_description::well_known::Rfc3339;
|
use wakey_core::telemetry::{TelemetryConfig, TelemetryGuard};
|
||||||
use tracing_subscriber::fmt::time::OffsetTime;
|
|
||||||
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
|
|
||||||
|
|
||||||
pub fn init(verbose: u8) {
|
pub fn init(
|
||||||
let filter = EnvFilter::try_from_default_env()
|
verbose: u8,
|
||||||
.or_else(|_| EnvFilter::try_new(default_filter(verbose)))
|
config: &TelemetryConfig,
|
||||||
.expect("static tracing filter should parse");
|
agent_id: Option<&str>,
|
||||||
|
) -> Result<TelemetryGuard> {
|
||||||
tracing_subscriber::registry()
|
wakey_core::telemetry::init(
|
||||||
.with(filter)
|
config,
|
||||||
.with(fmt::layer().with_timer(local_offset_timer()))
|
"wakey-agent",
|
||||||
.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_filter(verbose: u8) -> &'static str {
|
|
||||||
match verbose {
|
match verbose {
|
||||||
0 => "wakey_agent=info,wakey=info",
|
0 => "wakey_agent=info,wakey=info,wakey_core=info,opentelemetry=warn",
|
||||||
1 => "wakey_agent=debug,wakey=debug",
|
1 => "wakey_agent=debug,wakey=debug,wakey_core=info,opentelemetry=warn",
|
||||||
_ => "wakey_agent=trace,wakey=debug",
|
_ => "wakey_agent=trace,wakey=debug,wakey_core=info,opentelemetry=warn",
|
||||||
}
|
},
|
||||||
}
|
agent_id,
|
||||||
|
)
|
||||||
fn local_offset_timer() -> OffsetTime<Rfc3339> {
|
|
||||||
let offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
|
|
||||||
OffsetTime::new(offset, Rfc3339)
|
|
||||||
}
|
}
|
||||||
@@ -33,17 +33,6 @@ toml = "1.1"
|
|||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
tower-http = { version = "0.6.8", features = ["fs"] }
|
tower-http = { version = "0.6.8", features = ["fs"] }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-opentelemetry = "0.32"
|
|
||||||
tracing-subscriber = { version = "0.3", features = [
|
|
||||||
"env-filter",
|
|
||||||
"fmt",
|
|
||||||
"json",
|
|
||||||
"time",
|
|
||||||
] }
|
|
||||||
time = { version = "0.3", features = ["formatting", "local-offset"] }
|
|
||||||
opentelemetry = "0.31"
|
|
||||||
opentelemetry-otlp = { version = "0.31", features = ["grpc-tonic", "trace"] }
|
|
||||||
opentelemetry_sdk = { version = "0.31", features = ["rt-tokio"] }
|
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
wakey-agent = { path = "../wakey-agent", registry = "gitea", version = "0" }
|
wakey-agent = { path = "../wakey-agent", registry = "gitea", version = "0" }
|
||||||
wakey-core = { path = "../wakey-core", registry = "gitea", version = "0" }
|
wakey-core = { path = "../wakey-core", registry = "gitea", version = "0", features = ["telemetry"] }
|
||||||
@@ -4,7 +4,7 @@ use axum::http::StatusCode;
|
|||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tracing::{info, info_span, warn};
|
use tracing::{info, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use wakey_agent::protocol::{
|
use wakey_agent::protocol::{
|
||||||
AgentCapability, AgentCapabilityOptions, AgentCommand, ErrorPayload, RequestId, ServerMessage,
|
AgentCapability, AgentCapabilityOptions, AgentCommand, ErrorPayload, RequestId, ServerMessage,
|
||||||
@@ -85,6 +85,7 @@ pub async fn run_command(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "relay_command", skip_all, fields(agent_id = %agent_id, request_id = tracing::field::Empty, command = tracing::field::Empty))]
|
||||||
pub async fn relay_agent_command(
|
pub async fn relay_agent_command(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
agent_id: &str,
|
agent_id: &str,
|
||||||
@@ -93,14 +94,11 @@ pub async fn relay_agent_command(
|
|||||||
) -> Result<RelayCommandResponse, ApiError> {
|
) -> Result<RelayCommandResponse, ApiError> {
|
||||||
let request_id_string = format!("req-{}", Uuid::new_v4());
|
let request_id_string = format!("req-{}", Uuid::new_v4());
|
||||||
let command_kind = command_kind(&command);
|
let command_kind = command_kind(&command);
|
||||||
|
tracing::Span::current()
|
||||||
|
.record("request_id", &request_id_string)
|
||||||
|
.record("command", command_kind);
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
let span = info_span!(
|
let mut operation = wakey_core::telemetry::Operation::new(command_kind);
|
||||||
"relay_command",
|
|
||||||
agent_id = %agent_id,
|
|
||||||
request_id = %request_id_string,
|
|
||||||
command = %command_kind,
|
|
||||||
);
|
|
||||||
let _span_guard = span.enter();
|
|
||||||
|
|
||||||
let request_id = RequestId::try_from(request_id_string.clone()).map_err(|err| {
|
let request_id = RequestId::try_from(request_id_string.clone()).map_err(|err| {
|
||||||
ApiError::new(
|
ApiError::new(
|
||||||
@@ -149,6 +147,7 @@ pub async fn relay_agent_command(
|
|||||||
if let Err(err) = tx.send(SessionEvent::Message(ServerMessage::Command {
|
if let Err(err) = tx.send(SessionEvent::Message(ServerMessage::Command {
|
||||||
request_id,
|
request_id,
|
||||||
command,
|
command,
|
||||||
|
trace_context: wakey_core::telemetry::current_trace_context(),
|
||||||
})) {
|
})) {
|
||||||
state.pending.lock().await.remove(&request_id_string);
|
state.pending.lock().await.remove(&request_id_string);
|
||||||
warn!(error = %err, "failed sending command to agent session");
|
warn!(error = %err, "failed sending command to agent session");
|
||||||
@@ -249,6 +248,7 @@ pub async fn relay_agent_command(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
|
operation.outcome("timeout");
|
||||||
state.pending.lock().await.remove(&request_id_string);
|
state.pending.lock().await.remove(&request_id_string);
|
||||||
warn!(
|
warn!(
|
||||||
timeout_ms = timeout.as_millis() as u64,
|
timeout_ms = timeout.as_millis() as u64,
|
||||||
@@ -276,6 +276,11 @@ pub async fn relay_agent_command(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
operation.outcome(if response.status == "ok" {
|
||||||
|
"ok"
|
||||||
|
} else {
|
||||||
|
"error"
|
||||||
|
});
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ pub struct TerminalSessionResponse {
|
|||||||
pub attachment_token: Option<String>,
|
pub attachment_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "terminal_create", skip_all)]
|
||||||
pub async fn create_terminal(
|
pub async fn create_terminal(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(request): Json<CreateTerminalRequest>,
|
Json(request): Json<CreateTerminalRequest>,
|
||||||
@@ -183,6 +184,7 @@ pub async fn list_terminals(State(state): State<AppState>) -> Json<Vec<TerminalS
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "terminal_attach", skip_all)]
|
||||||
pub async fn attach_terminal(
|
pub async fn attach_terminal(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(terminal_id): Path<String>,
|
Path(terminal_id): Path<String>,
|
||||||
@@ -210,6 +212,7 @@ pub async fn attach_terminal(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "terminal_close", skip_all)]
|
||||||
pub async fn close_terminal(
|
pub async fn close_terminal(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(terminal_id): Path<String>,
|
Path(terminal_id): Path<String>,
|
||||||
|
|||||||
@@ -113,6 +113,9 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
|
|||||||
ui_dist_dir,
|
ui_dist_dir,
|
||||||
bootstrap_enroll_tokens,
|
bootstrap_enroll_tokens,
|
||||||
telemetry: WritableTelemetry {
|
telemetry: WritableTelemetry {
|
||||||
|
authorization_env: base_telemetry
|
||||||
|
.authorization_env
|
||||||
|
.or_else(|| defaults.telemetry.authorization_env.clone()),
|
||||||
otlp_endpoint: args
|
otlp_endpoint: args
|
||||||
.telemetry_otlp_endpoint
|
.telemetry_otlp_endpoint
|
||||||
.clone()
|
.clone()
|
||||||
@@ -223,6 +226,7 @@ bootstrap_enroll_tokens = ["old-token"]
|
|||||||
|
|
||||||
[telemetry]
|
[telemetry]
|
||||||
otlp_endpoint = "http://otel.example"
|
otlp_endpoint = "http://otel.example"
|
||||||
|
authorization_env = "WAKEY_OTLP_AUTHORIZATION"
|
||||||
service_name = "old-service"
|
service_name = "old-service"
|
||||||
json_logs = true
|
json_logs = true
|
||||||
"#,
|
"#,
|
||||||
@@ -261,6 +265,10 @@ json_logs = true
|
|||||||
Some("/tmp/wakey-base/old.sqlite3")
|
Some("/tmp/wakey-base/old.sqlite3")
|
||||||
);
|
);
|
||||||
assert_eq!(parsed["telemetry"]["json_logs"].as_bool(), Some(true));
|
assert_eq!(parsed["telemetry"]["json_logs"].as_bool(), Some(true));
|
||||||
|
assert_eq!(
|
||||||
|
parsed["telemetry"]["authorization_env"].as_str(),
|
||||||
|
Some("WAKEY_OTLP_AUTHORIZATION")
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed["telemetry"]["service_name"].as_str(),
|
parsed["telemetry"]["service_name"].as_str(),
|
||||||
Some("old-service")
|
Some("old-service")
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ fn resolve_admin_public_url(
|
|||||||
fn resolve_telemetry(file: Option<FileTelemetryConfig>) -> TelemetryConfig {
|
fn resolve_telemetry(file: Option<FileTelemetryConfig>) -> TelemetryConfig {
|
||||||
let mut out = TelemetryConfig::default();
|
let mut out = TelemetryConfig::default();
|
||||||
if let Some(file) = file {
|
if let Some(file) = file {
|
||||||
|
out.authorization_env = file.authorization_env;
|
||||||
if let Some(endpoint) = file.otlp_endpoint {
|
if let Some(endpoint) = file.otlp_endpoint {
|
||||||
let trimmed = endpoint.trim().to_string();
|
let trimmed = endpoint.trim().to_string();
|
||||||
if !trimmed.is_empty() {
|
if !trimmed.is_empty() {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ pub struct DaemonConfig {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct TelemetryConfig {
|
pub struct TelemetryConfig {
|
||||||
pub otlp_endpoint: Option<String>,
|
pub otlp_endpoint: Option<String>,
|
||||||
|
pub authorization_env: Option<String>,
|
||||||
pub service_name: String,
|
pub service_name: String,
|
||||||
pub json_logs: bool,
|
pub json_logs: bool,
|
||||||
}
|
}
|
||||||
@@ -32,6 +33,7 @@ impl Default for TelemetryConfig {
|
|||||||
let defaults = &DEFAULT_CONFIG.telemetry;
|
let defaults = &DEFAULT_CONFIG.telemetry;
|
||||||
Self {
|
Self {
|
||||||
otlp_endpoint: defaults.otlp_endpoint.clone(),
|
otlp_endpoint: defaults.otlp_endpoint.clone(),
|
||||||
|
authorization_env: defaults.authorization_env.clone(),
|
||||||
service_name: defaults.service_name.clone(),
|
service_name: defaults.service_name.clone(),
|
||||||
json_logs: defaults.json_logs,
|
json_logs: defaults.json_logs,
|
||||||
}
|
}
|
||||||
@@ -57,6 +59,7 @@ pub(crate) struct FileConfig {
|
|||||||
#[derive(Debug, Deserialize, Default)]
|
#[derive(Debug, Deserialize, Default)]
|
||||||
pub(crate) struct FileTelemetryConfig {
|
pub(crate) struct FileTelemetryConfig {
|
||||||
pub(crate) otlp_endpoint: Option<String>,
|
pub(crate) otlp_endpoint: Option<String>,
|
||||||
|
pub(crate) authorization_env: Option<String>,
|
||||||
pub(crate) service_name: Option<String>,
|
pub(crate) service_name: Option<String>,
|
||||||
pub(crate) json_logs: Option<bool>,
|
pub(crate) json_logs: Option<bool>,
|
||||||
}
|
}
|
||||||
@@ -81,6 +84,8 @@ pub(crate) struct WritableConfig {
|
|||||||
pub(crate) struct WritableTelemetry {
|
pub(crate) struct WritableTelemetry {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub(crate) otlp_endpoint: Option<String>,
|
pub(crate) otlp_endpoint: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(crate) authorization_env: Option<String>,
|
||||||
pub(crate) service_name: String,
|
pub(crate) service_name: String,
|
||||||
pub(crate) json_logs: bool,
|
pub(crate) json_logs: bool,
|
||||||
}
|
}
|
||||||
@@ -98,6 +103,7 @@ pub(crate) static DEFAULT_CONFIG: LazyLock<WritableConfig> = LazyLock::new(|| Wr
|
|||||||
bootstrap_enroll_tokens: Vec::new(),
|
bootstrap_enroll_tokens: Vec::new(),
|
||||||
telemetry: WritableTelemetry {
|
telemetry: WritableTelemetry {
|
||||||
otlp_endpoint: None,
|
otlp_endpoint: None,
|
||||||
|
authorization_env: None,
|
||||||
service_name: "wakey-control-plane".to_string(),
|
service_name: "wakey-control-plane".to_string(),
|
||||||
json_logs: false,
|
json_logs: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,8 +28,10 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let daemon = config::DaemonConfig::from_serve_args(&args)?;
|
let daemon = config::DaemonConfig::from_serve_args(&args)?;
|
||||||
tracing::init(cli.verbose, &daemon.telemetry)?;
|
let telemetry = tracing::init(cli.verbose, &daemon.telemetry)?;
|
||||||
runtime::serve(daemon).await
|
let result = runtime::serve(daemon).await;
|
||||||
|
tokio::task::spawn_blocking(move || telemetry.shutdown()).await?;
|
||||||
|
result
|
||||||
}
|
}
|
||||||
Command::InitConfig(args) => {
|
Command::InitConfig(args) => {
|
||||||
if let Some(path) = config::write_init_config(&args)? {
|
if let Some(path) = config::write_init_config(&args)? {
|
||||||
|
|||||||
@@ -213,6 +213,8 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
|||||||
use tokio::signal::unix::{SignalKind, signal};
|
use tokio::signal::unix::{SignalKind, signal};
|
||||||
let mut server = server;
|
let mut server = server;
|
||||||
let mut hup = signal(SignalKind::hangup()).context("failed to install SIGHUP handler")?;
|
let mut hup = signal(SignalKind::hangup()).context("failed to install SIGHUP handler")?;
|
||||||
|
let mut terminate =
|
||||||
|
signal(SignalKind::terminate()).context("failed to install SIGTERM handler")?;
|
||||||
let mut gc_tick = tokio::time::interval(Duration::from_secs(300));
|
let mut gc_tick = tokio::time::interval(Duration::from_secs(300));
|
||||||
gc_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
gc_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||||
|
|
||||||
@@ -224,6 +226,11 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
|||||||
server.abort();
|
server.abort();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
_ = terminate.recv() => {
|
||||||
|
info!("SIGTERM received; shutting down control-plane");
|
||||||
|
server.abort();
|
||||||
|
break;
|
||||||
|
}
|
||||||
_ = hup.recv() => {
|
_ = hup.recv() => {
|
||||||
match app_state.store.reload_from_disk().await {
|
match app_state.store.reload_from_disk().await {
|
||||||
Ok(()) => info!("reloaded state from disk"),
|
Ok(()) => info!("reloaded state from disk"),
|
||||||
|
|||||||
@@ -1,99 +1,22 @@
|
|||||||
use anyhow::{Context, Result};
|
|
||||||
use opentelemetry::global;
|
|
||||||
use opentelemetry::trace::TracerProvider as _;
|
|
||||||
use opentelemetry_otlp::WithExportConfig;
|
|
||||||
use opentelemetry_sdk::Resource;
|
|
||||||
use opentelemetry_sdk::trace::{SdkTracerProvider, Tracer};
|
|
||||||
use time::UtcOffset;
|
|
||||||
use time::format_description::well_known::Rfc3339;
|
|
||||||
use tracing_subscriber::fmt::time::OffsetTime;
|
|
||||||
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
|
|
||||||
|
|
||||||
use crate::config::TelemetryConfig;
|
use crate::config::TelemetryConfig;
|
||||||
|
use anyhow::Result;
|
||||||
|
pub use wakey_core::telemetry::TelemetryGuard;
|
||||||
|
|
||||||
pub fn init(verbose: u8, telemetry: &TelemetryConfig) -> Result<()> {
|
pub fn init(verbose: u8, telemetry: &TelemetryConfig) -> Result<TelemetryGuard> {
|
||||||
let filter = EnvFilter::try_from_default_env()
|
let config = wakey_core::telemetry::TelemetryConfig {
|
||||||
.or_else(|_| EnvFilter::try_new(default_filter(verbose)))
|
otlp_endpoint: telemetry.otlp_endpoint.clone(),
|
||||||
.expect("static tracing filter should parse");
|
authorization_env: telemetry.authorization_env.clone(),
|
||||||
|
service_name: telemetry.service_name.clone(),
|
||||||
let otel = build_otel_layer(telemetry)?;
|
json_logs: telemetry.json_logs,
|
||||||
|
|
||||||
if telemetry.json_logs {
|
|
||||||
if let Some(otel_layer) = otel {
|
|
||||||
tracing_subscriber::registry()
|
|
||||||
.with(otel_layer)
|
|
||||||
.with(filter)
|
|
||||||
.with(fmt::layer().json().with_timer(local_offset_timer()))
|
|
||||||
.init();
|
|
||||||
tracing::info!(endpoint = ?telemetry.otlp_endpoint, json_logs = telemetry.json_logs, "tracing initialized with otlp exporter");
|
|
||||||
} else {
|
|
||||||
tracing_subscriber::registry()
|
|
||||||
.with(filter)
|
|
||||||
.with(fmt::layer().json().with_timer(local_offset_timer()))
|
|
||||||
.init();
|
|
||||||
tracing::info!(
|
|
||||||
json_logs = telemetry.json_logs,
|
|
||||||
"tracing initialized without otlp exporter"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if let Some(otel_layer) = otel {
|
|
||||||
tracing_subscriber::registry()
|
|
||||||
.with(otel_layer)
|
|
||||||
.with(filter)
|
|
||||||
.with(fmt::layer().with_timer(local_offset_timer()))
|
|
||||||
.init();
|
|
||||||
tracing::info!(endpoint = ?telemetry.otlp_endpoint, json_logs = telemetry.json_logs, "tracing initialized with otlp exporter");
|
|
||||||
} else {
|
|
||||||
tracing_subscriber::registry()
|
|
||||||
.with(filter)
|
|
||||||
.with(fmt::layer().with_timer(local_offset_timer()))
|
|
||||||
.init();
|
|
||||||
tracing::info!(
|
|
||||||
json_logs = telemetry.json_logs,
|
|
||||||
"tracing initialized without otlp exporter"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_otel_layer(
|
|
||||||
telemetry: &TelemetryConfig,
|
|
||||||
) -> Result<Option<tracing_opentelemetry::OpenTelemetryLayer<tracing_subscriber::Registry, Tracer>>>
|
|
||||||
{
|
|
||||||
let Some(endpoint) = telemetry.otlp_endpoint.as_deref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
};
|
||||||
|
wakey_core::telemetry::init(
|
||||||
let exporter = opentelemetry_otlp::SpanExporter::builder()
|
&config,
|
||||||
.with_tonic()
|
"wakey-control-plane",
|
||||||
.with_endpoint(endpoint)
|
|
||||||
.build()
|
|
||||||
.context("failed to build OTLP span exporter")?;
|
|
||||||
|
|
||||||
let provider = SdkTracerProvider::builder()
|
|
||||||
.with_batch_exporter(exporter)
|
|
||||||
.with_resource(
|
|
||||||
Resource::builder_empty()
|
|
||||||
.with_service_name(telemetry.service_name.clone())
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let tracer = provider.tracer(telemetry.service_name.clone());
|
|
||||||
global::set_tracer_provider(provider);
|
|
||||||
Ok(Some(tracing_opentelemetry::layer().with_tracer(tracer)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_filter(verbose: u8) -> &'static str {
|
|
||||||
match verbose {
|
match verbose {
|
||||||
0 => "wakey_control_plane=info",
|
0 => "wakey_control_plane=info,wakey_core=info,opentelemetry=warn",
|
||||||
1 => "wakey_control_plane=debug",
|
1 => "wakey_control_plane=debug,wakey_core=info,opentelemetry=warn",
|
||||||
_ => "wakey_control_plane=trace",
|
_ => "wakey_control_plane=trace,wakey_core=info,opentelemetry=warn",
|
||||||
}
|
},
|
||||||
}
|
None,
|
||||||
|
)
|
||||||
fn local_offset_timer() -> OffsetTime<Rfc3339> {
|
|
||||||
let offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
|
|
||||||
OffsetTime::new(offset, Rfc3339)
|
|
||||||
}
|
}
|
||||||
@@ -309,12 +309,18 @@ async fn process_agent_text(
|
|||||||
anyhow::bail!("device_snapshot for unauthenticated or mismatched agent");
|
anyhow::bail!("device_snapshot for unauthenticated or mismatched agent");
|
||||||
}
|
}
|
||||||
ensure_current_session(state, &agent_id, connection_id).await?;
|
ensure_current_session(state, &agent_id, connection_id).await?;
|
||||||
match state
|
let mut operation = wakey_core::telemetry::Operation::new("snapshot_store");
|
||||||
|
let span = tracing::info_span!("store_device_snapshot", agent_id = %agent_id, devices = devices.len());
|
||||||
|
match tracing::Instrument::instrument(
|
||||||
|
state
|
||||||
.store
|
.store
|
||||||
.replace_agent_device_snapshot(&agent_id, &devices)
|
.replace_agent_device_snapshot(&agent_id, &devices),
|
||||||
|
span,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(accepted) => {
|
Ok(accepted) => {
|
||||||
|
operation.outcome("ok");
|
||||||
debug!(agent_id = %agent_id, accepted, "agent websocket device snapshot accepted");
|
debug!(agent_id = %agent_id, accepted, "agent websocket device snapshot accepted");
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
|||||||
@@ -5,11 +5,28 @@ edition = "2024"
|
|||||||
publish = ["gitea"]
|
publish = ["gitea"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
anyhow = { workspace = true, optional = true }
|
||||||
|
opentelemetry = { version = "0.31", optional = true }
|
||||||
|
opentelemetry-otlp = { version = "0.31", default-features = false, features = ["grpc-tonic", "trace", "logs", "metrics"], optional = true }
|
||||||
|
opentelemetry_sdk = { version = "0.31", optional = true }
|
||||||
|
opentelemetry-appender-tracing = { version = "0.31", features = ["experimental_use_tracing_span_context"], optional = true }
|
||||||
|
tracing = { workspace = true, optional = true }
|
||||||
|
tracing-opentelemetry = { version = "0.32", optional = true }
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json", "time"], optional = true }
|
||||||
|
time = { version = "0.3", features = ["formatting", "local-offset"], optional = true }
|
||||||
|
tonic = { version = "0.14", default-features = false, optional = true }
|
||||||
macaddr = { workspace = true }
|
macaddr = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_with = { version = "3", features = ["json"] }
|
serde_with = { version = "3", features = ["json"] }
|
||||||
strum = { version = "0", features = ["derive", "strum_macros"] }
|
strum = { version = "0", features = ["derive", "strum_macros"] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
telemetry = ["dep:anyhow", "dep:opentelemetry", "dep:opentelemetry-otlp", "dep:opentelemetry_sdk", "dep:opentelemetry-appender-tracing", "dep:tracing", "dep:tracing-opentelemetry", "dep:tracing-subscriber", "dep:time", "dep:tonic"]
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync", "time"] }
|
||||||
|
tokio-stream = { version = "0.1", features = ["net"] }
|
||||||
|
tonic = "0.14"
|
||||||
|
opentelemetry-proto = { version = "0.31", default-features = false, features = ["gen-tonic", "trace", "logs", "metrics"] }
|
||||||
@@ -3,4 +3,7 @@
|
|||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod parse;
|
pub mod parse;
|
||||||
|
|
||||||
|
#[cfg(feature = "telemetry")]
|
||||||
|
pub mod telemetry;
|
||||||
|
|
||||||
pub use model::*;
|
pub use model::*;
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
//! Shared, opt-in OTLP setup. Local logging remains available without a collector.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use opentelemetry::{
|
||||||
|
KeyValue, global,
|
||||||
|
trace::{TraceContextExt as _, TracerProvider as _},
|
||||||
|
};
|
||||||
|
use opentelemetry_otlp::WithExportConfig;
|
||||||
|
use opentelemetry_otlp::WithTonicConfig;
|
||||||
|
use opentelemetry_sdk::{
|
||||||
|
Resource, logs::SdkLoggerProvider, metrics::SdkMeterProvider, trace::SdkTracerProvider,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||||
|
use tracing_subscriber::{EnvFilter, Layer, fmt, layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
/// A single gRPC receiver accepts all three signals. No endpoint means local-only.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct TelemetryConfig {
|
||||||
|
pub otlp_endpoint: Option<String>,
|
||||||
|
/// Name of the environment variable holding the complete Authorization header.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub authorization_env: Option<String>,
|
||||||
|
pub service_name: String,
|
||||||
|
pub json_logs: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owns providers until all instrumented work has stopped; flush off the async workers.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct TelemetryGuard {
|
||||||
|
traces: Option<SdkTracerProvider>,
|
||||||
|
logs: Option<SdkLoggerProvider>,
|
||||||
|
metrics: Option<SdkMeterProvider>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TelemetryGuard {
|
||||||
|
pub fn shutdown(self) {
|
||||||
|
// Export failures must not turn an otherwise successful daemon exit into a failure.
|
||||||
|
if let Some(provider) = self.metrics {
|
||||||
|
let _ = provider.shutdown();
|
||||||
|
}
|
||||||
|
if let Some(provider) = self.logs {
|
||||||
|
let _ = provider.shutdown();
|
||||||
|
}
|
||||||
|
if let Some(provider) = self.traces {
|
||||||
|
let _ = provider.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init(
|
||||||
|
config: &TelemetryConfig,
|
||||||
|
service: &str,
|
||||||
|
default_filter: &str,
|
||||||
|
instance: Option<&str>,
|
||||||
|
) -> Result<TelemetryGuard> {
|
||||||
|
let filter =
|
||||||
|
EnvFilter::try_from_default_env().or_else(|_| EnvFilter::try_new(default_filter))?;
|
||||||
|
let service = if config.service_name.is_empty() {
|
||||||
|
service
|
||||||
|
} else {
|
||||||
|
&config.service_name
|
||||||
|
};
|
||||||
|
let mut guard = TelemetryGuard::default();
|
||||||
|
if let Some(endpoint) = config
|
||||||
|
.otlp_endpoint
|
||||||
|
.as_deref()
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
{
|
||||||
|
let metadata = authorization_metadata(config.authorization_env.as_deref(), |name| {
|
||||||
|
std::env::var(name).ok()
|
||||||
|
})?;
|
||||||
|
let mut resource = Resource::builder_empty().with_service_name(service.to_owned());
|
||||||
|
if let Some(instance) = instance {
|
||||||
|
resource =
|
||||||
|
resource.with_attribute(KeyValue::new("service.instance.id", instance.to_owned()));
|
||||||
|
}
|
||||||
|
let resource = resource.build();
|
||||||
|
// Small fixed queues are intentional: a disconnected collector cannot grow router RAM.
|
||||||
|
let trace_exporter = opentelemetry_otlp::SpanExporter::builder()
|
||||||
|
.with_tonic()
|
||||||
|
.with_metadata(metadata.clone())
|
||||||
|
.with_endpoint(endpoint)
|
||||||
|
.with_timeout(Duration::from_secs(3))
|
||||||
|
.build()
|
||||||
|
.context("OTLP trace exporter")?;
|
||||||
|
let trace_processor = opentelemetry_sdk::trace::BatchSpanProcessor::builder(trace_exporter)
|
||||||
|
.with_batch_config(
|
||||||
|
opentelemetry_sdk::trace::BatchConfigBuilder::default()
|
||||||
|
.with_max_queue_size(256)
|
||||||
|
.with_max_export_batch_size(64)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
let traces = SdkTracerProvider::builder()
|
||||||
|
.with_span_processor(trace_processor)
|
||||||
|
.with_resource(resource.clone())
|
||||||
|
.build();
|
||||||
|
let log_exporter = opentelemetry_otlp::LogExporter::builder()
|
||||||
|
.with_tonic()
|
||||||
|
.with_metadata(metadata.clone())
|
||||||
|
.with_endpoint(endpoint)
|
||||||
|
.with_timeout(Duration::from_secs(3))
|
||||||
|
.build()
|
||||||
|
.context("OTLP log exporter")?;
|
||||||
|
let log_processor = opentelemetry_sdk::logs::BatchLogProcessor::builder(log_exporter)
|
||||||
|
.with_batch_config(
|
||||||
|
opentelemetry_sdk::logs::BatchConfigBuilder::default()
|
||||||
|
.with_max_queue_size(256)
|
||||||
|
.with_max_export_batch_size(64)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
let logs = SdkLoggerProvider::builder()
|
||||||
|
.with_log_processor(log_processor)
|
||||||
|
.with_resource(resource.clone())
|
||||||
|
.build();
|
||||||
|
let metric_exporter = opentelemetry_otlp::MetricExporter::builder()
|
||||||
|
.with_tonic()
|
||||||
|
.with_metadata(metadata)
|
||||||
|
.with_endpoint(endpoint)
|
||||||
|
.with_timeout(Duration::from_secs(3))
|
||||||
|
.build()
|
||||||
|
.context("OTLP metric exporter")?;
|
||||||
|
let reader = opentelemetry_sdk::metrics::PeriodicReader::builder(metric_exporter)
|
||||||
|
.with_interval(Duration::from_secs(30))
|
||||||
|
.build();
|
||||||
|
let metrics = SdkMeterProvider::builder()
|
||||||
|
.with_reader(reader)
|
||||||
|
.with_resource(resource)
|
||||||
|
.build();
|
||||||
|
global::set_tracer_provider(traces.clone());
|
||||||
|
global::set_meter_provider(metrics.clone());
|
||||||
|
let _ = METRICS.set(Instruments::new());
|
||||||
|
let otel = tracing_opentelemetry::layer().with_tracer(traces.tracer(service.to_owned()));
|
||||||
|
// Exporter failures must remain local, never feed back into the failing exporter.
|
||||||
|
let logs_layer =
|
||||||
|
opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge::new(&logs)
|
||||||
|
.with_filter(tracing_subscriber::filter::filter_fn(|metadata| {
|
||||||
|
!metadata.target().starts_with("opentelemetry")
|
||||||
|
}));
|
||||||
|
let subscriber = tracing_subscriber::registry()
|
||||||
|
.with(filter)
|
||||||
|
.with(otel)
|
||||||
|
.with(logs_layer);
|
||||||
|
if config.json_logs {
|
||||||
|
subscriber
|
||||||
|
.with(fmt::layer().json().with_timer(local_timer()))
|
||||||
|
.try_init()?;
|
||||||
|
} else {
|
||||||
|
subscriber
|
||||||
|
.with(fmt::layer().with_timer(local_timer()))
|
||||||
|
.try_init()?;
|
||||||
|
}
|
||||||
|
guard = TelemetryGuard {
|
||||||
|
traces: Some(traces),
|
||||||
|
logs: Some(logs),
|
||||||
|
metrics: Some(metrics),
|
||||||
|
};
|
||||||
|
} else if config.json_logs {
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(filter)
|
||||||
|
.with(fmt::layer().json().with_timer(local_timer()))
|
||||||
|
.try_init()?;
|
||||||
|
} else {
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(filter)
|
||||||
|
.with(fmt::layer().with_timer(local_timer()))
|
||||||
|
.try_init()?;
|
||||||
|
}
|
||||||
|
Ok(guard)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authorization_metadata(
|
||||||
|
name: Option<&str>,
|
||||||
|
lookup: impl FnOnce(&str) -> Option<String>,
|
||||||
|
) -> Result<tonic::metadata::MetadataMap> {
|
||||||
|
let mut metadata = tonic::metadata::MetadataMap::new();
|
||||||
|
let Some(name) = name else {
|
||||||
|
return Ok(metadata);
|
||||||
|
};
|
||||||
|
anyhow::ensure!(
|
||||||
|
!name.is_empty()
|
||||||
|
&& name.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_')
|
||||||
|
&& !name.as_bytes()[0].is_ascii_digit(),
|
||||||
|
"telemetry authorization_env must be a valid environment variable name"
|
||||||
|
);
|
||||||
|
let raw = lookup(name).with_context(|| {
|
||||||
|
format!("telemetry authorization environment variable {name} is missing or not UTF-8")
|
||||||
|
})?;
|
||||||
|
let header = raw.trim();
|
||||||
|
anyhow::ensure!(
|
||||||
|
!header.is_empty(),
|
||||||
|
"telemetry authorization environment variable {name} is empty"
|
||||||
|
);
|
||||||
|
let valid_scheme = header.split_once(' ').is_some_and(|(scheme, credentials)| {
|
||||||
|
(scheme.eq_ignore_ascii_case("basic") || scheme.eq_ignore_ascii_case("bearer"))
|
||||||
|
&& !credentials.trim().is_empty()
|
||||||
|
});
|
||||||
|
anyhow::ensure!(
|
||||||
|
valid_scheme && !raw.contains(['\r', '\n']),
|
||||||
|
"telemetry authorization environment variable {name} must contain a single Basic or Bearer header"
|
||||||
|
);
|
||||||
|
// Never include the supplied value/parser error in diagnostics. Sensitive
|
||||||
|
// metadata also hides it if a dependency Debug-prints an outgoing request.
|
||||||
|
let mut value = tonic::metadata::MetadataValue::try_from(header).map_err(|_| {
|
||||||
|
anyhow::anyhow!("invalid authorization metadata in environment variable {name}")
|
||||||
|
})?;
|
||||||
|
value.set_sensitive(true);
|
||||||
|
metadata.insert("authorization", value);
|
||||||
|
Ok(metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_timer() -> fmt::time::OffsetTime<time::format_description::well_known::Rfc3339> {
|
||||||
|
fmt::time::OffsetTime::new(
|
||||||
|
time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC),
|
||||||
|
time::format_description::well_known::Rfc3339,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct instruments once, after the real provider exists. Disabled telemetry
|
||||||
|
// therefore does no per-PTY-read allocations or instrument registration.
|
||||||
|
static METRICS: OnceLock<Instruments> = OnceLock::new();
|
||||||
|
struct Instruments {
|
||||||
|
operations: opentelemetry::metrics::Counter<u64>,
|
||||||
|
duration: opentelemetry::metrics::Histogram<f64>,
|
||||||
|
snapshot_devices: opentelemetry::metrics::Counter<u64>,
|
||||||
|
terminal_active: opentelemetry::metrics::UpDownCounter<i64>,
|
||||||
|
terminal_input: opentelemetry::metrics::Counter<u64>,
|
||||||
|
terminal_output: opentelemetry::metrics::Counter<u64>,
|
||||||
|
terminal_backpressure: opentelemetry::metrics::Counter<u64>,
|
||||||
|
}
|
||||||
|
impl Instruments {
|
||||||
|
fn new() -> Self {
|
||||||
|
let meter = global::meter("wakey");
|
||||||
|
Self {
|
||||||
|
operations: meter.u64_counter("wakey.operations").build(),
|
||||||
|
duration: meter
|
||||||
|
.f64_histogram("wakey.operation.duration")
|
||||||
|
.with_unit("s")
|
||||||
|
.build(),
|
||||||
|
snapshot_devices: meter.u64_counter("wakey.snapshot.devices").build(),
|
||||||
|
terminal_active: meter.i64_up_down_counter("wakey.terminal.active").build(),
|
||||||
|
terminal_input: meter
|
||||||
|
.u64_counter("wakey.terminal.input.bytes")
|
||||||
|
.with_unit("By")
|
||||||
|
.build(),
|
||||||
|
terminal_output: meter
|
||||||
|
.u64_counter("wakey.terminal.output.bytes")
|
||||||
|
.with_unit("By")
|
||||||
|
.build(),
|
||||||
|
terminal_backpressure: meter.u64_counter("wakey.terminal.backpressure").build(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// W3C context only, never arbitrary application fields or credentials.
|
||||||
|
pub fn current_trace_context() -> BTreeMap<String, String> {
|
||||||
|
use opentelemetry::propagation::TextMapPropagator;
|
||||||
|
let mut carrier = std::collections::HashMap::new();
|
||||||
|
opentelemetry_sdk::propagation::TraceContextPropagator::new()
|
||||||
|
.inject_context(&tracing::Span::current().context(), &mut carrier);
|
||||||
|
carrier.into_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_remote_parent(span: &tracing::Span, carrier: &BTreeMap<String, String>) {
|
||||||
|
use opentelemetry::propagation::TextMapPropagator;
|
||||||
|
let carrier: std::collections::HashMap<String, String> = carrier
|
||||||
|
.iter()
|
||||||
|
.filter(|(key, value)| {
|
||||||
|
matches!(key.as_str(), "traceparent" | "tracestate") && value.len() <= 512
|
||||||
|
})
|
||||||
|
.map(|(k, v)| (k.clone(), v.clone()))
|
||||||
|
.collect();
|
||||||
|
let context = opentelemetry_sdk::propagation::TraceContextPropagator::new().extract(&carrier);
|
||||||
|
if context.has_active_span() {
|
||||||
|
let _ = span.set_parent(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records completion on every return path, without high-cardinality identifiers.
|
||||||
|
pub struct Operation {
|
||||||
|
started: Instant,
|
||||||
|
name: &'static str,
|
||||||
|
outcome: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Operation {
|
||||||
|
pub fn new(name: &'static str) -> Self {
|
||||||
|
Self {
|
||||||
|
started: Instant::now(),
|
||||||
|
name,
|
||||||
|
outcome: "error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn outcome(&mut self, outcome: &'static str) {
|
||||||
|
self.outcome = outcome;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Operation {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let Some(metrics) = METRICS.get() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let labels = [
|
||||||
|
KeyValue::new("operation", self.name),
|
||||||
|
KeyValue::new("outcome", self.outcome),
|
||||||
|
];
|
||||||
|
metrics.operations.add(1, &labels);
|
||||||
|
metrics
|
||||||
|
.duration
|
||||||
|
.record(self.started.elapsed().as_secs_f64(), &labels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Counter {
|
||||||
|
SnapshotDevices,
|
||||||
|
TerminalInputBytes,
|
||||||
|
TerminalOutputBytes,
|
||||||
|
TerminalBackpressure,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn count(counter: Counter, value: u64) {
|
||||||
|
let Some(metrics) = METRICS.get() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let counter = match counter {
|
||||||
|
Counter::SnapshotDevices => &metrics.snapshot_devices,
|
||||||
|
Counter::TerminalInputBytes => &metrics.terminal_input,
|
||||||
|
Counter::TerminalOutputBytes => &metrics.terminal_output,
|
||||||
|
Counter::TerminalBackpressure => &metrics.terminal_backpressure,
|
||||||
|
};
|
||||||
|
counter.add(value, &[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An active session contributes until its worker finishes or is cancelled.
|
||||||
|
pub struct ActiveTerminal;
|
||||||
|
impl ActiveTerminal {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
if let Some(metrics) = METRICS.get() {
|
||||||
|
metrics.terminal_active.add(1, &[]);
|
||||||
|
}
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Default for ActiveTerminal {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for ActiveTerminal {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(metrics) = METRICS.get() {
|
||||||
|
metrics.terminal_active.add(-1, &[]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod auth_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authorization_supports_both_schemes_and_redacts_debug() {
|
||||||
|
for header in ["Basic dGVzdDp0ZXN0", "Bearer test-token"] {
|
||||||
|
let metadata =
|
||||||
|
authorization_metadata(Some("OTLP_AUTH"), |_| Some(header.into())).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
metadata.get("authorization").unwrap().to_str().unwrap(),
|
||||||
|
header
|
||||||
|
);
|
||||||
|
assert!(!format!("{metadata:?}").contains(header));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
authorization_metadata(None, |_| panic!("no credential lookup"))
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_empty_and_invalid_auth_fail_without_echoing_secrets() {
|
||||||
|
for value in [
|
||||||
|
None,
|
||||||
|
Some(""),
|
||||||
|
Some(" "),
|
||||||
|
Some("Basic "),
|
||||||
|
Some("Digest private-secret"),
|
||||||
|
Some("Basic private-secret\r\n"),
|
||||||
|
Some("Bearer private-secret\u{7f}"),
|
||||||
|
] {
|
||||||
|
let error = authorization_metadata(Some("OTLP_AUTH"), |_| value.map(str::to_owned))
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(!format!("{error:#}").contains("private-secret"));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
authorization_metadata(Some("BAD=NAME"), |_| panic!(
|
||||||
|
"invalid name must not be looked up"
|
||||||
|
))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
#![cfg(feature = "telemetry")]
|
||||||
|
|
||||||
|
use opentelemetry_proto::tonic::collector::{
|
||||||
|
logs::v1 as logs, metrics::v1 as metrics, trace::v1 as traces,
|
||||||
|
};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tonic::{Request, Response, Status};
|
||||||
|
use wakey_core::telemetry::{self, TelemetryConfig};
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct Collector {
|
||||||
|
traces: Arc<Mutex<Vec<traces::ExportTraceServiceRequest>>>,
|
||||||
|
logs: Arc<Mutex<Vec<logs::ExportLogsServiceRequest>>>,
|
||||||
|
metrics: Arc<Mutex<Vec<metrics::ExportMetricsServiceRequest>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_auth<T>(request: &Request<T>) -> Result<(), Status> {
|
||||||
|
if request
|
||||||
|
.metadata()
|
||||||
|
.get("authorization")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
!= Some("Basic dGVzdDp0ZXN0")
|
||||||
|
{
|
||||||
|
return Err(Status::unauthenticated(
|
||||||
|
"test receiver requires authorization",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tonic::async_trait]
|
||||||
|
impl traces::trace_service_server::TraceService for Collector {
|
||||||
|
async fn export(
|
||||||
|
&self,
|
||||||
|
request: Request<traces::ExportTraceServiceRequest>,
|
||||||
|
) -> Result<Response<traces::ExportTraceServiceResponse>, Status> {
|
||||||
|
check_auth(&request)?;
|
||||||
|
self.traces.lock().unwrap().push(request.into_inner());
|
||||||
|
Ok(Response::new(Default::default()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[tonic::async_trait]
|
||||||
|
impl logs::logs_service_server::LogsService for Collector {
|
||||||
|
async fn export(
|
||||||
|
&self,
|
||||||
|
request: Request<logs::ExportLogsServiceRequest>,
|
||||||
|
) -> Result<Response<logs::ExportLogsServiceResponse>, Status> {
|
||||||
|
check_auth(&request)?;
|
||||||
|
self.logs.lock().unwrap().push(request.into_inner());
|
||||||
|
Ok(Response::new(Default::default()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[tonic::async_trait]
|
||||||
|
impl metrics::metrics_service_server::MetricsService for Collector {
|
||||||
|
async fn export(
|
||||||
|
&self,
|
||||||
|
request: Request<metrics::ExportMetricsServiceRequest>,
|
||||||
|
) -> Result<Response<metrics::ExportMetricsServiceResponse>, Status> {
|
||||||
|
check_auth(&request)?;
|
||||||
|
self.metrics.lock().unwrap().push(request.into_inner());
|
||||||
|
Ok(Response::new(Default::default()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shutdown is the synchronization point: no sleeps or export-interval polling.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn exports_all_signals_and_preserves_remote_command_parent() {
|
||||||
|
// Exercise the real global subscriber without mutating this process's environment
|
||||||
|
// or inheriting a developer's RUST_LOG=warn/off setting.
|
||||||
|
if std::env::var_os("WAKEY_OTLP_TEST_CHILD").is_none() {
|
||||||
|
let status = std::process::Command::new(std::env::current_exe().unwrap())
|
||||||
|
.args([
|
||||||
|
"--exact",
|
||||||
|
"exports_all_signals_and_preserves_remote_command_parent",
|
||||||
|
"--nocapture",
|
||||||
|
])
|
||||||
|
.env("WAKEY_OTLP_TEST_CHILD", "1")
|
||||||
|
.env("RUST_LOG", "info")
|
||||||
|
.env("WAKEY_OTLP_TEST_AUTH", "Basic dGVzdDp0ZXN0")
|
||||||
|
.status()
|
||||||
|
.unwrap();
|
||||||
|
assert!(status.success());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let endpoint = format!("http://{}", listener.local_addr().unwrap());
|
||||||
|
let collector = Collector::default();
|
||||||
|
let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
|
||||||
|
let server = tonic::transport::Server::builder()
|
||||||
|
.add_service(traces::trace_service_server::TraceServiceServer::new(
|
||||||
|
collector.clone(),
|
||||||
|
))
|
||||||
|
.add_service(logs::logs_service_server::LogsServiceServer::new(
|
||||||
|
collector.clone(),
|
||||||
|
))
|
||||||
|
.add_service(metrics::metrics_service_server::MetricsServiceServer::new(
|
||||||
|
collector.clone(),
|
||||||
|
))
|
||||||
|
.serve_with_incoming_shutdown(
|
||||||
|
tokio_stream::wrappers::TcpListenerStream::new(listener),
|
||||||
|
async {
|
||||||
|
let _ = stop_rx.await;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let server = tokio::spawn(server);
|
||||||
|
let guard = telemetry::init(
|
||||||
|
&TelemetryConfig {
|
||||||
|
otlp_endpoint: Some(endpoint),
|
||||||
|
authorization_env: Some("WAKEY_OTLP_TEST_AUTH".into()),
|
||||||
|
service_name: "wakey-test".into(),
|
||||||
|
json_logs: false,
|
||||||
|
},
|
||||||
|
"unused",
|
||||||
|
"info",
|
||||||
|
Some("test-agent"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let parent = tracing::info_span!("relay_command");
|
||||||
|
let context = parent.in_scope(telemetry::current_trace_context);
|
||||||
|
assert!(context.contains_key("traceparent"));
|
||||||
|
let child = tracing::info_span!("execute_command");
|
||||||
|
telemetry::set_remote_parent(&child, &context);
|
||||||
|
child.in_scope(|| {
|
||||||
|
tracing::info!(command = "inventory", "command complete");
|
||||||
|
let mut operation = telemetry::Operation::new("inventory");
|
||||||
|
operation.outcome("ok");
|
||||||
|
telemetry::count(telemetry::Counter::SnapshotDevices, 3);
|
||||||
|
let _active = telemetry::ActiveTerminal::new();
|
||||||
|
});
|
||||||
|
drop(child);
|
||||||
|
drop(parent);
|
||||||
|
tracing::warn!(target: "opentelemetry_sdk", "synthetic exporter failure remains local");
|
||||||
|
let invalid_parent = tracing::info_span!("invalid_remote_parent");
|
||||||
|
telemetry::set_remote_parent(
|
||||||
|
&invalid_parent,
|
||||||
|
&[("traceparent".into(), "malformed".into())].into(),
|
||||||
|
);
|
||||||
|
drop(invalid_parent);
|
||||||
|
tokio::task::spawn_blocking(move || guard.shutdown())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let _ = stop_tx.send(());
|
||||||
|
server.await.unwrap().unwrap();
|
||||||
|
|
||||||
|
let traces = collector.traces.lock().unwrap();
|
||||||
|
let spans: Vec<_> = traces
|
||||||
|
.iter()
|
||||||
|
.flat_map(|r| &r.resource_spans)
|
||||||
|
.flat_map(|r| &r.scope_spans)
|
||||||
|
.flat_map(|s| &s.spans)
|
||||||
|
.collect();
|
||||||
|
let parent = spans.iter().find(|s| s.name == "relay_command").unwrap();
|
||||||
|
let child = spans.iter().find(|s| s.name == "execute_command").unwrap();
|
||||||
|
assert_eq!(parent.trace_id, child.trace_id);
|
||||||
|
assert_eq!(parent.span_id, child.parent_span_id);
|
||||||
|
let invalid_parent = spans
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name == "invalid_remote_parent")
|
||||||
|
.unwrap();
|
||||||
|
assert!(invalid_parent.parent_span_id.is_empty());
|
||||||
|
assert_ne!(parent.trace_id, invalid_parent.trace_id);
|
||||||
|
let logs = collector.logs.lock().unwrap();
|
||||||
|
let records: Vec<_> = logs
|
||||||
|
.iter()
|
||||||
|
.flat_map(|r| &r.resource_logs)
|
||||||
|
.flat_map(|r| &r.scope_logs)
|
||||||
|
.flat_map(|s| &s.log_records)
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
records
|
||||||
|
.iter()
|
||||||
|
.any(|r| r.trace_id == child.trace_id && r.span_id == child.span_id)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!records
|
||||||
|
.iter()
|
||||||
|
.any(|r| format!("{:?}", r.body).contains("synthetic exporter failure"))
|
||||||
|
);
|
||||||
|
let metrics = collector.metrics.lock().unwrap();
|
||||||
|
let names: Vec<_> = metrics
|
||||||
|
.iter()
|
||||||
|
.flat_map(|r| &r.resource_metrics)
|
||||||
|
.flat_map(|r| &r.scope_metrics)
|
||||||
|
.flat_map(|s| &s.metrics)
|
||||||
|
.map(|m| m.name.as_str())
|
||||||
|
.collect();
|
||||||
|
for name in [
|
||||||
|
"wakey.operations",
|
||||||
|
"wakey.operation.duration",
|
||||||
|
"wakey.snapshot.devices",
|
||||||
|
"wakey.terminal.active",
|
||||||
|
] {
|
||||||
|
assert!(names.contains(&name), "missing {name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user