diff --git a/Cargo.lock b/Cargo.lock
index 208f31f..1fc9518 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -11,6 +11,12 @@ dependencies = [
"gimli",
]
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -436,6 +442,36 @@ dependencies = [
"uuid",
]
+[[package]]
+name = "aws-sdk-cloudwatch"
+version = "1.127.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac606233394937c6014a46fc776214f5eed0b6bde3bdd38c8fa1aa28e0f3ad09"
+dependencies = [
+ "arc-swap",
+ "aws-credential-types",
+ "aws-runtime",
+ "aws-smithy-async",
+ "aws-smithy-cbor",
+ "aws-smithy-compression",
+ "aws-smithy-http 0.64.0",
+ "aws-smithy-json 0.63.0",
+ "aws-smithy-observability 0.3.0",
+ "aws-smithy-runtime",
+ "aws-smithy-runtime-api",
+ "aws-smithy-schema",
+ "aws-smithy-types",
+ "aws-types",
+ "fastrand",
+ "flate2",
+ "http 0.2.12",
+ "http 1.4.0",
+ "http-body 1.0.1",
+ "http-body-util",
+ "regex-lite",
+ "tracing",
+]
+
[[package]]
name = "aws-sdk-cloudwatchlogs"
version = "1.149.0"
@@ -571,6 +607,32 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "aws-sdk-xray"
+version = "1.109.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aaa8a863929c975e71cece4092363b01140da9afeabd2a54d8c66e7cf9fce56b"
+dependencies = [
+ "arc-swap",
+ "aws-credential-types",
+ "aws-runtime",
+ "aws-smithy-async",
+ "aws-smithy-http 0.64.0",
+ "aws-smithy-json 0.63.0",
+ "aws-smithy-observability 0.3.0",
+ "aws-smithy-runtime",
+ "aws-smithy-runtime-api",
+ "aws-smithy-schema",
+ "aws-smithy-types",
+ "aws-types",
+ "bytes",
+ "fastrand",
+ "http 0.2.12",
+ "http 1.4.0",
+ "regex-lite",
+ "tracing",
+]
+
[[package]]
name = "aws-sigv4"
version = "1.5.1"
@@ -605,6 +667,19 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "aws-smithy-cbor"
+version = "0.62.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f069169abdc367533ea89b869b7dfaadd4134a9bc287c13dbe5ff364ff13228"
+dependencies = [
+ "aws-smithy-runtime-api",
+ "aws-smithy-schema",
+ "aws-smithy-types",
+ "minicbor",
+ "num-bigint",
+]
+
[[package]]
name = "aws-smithy-checksums"
version = "0.64.7"
@@ -626,6 +701,24 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "aws-smithy-compression"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "016ef334a13c53bd75184bb8f89b0fb2aa4d56cb3cfc478ab3f05f13654b60a1"
+dependencies = [
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "bytes",
+ "flate2",
+ "futures-util",
+ "http 1.4.0",
+ "http-body 1.0.1",
+ "http-body-util",
+ "pin-project-lite",
+ "tracing",
+]
+
[[package]]
name = "aws-smithy-eventstream"
version = "0.60.20"
@@ -1990,10 +2083,12 @@ dependencies = [
"aws-config",
"aws-credential-types",
"aws-lc-rs",
+ "aws-sdk-cloudwatch",
"aws-sdk-cloudwatchlogs",
"aws-sdk-kms",
"aws-sdk-s3",
"aws-sdk-ssm",
+ "aws-sdk-xray",
"aws-sigv4",
"aws-smithy-http-client",
"aws-smithy-runtime-api",
@@ -2165,6 +2260,17 @@ version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
+[[package]]
+name = "flate2"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+ "zlib-rs",
+]
+
[[package]]
name = "fnv"
version = "1.0.7"
@@ -3162,12 +3268,31 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+[[package]]
+name = "minicbor"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c12b4033ffaa92fbf9df03df38d19324f52bad130dd223f811734a8006dd2d69"
+dependencies = [
+ "half",
+]
+
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+[[package]]
+name = "miniz_oxide"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
[[package]]
name = "mio"
version = "1.2.0"
@@ -4434,6 +4559,12 @@ dependencies = [
"libc",
]
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
[[package]]
name = "siphasher"
version = "1.0.2"
@@ -6425,6 +6556,12 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "zlib-rs"
+version = "0.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
+
[[package]]
name = "zmij"
version = "1.0.21"
diff --git a/deploy/nix/deployment.nix b/deploy/nix/deployment.nix
index 26166f8..cef0834 100644
--- a/deploy/nix/deployment.nix
+++ b/deploy/nix/deployment.nix
@@ -80,6 +80,19 @@
# which names it "/${name_prefix}-${environment}/guest".
guestLogGroup = "/CHANGE-ME-production/guest";
+ # Guest telemetry: what a guest reports through wit/telemetry. `xray`
+ # exports each guest invocation as one X-Ray trace, attributed to its
+ # tenant; `metricsNamespace` publishes guest metrics to CloudWatch under that
+ # namespace (not under `AWS/`), or "" for none. Each needs the instance
+ # role's grant — `deploy/tofu` gives it when `telemetry` is set there.
+ #
+ # Measured by PCR0 like everything here, so a client can tell from an
+ # attestation whether an enclave ships its traces off the box. What a trace
+ # carries is the guest's own text plus the tenant id; see
+ # runtime/src/telemetry.
+ xray = false;
+ metricsNamespace = "";
+
# The guest is not here either. The runtime fetches it from
# `guest/guest.wasm` in `rootsBucket` (`bucketPrefix` is not applied) and
# measures it into PCR16 before it asks KMS for a key, so changing the guest
diff --git a/deploy/tofu/main.tf b/deploy/tofu/main.tf
index 08a289c..e85ef4c 100644
--- a/deploy/tofu/main.tf
+++ b/deploy/tofu/main.tf
@@ -278,6 +278,50 @@ resource "aws_iam_role_policy" "push" {
policy = data.aws_iam_policy_document.push[0].json
}
+# Guest telemetry, as the image names it (`xray` and `metricsNamespace` in
+# deploy/nix/deployment.nix), and only what the runtime calls. X-Ray takes no
+# resource-level scoping, so trace segments are account-wide; metrics are held to the
+# image's one namespace by condition. Nothing when telemetry is off.
+locals {
+ telemetry_on = var.telemetry.xray || var.telemetry.metrics_namespace != ""
+}
+
+data "aws_iam_policy_document" "telemetry" {
+ count = local.telemetry_on ? 1 : 0
+
+ dynamic "statement" {
+ for_each = var.telemetry.xray ? [1] : []
+ content {
+ sid = "PutGuestTraces"
+ effect = "Allow"
+ actions = ["xray:PutTraceSegments"]
+ resources = ["*"]
+ }
+ }
+
+ dynamic "statement" {
+ for_each = var.telemetry.metrics_namespace != "" ? [1] : []
+ content {
+ sid = "PutGuestMetrics"
+ effect = "Allow"
+ actions = ["cloudwatch:PutMetricData"]
+ resources = ["*"]
+ condition {
+ test = "StringEquals"
+ variable = "cloudwatch:namespace"
+ values = [var.telemetry.metrics_namespace]
+ }
+ }
+ }
+}
+
+resource "aws_iam_role_policy" "telemetry" {
+ count = local.telemetry_on ? 1 : 0
+ name = "${local.name}-telemetry"
+ role = aws_iam_role.parent.id
+ policy = data.aws_iam_policy_document.telemetry[0].json
+}
+
# For a shell without opening port 22.
resource "aws_iam_role_policy_attachment" "ssm" {
role = aws_iam_role.parent.name
diff --git a/deploy/tofu/variables.tf b/deploy/tofu/variables.tf
index 537ca64..203a66d 100644
--- a/deploy/tofu/variables.tf
+++ b/deploy/tofu/variables.tf
@@ -91,6 +91,15 @@ variable "push_app_id" {
default = ""
}
+variable "telemetry" {
+ description = "Guest telemetry, as the image names it (xray and metricsNamespace in deployment.nix). With xray the parent's role may put X-Ray trace segments; with a metrics_namespace it may publish CloudWatch metrics to that namespace and no other."
+ type = object({
+ xray = optional(bool, false)
+ metrics_namespace = optional(string, "")
+ })
+ default = {}
+}
+
variable "pool_size_gib" {
type = number
default = 32
diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md
new file mode 100644
index 0000000..fd01a55
--- /dev/null
+++ b/docs/TELEMETRY.md
@@ -0,0 +1,142 @@
+# Telemetry
+
+A guest can report what it is doing beyond its stdout: **spans and events to AWS
+X-Ray**, and **metrics to CloudWatch**. The ABI is
+[`wit/telemetry/telemetry.wit`](../wit/telemetry/telemetry.wit); the runtime's
+side is [`runtime/src/telemetry`](../runtime/src/telemetry).
+
+As with every interface the runtime adds, nothing in it takes a tenant id. The
+runtime already knows which tenant an invocation belongs to, and attributes what
+the guest reports to it.
+
+## From a guest
+
+Include the world, and call it:
+
+```wit
+world app {
+ include enclave:telemetry/observed@0.1.0;
+}
+```
+
+```rust
+use bindings::enclave::telemetry::{metrics, tracing::{self, Field, Level, Span}};
+
+let span = Span::new(Level::Info, "sign", &[]); // a subsegment of the invocation
+span.set_field("session", &session_id);
+tracing::event(Level::Warn, "retrying", &[]); // lands on the innermost open span
+drop(span); // or span.end()
+
+metrics::counter_add("signatures", 1.0, &[]);
+metrics::histogram_record("sign-latency", ms, metrics::Unit::Milliseconds, &[]);
+```
+
+Every call returns nothing and never fails. When X-Ray or metrics are switched
+off, the calls succeed and do less, so a guest never branches on whether
+telemetry is on. Each event and closed span is also mirrored to the runtime's
+console under the `guest` target, so an emulator with neither still shows what a
+guest reported.
+
+## What happens to it
+
+**One trace per invocation.** An inbound request, a task run and a stream
+message are each one X-Ray trace. Its root segment is named `enclave-guest` and
+carries two *annotations*, the `tenant` (hex) and the `invocation` kind, which
+X-Ray indexes, so "every trace for this tenant" is a filter expression. Each span
+a guest opens is a subsegment of whichever span was open when it was created;
+its level is an annotation. Events and fields ride in the span's `metadata`.
+
+The trace is shipped when the invocation's store is dropped. That is the end of
+the invocation however it ends, a timeout or a trap included. Spans the guest
+never closed are closed then.
+
+**Metrics are aggregated, not queued.** Over each 30-second window a counter is
+summed, a gauge keeps its last value, and a histogram keeps each distinct value
+with its count. That is CloudWatch's own `values`/`counts` distribution, so
+percentiles survive aggregation. Each window becomes one `PutMetricData` call
+per 1000 datums, under the deployment's namespace. The runtime adds no tenant
+dimension: CloudWatch bills per unique combination, and that is the runtime's
+cost to decide, not a guest's.
+
+## What leaves the enclave
+
+What a guest puts in a span name, a message, a field or a metric is its own
+business, exactly as its stdout already is. The runtime adds only what it knows:
+the tenant id, the invocation kind, the image's PCR0, the region and its own
+version. All of it goes to the operator's own AWS account, the one guest logs
+already go to.
+
+Whether an enclave exports traces or metrics at all is part of its image (below),
+so it is measured into PCR0. A client can tell from an attestation whether its
+guest's traces leave the box.
+
+## Limits
+
+A guest that reports too much loses detail. The guest's calls never fail.
+
+| | Limit | Past it |
+|---|---|---|
+| Spans per invocation | 256 | counted (`dropped_spans`), not recorded |
+| Events per span | 64 | counted (`dropped_events`) |
+| Fields per span or event | 32 | the rest ignored |
+| Field key / message or value | 64 / 1024 characters | clipped |
+| Span name | 200 characters of what X-Ray accepts | clipped; other characters become `_` |
+| Trace document | 64 KiB (X-Ray's limit) | events and fields shed, then subsegments; marked `trimmed` |
+| Metric name | 1-255 printable ASCII | dropped and counted |
+| Dimensions | ≤10, each name and value 1-64 of `[A-Za-z0-9_.-]`, no name twice | dropped and counted |
+| Metric value | finite, and 0 or within CloudWatch's range | dropped and counted |
+| Series per window | 1000 | new series dropped; existing ones still count |
+| Distinct histogram values | 150 per datum | sealed into another datum, not lost |
+
+The metric checks run when a value is recorded, not when it is sent. CloudWatch
+refuses a whole request over one bad datum, so letting one through would lose
+every other metric in the same call.
+
+## Delivery
+
+Best effort, like guest logs, and for the same reason: nothing a guest calls
+waits on the network. A finished trace goes to a bounded queue (1024) by
+`try_send`. A forwarder ships batches of up to 50 documents or 1 MiB, after a
+one-second linger. A failure that may heal (throttling, a 5xx, an expired session
+token) is retried twice, after 0.5 s and 2 s. One that cannot (access denied, a
+malformed request) is dropped. Drops are counted and reported at most once a
+minute. At shutdown the queue and the last metrics window are flushed within five
+seconds.
+
+## Turning it on
+
+In the image, [`deploy/nix/deployment.nix`](../deploy/nix/deployment.nix):
+
+```nix
+xray = true; # each invocation as an X-Ray trace
+metricsNamespace = "Merlin/Guest"; # or "" for no metrics; never under AWS/
+```
+
+The parent's role needs the grants. In [`deploy/tofu`](../deploy/tofu):
+
+```hcl
+telemetry = { xray = true, metrics_namespace = "Merlin/Guest" }
+```
+
+That allows `xray:PutTraceSegments`, which X-Ray cannot scope to a resource, and
+`cloudwatch:PutMetricData` for that namespace only. Without the grants the
+runtime still serves, and logs at most once a minute that X-Ray or CloudWatch
+refused it.
+
+The flags behind these are `ENCLAVE_XRAY` and `ENCLAVE_METRICS_NAMESPACE`. A
+malformed namespace stops the boot. A client that cannot be built at startup
+does not: that half of telemetry stays on the console. For an emulator,
+`ENCLAVE_XRAY_ENDPOINT` and `ENCLAVE_METRICS_ENDPOINT` point the clients at a
+stand-in such as LocalStack.
+
+## Testing
+
+`runtime/tests/telemetry.rs` serves the example guest's `/telemetry` route
+through the real linker, with X-Ray and CloudWatch replaced by in-memory
+destinations. It checks the trace (attribution, nesting, fields, events, timings)
+and the metrics (aggregation, units, no tenant dimension):
+
+```console
+$ scripts/build-guest.sh http
+$ cargo test -p enclave-runtime --features testing --test telemetry -- --ignored
+```
diff --git a/examples/guest-http/src/main.rs b/examples/guest-http/src/main.rs
index 798b373..3aa031a 100644
--- a/examples/guest-http/src/main.rs
+++ b/examples/guest-http/src/main.rs
@@ -199,6 +199,10 @@ async fn main(mut req: Request
) -> Result, Error> {
})
}
("GET", "/env") => Ok(text(StatusCode::OK, environment())),
+ // Known spans, events and metrics, for the runtime's telemetry tests: a span with a
+ // field and an event, a child opened inside it, a free-standing event that must land on
+ // the innermost open span, and one of each kind of metric.
+ ("GET", "/telemetry") => Ok(text(StatusCode::OK, telemetry())),
// Whatever the runtime says about the caller. A guest can only ever
// read this header, never write it, and the runtime overwrites it on
// every request — so what arrives here is the runtime's word, not the
@@ -405,6 +409,34 @@ fn counter() -> Result {
Ok(next)
}
+fn telemetry() -> String {
+ use bindings::enclave::telemetry::metrics::{self, Dimension, Unit};
+ use bindings::enclave::telemetry::tracing::{self, Field, Level, Span};
+
+ let field = |key: &str, value: &str| Field {
+ key: key.to_string(),
+ value: value.to_string(),
+ };
+ let outer = Span::new(Level::Info, "handle", &[field("route", "/telemetry")]);
+ outer.set_field("phase", "start");
+ outer.add_event(Level::Info, "began", &[]);
+ {
+ // Ended by being dropped, not by `end`.
+ let _inner = Span::new(Level::Debug, "inner", &[]);
+ tracing::event(Level::Warn, "inside inner", &[field("n", "1")]);
+ }
+ outer.end();
+
+ let route = [Dimension {
+ name: "route".to_string(),
+ value: "telemetry".to_string(),
+ }];
+ metrics::counter_add("requests", 1.0, &route);
+ metrics::gauge_set("queue-depth", 3.0, Unit::Count, &[]);
+ metrics::histogram_record("latency", 12.5, Unit::Milliseconds, &route);
+ "reported\n".to_string()
+}
+
fn environment() -> String {
let mut vars: Vec<_> = std::env::vars().collect();
vars.sort();
diff --git a/examples/guest-http/wit/app.wit b/examples/guest-http/wit/app.wit
index 8c2ed24..3d49b9e 100644
--- a/examples/guest-http/wit/app.wit
+++ b/examples/guest-http/wit/app.wit
@@ -10,4 +10,5 @@ package guest:http@0.1.0;
world app {
include enclave:tasks/background@0.1.0;
import enclave:notify/notify@0.1.0;
+ include enclave:telemetry/observed@0.1.0;
}
diff --git a/examples/guest-http/wit/deps/telemetry/telemetry.wit b/examples/guest-http/wit/deps/telemetry/telemetry.wit
new file mode 100644
index 0000000..145d3d4
--- /dev/null
+++ b/examples/guest-http/wit/deps/telemetry/telemetry.wit
@@ -0,0 +1,109 @@
+package enclave:telemetry@0.1.0;
+
+/// Structured tracing, exported to AWS X-Ray.
+///
+/// The runtime roots one trace per guest invocation — an inbound request, a
+/// task run, or a stream message — and ships it when that invocation ends. A
+/// guest's events and spans attach to that trace; the runtime binds the tenant,
+/// so **no function here accepts a tenant id** (the same rule as enclave:notify
+/// and enclave:tasks). Calls made in a build with X-Ray switched off are
+/// accepted and discarded, so a guest never has to branch on whether it is on.
+///
+/// Spans and events carry no secret by construction on the runtime's side: the
+/// text and fields are the guest's own, and the runtime adds only what it
+/// already knows (the tenant, the image, the region). What a guest puts in a
+/// message or field is the guest's responsibility, exactly as with its stdout.
+interface tracing {
+ /// Severity. Mapped onto the runtime's own `tracing` levels for the console,
+ /// and recorded on the X-Ray (sub)segment.
+ enum level {
+ trace,
+ debug,
+ info,
+ warn,
+ error,
+ }
+
+ /// A key/value annotation on an event or span. The value is a string the
+ /// guest has already formatted; the runtime does not parse it. Keys are
+ /// 1-64 characters of [A-Za-z0-9_.-].
+ record field {
+ key: string,
+ value: string,
+ }
+
+ /// Record a point-in-time event on the span that is currently open (or on
+ /// the invocation's root span if the guest has opened none). Cheap: there is
+ /// no handle to keep.
+ event: func(level: level, message: string, fields: list);
+
+ /// A unit of work with a duration, exported as an X-Ray subsegment of
+ /// whichever span is open when it is created (the invocation's root span if
+ /// none) — so spans nest by the order a guest opens them. The subsegment's
+ /// end time is set when the span is closed: by `end`, or by dropping the
+ /// resource, whichever comes first.
+ resource span {
+ /// Open a span beneath the currently-open span.
+ constructor(level: level, name: string, fields: list);
+
+ /// Annotate the span after it has opened.
+ set-field: func(key: string, value: string);
+
+ /// Record a point-in-time event on this span.
+ add-event: func(level: level, message: string, fields: list);
+
+ /// Close the span now. Dropping the resource without calling this closes
+ /// it at the drop instead; calling it more than once does nothing.
+ end: func();
+ }
+}
+
+/// Metrics, published to AWS CloudWatch through the dedicated `PutMetricData`
+/// API under the runtime's configured namespace.
+///
+/// As with tracing, the runtime binds the tenant and a guest cannot name it:
+/// CloudWatch bills per unique dimension combination, so a tenant dimension is
+/// the runtime's decision to make, not a guest's. Dimensions a guest does give
+/// must be low cardinality. Calls made in a build with metrics switched off are
+/// accepted and discarded.
+///
+/// A metric name is 1-255 printable ASCII characters. A call CloudWatch would
+/// refuse — a bad name or dimension, a value that is not finite or is outside
+/// CloudWatch's range — is dropped and counted rather than sent, because
+/// CloudWatch refuses a whole request over one bad datum.
+interface metrics {
+ /// The unit a value is in, as CloudWatch names them. `none` leaves it unset.
+ enum unit {
+ none,
+ count,
+ seconds,
+ milliseconds,
+ bytes,
+ kilobytes,
+ percent,
+ }
+
+ /// A metric dimension: low cardinality only. 1-64 characters of
+ /// [A-Za-z0-9_.-] each, and at most 10 per call.
+ record dimension {
+ name: string,
+ value: string,
+ }
+
+ /// Add `value` to a counter (published as a Sum aggregation).
+ counter-add: func(name: string, value: f64, dimensions: list);
+
+ /// Report a gauge's current value (published as-is).
+ gauge-set: func(name: string, value: f64, %unit: unit, dimensions: list);
+
+ /// Record one observation into a distribution (published through
+ /// CloudWatch's values/counts distribution).
+ histogram-record: func(name: string, value: f64, %unit: unit, dimensions: list);
+}
+
+/// What a guest includes to get both. Both are imports: the guest calls the
+/// runtime, and there is nothing here for it to export.
+world observed {
+ import tracing;
+ import metrics;
+}
diff --git a/flake.nix b/flake.nix
index 4e0371f..85ce57f 100644
--- a/flake.nix
+++ b/flake.nix
@@ -363,6 +363,15 @@
# them.
ENCLAVE_GUEST_LOG_GROUP = deployment.guestLogGroup;
+ # Guest telemetry (wit/telemetry). With `xray`, each guest invocation
+ # goes to X-Ray as one trace, attributed to its tenant; with a
+ # `metricsNamespace`, guest metrics go to CloudWatch under it through
+ # PutMetricData. Called from inside, like the log group, so each needs
+ # its IAM grant (deploy/tofu `telemetry`). Defaulted, so a
+ # deployment.nix written before telemetry still builds, with it off.
+ ENCLAVE_XRAY = lib.boolToString (deployment.xray or false);
+ ENCLAVE_METRICS_NAMESPACE = deployment.metricsNamespace or "";
+
# Every request that could reach the guest needs a fresh WebAuthn
# assertion bound to exactly that request. Without an RP id the
# runtime serves the guest to anyone who can open a connection and
diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml
index fc8d1c4..1c68c79 100644
--- a/runtime/Cargo.toml
+++ b/runtime/Cargo.toml
@@ -176,6 +176,9 @@ aws-sdk-ssm = { version = "1.56.0", default-features = false, features = ["rt-to
# own still calls AWS over gvproxy. It brings no new crypto stack; kms and ssm
# already pull `rustls`, which is what the ban above is actually about.
aws-sdk-cloudwatchlogs = { version = "1.131.0", default-features = false, features = ["rt-tokio", "rustls", "behavior-version-latest"] }
+# Telemetry: guest spans to X-Ray and guest metrics to CloudWatch (see src/telemetry).
+aws-sdk-xray = { version = "1.109.0", default-features = false, features = ["rt-tokio", "rustls", "behavior-version-latest"] }
+aws-sdk-cloudwatch = { version = "1.127.0", default-features = false, features = ["rt-tokio", "rustls", "behavior-version-latest"] }
# The default credential chain, which resolves through gvproxy to the parent
# instance's IMDS. Named explicitly rather than relied on as a fallback, so it
# is obvious *which* identity guest logs are written with.
diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs
index 58abc60..21e24ad 100644
--- a/runtime/src/lib.rs
+++ b/runtime/src/lib.rs
@@ -40,6 +40,7 @@ pub mod state;
pub mod store;
pub mod stream;
pub mod tasks;
+pub mod telemetry;
pub mod tenant;
pub mod zfs;
diff --git a/runtime/src/linker.rs b/runtime/src/linker.rs
index 562b272..957cf82 100644
--- a/runtime/src/linker.rs
+++ b/runtime/src/linker.rs
@@ -19,6 +19,7 @@ pub fn build_linker(engine: &wasmtime::Engine) -> Result> {
crate::tasks::add_to_linker(&mut linker)?;
crate::stream::add_to_linker(&mut linker)?;
crate::notify::add_to_linker(&mut linker)?;
+ crate::telemetry::add_to_linker(&mut linker)?;
Ok(linker)
}
diff --git a/runtime/src/main.rs b/runtime/src/main.rs
index 83f85ee..d1f2bac 100644
--- a/runtime/src/main.rs
+++ b/runtime/src/main.rs
@@ -69,6 +69,87 @@ fn nonempty(value: &Option) -> Option {
.map(str::to_string)
}
+/// Start shipping guest spans to X-Ray and guest metrics to CloudWatch, as the flags ask.
+///
+/// A malformed namespace fails the boot: it is a deployment mistake that would otherwise surface
+/// only as every metric refused. A client that cannot be built in time does not: that guest
+/// telemetry stays on the console, and the enclave serves.
+async fn start_telemetry(
+ cli: &Cli,
+ image: Option,
+) -> Result {
+ use enclave_runtime::telemetry::{
+ CloudWatchMetrics, Metrics, MetricsConfig, Telemetry, Tracer, XrayConfig, XrayDestination,
+ };
+
+ let tracer = if cli.xray {
+ let config = XrayConfig {
+ region: cli.region.clone(),
+ endpoint: cli.testing.xray_endpoint.clone(),
+ };
+ match tokio::time::timeout(
+ enclave_runtime::STARTUP_PROBE_TIMEOUT,
+ XrayDestination::connect(&config),
+ )
+ .await
+ {
+ Ok(destination) => {
+ tracing::info!("guest spans are going to X-Ray");
+ Some(Tracer::start(
+ std::sync::Arc::new(destination),
+ image,
+ cli.region.clone(),
+ ))
+ }
+ Err(_) => {
+ tracing::error!(
+ timeout = ?enclave_runtime::STARTUP_PROBE_TIMEOUT,
+ "could not build an X-Ray client in time; guest spans stay on the console"
+ );
+ None
+ }
+ }
+ } else {
+ None
+ };
+
+ let metrics = match nonempty(&cli.metrics_namespace) {
+ None => None,
+ Some(namespace) => {
+ anyhow::ensure!(
+ MetricsConfig::valid_namespace(&namespace),
+ "--metrics-namespace must be 1-255 printable ASCII characters, and not under AWS/"
+ );
+ let config = MetricsConfig {
+ namespace: namespace.clone(),
+ region: cli.region.clone(),
+ endpoint: cli.testing.metrics_endpoint.clone(),
+ };
+ match tokio::time::timeout(
+ enclave_runtime::STARTUP_PROBE_TIMEOUT,
+ CloudWatchMetrics::connect(&config),
+ )
+ .await
+ {
+ Ok(destination) => {
+ tracing::info!(%namespace, "guest metrics are going to CloudWatch");
+ Some(Metrics::start(std::sync::Arc::new(destination), namespace))
+ }
+ Err(_) => {
+ tracing::error!(
+ timeout = ?enclave_runtime::STARTUP_PROBE_TIMEOUT,
+ %namespace,
+ "could not build a CloudWatch client in time; guest metrics are dropped"
+ );
+ None
+ }
+ }
+ }
+ };
+
+ Ok(Telemetry::new(tracer, metrics))
+}
+
#[derive(Parser, Debug)]
#[command(
version,
@@ -189,6 +270,16 @@ struct Cli {
#[arg(long, env = "ENCLAVE_GUEST_LOG_GROUP")]
guest_log_group: Option,
+ /// Export each guest invocation as an AWS X-Ray trace: its spans, its events, and the tenant
+ /// it ran for. The instance role needs `xray:PutTraceSegments`.
+ #[arg(long, env = "ENCLAVE_XRAY", value_parser = parse_bool_flag, num_args = 0..=1, default_value_t = false, default_missing_value = "true")]
+ xray: bool,
+
+ /// CloudWatch namespace guest metrics are published under with `PutMetricData`. Empty or
+ /// unset means none. The instance role needs `cloudwatch:PutMetricData`.
+ #[arg(long, env = "ENCLAVE_METRICS_NAMESPACE")]
+ metrics_namespace: Option,
+
/// Report on the clock and entropy source and exit, without mounting or
/// running anything. For diagnosing a deployment.
#[arg(long, alias = "clock-check")]
@@ -288,6 +379,14 @@ struct Testing {
#[arg(long, env = "ENCLAVE_GUEST_LOG_ENDPOINT")]
guest_log_endpoint: Option,
+ /// Point the X-Ray client somewhere else, such as a local stand-in for the service.
+ #[arg(long, env = "ENCLAVE_XRAY_ENDPOINT")]
+ xray_endpoint: Option,
+
+ /// Point the CloudWatch metrics client somewhere else.
+ #[arg(long, env = "ENCLAVE_METRICS_ENDPOINT")]
+ metrics_endpoint: Option,
+
/// Point the push client at a stub: an `http://` endpoint hands wake signals to
/// whatever is listening, and requests to it are signed with a placeholder
/// rather than the instance's role.
@@ -333,6 +432,8 @@ impl Testing {
acme_ca: None,
acme_contacts: Vec::new(),
guest_log_endpoint: None,
+ xray_endpoint: None,
+ metrics_endpoint: None,
push_endpoint: None,
cosign_attestations: false,
}
@@ -706,20 +807,20 @@ async fn run() -> Result {
vec![std::sync::Arc::new(enclave_runtime::TracingLogSink)];
let mut log_forwarder = None;
- if let Some(config) = cli.guest_log_config() {
- // Which image is writing to this stream. A fixed stream name is
- // shared by every boot and every image, so without this nothing
- // in it says which enclave produced which line.
- let image = attestation
- .as_ref()
- .and_then(|nsm| match nsm.describe_pcr(0) {
- Ok(pcr) => Some(hex::encode(pcr.value)),
- Err(e) => {
- tracing::warn!(error = %e, "could not read PCR0 for the guest log stream");
- None
- }
- });
+ // Which image is running, for the guest log stream and on every trace. A fixed stream name is
+ // shared by every boot and every image, so without this nothing in it says which enclave
+ // produced which line.
+ let image = attestation
+ .as_ref()
+ .and_then(|nsm| match nsm.describe_pcr(0) {
+ Ok(pcr) => Some(hex::encode(pcr.value)),
+ Err(e) => {
+ tracing::warn!(error = %e, "could not read PCR0 for guest logs and traces");
+ None
+ }
+ });
+ if let Some(config) = cli.guest_log_config() {
// Bounded, like everything else on this path. Building the
// client resolves a credential provider, and that reaches IMDS
// through gvproxy — a path this deployment has not verified. A
@@ -797,6 +898,11 @@ async fn run() -> Result {
}
}
+ // Guest spans and metrics. Bounded like the log client, for the same reason: each client
+ // resolves a credential provider through gvproxy, and telemetry that cannot be set up must
+ // leave the guest's calls working and the listener binding.
+ let telemetry = start_telemetry(&cli, image.clone()).await?;
+
// Decided before the listener binds, so a setting that cannot work is a boot failure rather
// than a surprise on the first wake. Whether the application can deliver is the probe's to
// find out once serving starts.
@@ -843,6 +949,7 @@ async fn run() -> Result {
guest,
ServeConfig {
notify,
+ telemetry,
// Whether tasks run is the guest's to say, by exporting `run-task`.
background_tasks: Some(Default::default()),
addr: HTTP_LISTEN,
diff --git a/runtime/src/serve/http.rs b/runtime/src/serve/http.rs
index a9c33b4..e18ee0d 100644
--- a/runtime/src/serve/http.rs
+++ b/runtime/src/serve/http.rs
@@ -47,6 +47,9 @@ pub struct ServeConfig {
/// here rather than by the caller, because both need the mounted
/// filesystem and the trusted clock, and this is where those exist.
pub notify: Option,
+ /// Where guest spans and metrics go. The default sends nowhere, and the guest's calls still
+ /// succeed — see [`crate::telemetry`].
+ pub telemetry: crate::telemetry::Telemetry,
/// Address to accept on.
pub addr: SocketAddr,
/// Where TLS connections take their serving identity. `None` serves
@@ -107,6 +110,7 @@ impl Default for ServeConfig {
ServeConfig {
background_tasks: None,
notify: None,
+ telemetry: Default::default(),
addr: ([0, 0, 0, 0], 8080).into(),
certificate: None,
acme: None,
@@ -177,6 +181,9 @@ pub struct ServeHandle {
/// Connections this runtime holds for guests — see [`crate::stream`].
streams: Option>,
notify: Option>,
+ /// Where each invocation's trace and metrics go. Every invocation gets a context, so a guest's
+ /// telemetry calls always resolve.
+ telemetry: crate::telemetry::Telemetry,
guest: Arc,
/// One request at a time for callers with no resolved tenant.
///
@@ -284,6 +291,7 @@ impl ServeHandle {
tasks: None,
streams: None,
notify: None,
+ telemetry: Default::default(),
pre,
guest: Arc::new(guest),
anonymous: Arc::new(tokio::sync::Mutex::new(())),
@@ -343,6 +351,13 @@ impl ServeHandle {
Ok(self)
}
+ /// Ship guest spans and metrics. No tenancy requirement: an anonymous request is attributed
+ /// to the anonymous tenant, as its filesystem is.
+ pub fn with_telemetry(mut self, telemetry: crate::telemetry::Telemetry) -> Self {
+ self.telemetry = telemetry;
+ self
+ }
+
/// An internal component export, never an HTTP route or a fabricated token.
/// One message from a held connection, as one guest invocation.
///
@@ -386,6 +401,10 @@ impl ServeHandle {
tenant,
interactive: false,
});
+ store.data_mut().telemetry = Some(
+ self.telemetry
+ .context(tenant, crate::telemetry::Invocation::Message),
+ );
let run = instance
.get_typed_func::<(String, String, Vec), (std::result::Result, String>,)>(
&mut store,
@@ -465,6 +484,10 @@ impl ServeHandle {
tenant: task.tenant,
interactive: false,
});
+ store.data_mut().telemetry = Some(
+ self.telemetry
+ .context(task.tenant, crate::telemetry::Invocation::Task),
+ );
let run = instance
.get_typed_func::<(String, Vec), (std::result::Result, String>,)>(
&mut store, "run-task",
@@ -649,6 +672,10 @@ impl ServeHandle {
tenant: tenant_id,
interactive: true,
});
+ instance.store.data_mut().telemetry = Some(
+ self.telemetry
+ .context(tenant_id, crate::telemetry::Invocation::Request),
+ );
let progress = instance.progress.clone();
let (sender, receiver) = tokio::sync::oneshot::channel();
// Wrapped before it becomes a guest resource, which is the last point
@@ -791,6 +818,10 @@ impl ServeHandle {
// unauthenticated guest `/runtime` and every tenant.
let (dir, _) = self.guest.zfs().tenant_dir(ANONYMOUS).await?;
let mut store = Store::new(self.pre.engine(), self.guest.new_state(Some(&dir))?);
+ store.data_mut().telemetry = Some(
+ self.telemetry
+ .context(ANONYMOUS, crate::telemetry::Invocation::Request),
+ );
let progress = Arc::new(StreamProgress::new());
store.set_epoch_deadline(self.watchdog());
self.arm_watchdog(&mut store, progress.clone());
@@ -1623,9 +1654,11 @@ pub async fn serve_component(
// wasmtime 44 enables async at the engine level when the `async` feature
// is on; `Config::async_support` is a no-op. Same as `run_component`.
let engine = ServeHandle::engine_with_watchdog()?;
+ let telemetry = std::mem::take(&mut config.telemetry);
let mut handle = ServeHandle::new(&engine, component_bytes, guest)?
.with_timeout(config.request_timeout)
- .with_max_interaction(config.max_interaction);
+ .with_max_interaction(config.max_interaction)
+ .with_telemetry(telemetry.clone());
if let Some(tenancy) = &config.tenancy {
handle = handle.with_tenancy(tenancy.clone());
}
@@ -1795,6 +1828,8 @@ pub async fn serve_component(
if let Some(forwarder) = notify_forwarder {
forwarder.shutdown().await;
}
+ // The same for the last invocations' traces and the metrics still aggregating.
+ telemetry.shutdown().await;
served
}
diff --git a/runtime/src/state.rs b/runtime/src/state.rs
index c397657..d33b48d 100644
--- a/runtime/src/state.rs
+++ b/runtime/src/state.rs
@@ -11,6 +11,9 @@ pub struct State {
/// The connections the runtime holds for this tenant — see [`crate::stream`].
pub(crate) streams: Option,
pub(crate) notify: Option,
+ /// This invocation's trace and where its metrics go — see [`crate::telemetry`]. Its trace is
+ /// shipped when this is dropped, which is when the store is.
+ pub(crate) telemetry: Option,
wasi: WasiCtx,
table: ResourceTable,
http: wasmtime_wasi_http::WasiHttpCtx,
@@ -25,12 +28,24 @@ impl State {
tasks: None,
streams: None,
notify: None,
+ telemetry: None,
wasi,
table: ResourceTable::new(),
http: wasmtime_wasi_http::WasiHttpCtx::new(),
egress: crate::serve::GuestEgress,
}
}
+
+ /// The telemetry context and the resource table at once. A guest's span is an entry in the
+ /// table that points into the context's trace, so every span call needs both halves.
+ pub(crate) fn telemetry_and_table(
+ &mut self,
+ ) -> (
+ Option<&mut crate::telemetry::TelemetryContext>,
+ &mut ResourceTable,
+ ) {
+ (self.telemetry.as_mut(), &mut self.table)
+ }
}
impl WasiView for State {
diff --git a/runtime/src/telemetry/metrics.rs b/runtime/src/telemetry/metrics.rs
new file mode 100644
index 0000000..d90301b
--- /dev/null
+++ b/runtime/src/telemetry/metrics.rs
@@ -0,0 +1,669 @@
+//! Guest metrics, aggregated in memory and published to CloudWatch with `PutMetricData`.
+//!
+//! Aggregated, not queued: a counter a guest bumps a thousand times between flushes is one datum,
+//! and CloudWatch bills per request and per datum. Counters sum, gauges keep their last value, and
+//! histograms keep each distinct value with its count — CloudWatch's own `values`/`counts`
+//! distribution, so percentiles survive aggregation.
+//!
+//! A metric is checked when it is recorded, not when it is sent. CloudWatch refuses a whole
+//! request over one bad datum — a value out of its range, a repeated dimension — so letting one
+//! through would take every other guest metric in the same call with it. A refused metric is
+//! dropped and counted, as the WIT promises.
+//!
+//! Series are capped per window, which bounds memory and bills alike: a guest that mints a new
+//! dimension value per request gets its first [`MAX_SERIES`] and a count of the rest.
+
+use std::collections::hash_map::Entry;
+use std::collections::HashMap;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::{Arc, Mutex};
+use std::time::{Duration, Instant, SystemTime};
+
+use async_trait::async_trait;
+use tokio::sync::Notify;
+
+use super::{classify, warn_due, Dimension, SendError, Unit, FLUSH_DEADLINE, RETRY_BACKOFF};
+
+/// How often aggregated metrics go to CloudWatch. Its standard resolution is a minute.
+const FLUSH_INTERVAL: Duration = Duration::from_secs(30);
+/// Distinct series (name, kind, unit and dimensions) per window.
+const MAX_SERIES: usize = 1000;
+/// Datums per `PutMetricData` call.
+const MAX_DATUMS_PER_CALL: usize = 1000;
+/// Distinct values in one histogram datum — CloudWatch's limit on `values`.
+const MAX_DISTINCT_VALUES: usize = 150;
+/// Dimensions per metric, as the WIT says.
+const MAX_DIMENSIONS: usize = 10;
+/// The magnitudes CloudWatch accepts; one value outside them fails the whole request.
+const MAX_MAGNITUDE: f64 = 8.515920e+109;
+const MIN_MAGNITUDE: f64 = 8.515920e-109;
+
+/// Where the CloudWatch client sends, and under which namespace.
+#[derive(Clone, Debug)]
+pub struct MetricsConfig {
+ pub namespace: String,
+ pub region: String,
+ /// A stand-in for the service, for an emulator. `None` is the real one.
+ pub endpoint: Option,
+}
+
+impl MetricsConfig {
+ /// A namespace CloudWatch accepts for custom metrics: 1-255 printable ASCII characters, and
+ /// not under `AWS/`, which is reserved for AWS's own.
+ pub fn valid_namespace(namespace: &str) -> bool {
+ (1..=255).contains(&namespace.len())
+ && namespace.bytes().all(|b| b.is_ascii_graphic() || b == b' ')
+ && !namespace.starts_with("AWS/")
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub(super) enum Kind {
+ Counter,
+ Gauge,
+ Histogram,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+struct Series {
+ name: String,
+ kind: Kind,
+ unit: Unit,
+ /// Sorted by name, so the same dimensions in any order are one series.
+ dimensions: Vec<(String, String)>,
+}
+
+enum Aggregate {
+ Sum(f64),
+ Last(f64),
+ Spread(Vec<(f64, f64)>),
+}
+
+/// One CloudWatch datum, before it becomes the SDK's type. Public as the destination's seam.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Datum {
+ pub name: String,
+ pub unit: Unit,
+ pub dimensions: Vec<(String, String)>,
+ pub value: DatumValue,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum DatumValue {
+ Single(f64),
+ Spread { values: Vec, counts: Vec },
+}
+
+fn datum(series: &Series, aggregate: Aggregate) -> Datum {
+ Datum {
+ name: series.name.clone(),
+ unit: series.unit,
+ dimensions: series.dimensions.clone(),
+ value: match aggregate {
+ Aggregate::Sum(v) | Aggregate::Last(v) => DatumValue::Single(v),
+ Aggregate::Spread(points) => {
+ let (values, counts) = points.into_iter().unzip();
+ DatumValue::Spread { values, counts }
+ }
+ },
+ }
+}
+
+#[derive(Default)]
+struct Window {
+ series: HashMap,
+ /// Histogram datums sealed early because they hit [`MAX_DISTINCT_VALUES`].
+ sealed: Vec,
+}
+
+impl Window {
+ /// Add one observation. `false` when the series is new and the window already holds
+ /// [`MAX_SERIES`].
+ fn add(&mut self, series: Series, value: f64) -> bool {
+ if self.series.len() >= MAX_SERIES && !self.series.contains_key(&series) {
+ return false;
+ }
+ match self.series.entry(series) {
+ Entry::Vacant(entry) => {
+ let aggregate = match entry.key().kind {
+ Kind::Counter => Aggregate::Sum(value),
+ Kind::Gauge => Aggregate::Last(value),
+ Kind::Histogram => Aggregate::Spread(vec![(value, 1.0)]),
+ };
+ entry.insert(aggregate);
+ }
+ Entry::Occupied(mut entry) => {
+ let full = match entry.get_mut() {
+ Aggregate::Sum(sum) => {
+ *sum += value;
+ None
+ }
+ Aggregate::Last(last) => {
+ *last = value;
+ None
+ }
+ Aggregate::Spread(points) => {
+ if let Some(point) = points.iter_mut().find(|p| p.0 == value) {
+ point.1 += 1.0;
+ None
+ } else if points.len() < MAX_DISTINCT_VALUES {
+ points.push((value, 1.0));
+ None
+ } else {
+ Some(std::mem::replace(points, vec![(value, 1.0)]))
+ }
+ }
+ };
+ if let Some(points) = full {
+ self.sealed
+ .push(datum(entry.key(), Aggregate::Spread(points)));
+ }
+ }
+ }
+ true
+ }
+
+ fn take(&mut self) -> Vec {
+ let mut data = std::mem::take(&mut self.sealed);
+ data.extend(
+ self.series
+ .drain()
+ .map(|(series, aggregate)| datum(&series, aggregate)),
+ );
+ data
+ }
+}
+
+/// Where flushed metrics go. A trait so aggregation and retries can be tested without HTTP.
+#[async_trait]
+pub trait MetricDestination: Send + Sync {
+ async fn put(
+ &self,
+ namespace: &str,
+ data: Vec,
+ timestamp: SystemTime,
+ ) -> Result<(), SendError>;
+}
+
+struct Shared {
+ window: Mutex,
+ /// Metrics refused when recorded, past the series cap, or lost in delivery.
+ dropped: AtomicU64,
+}
+
+impl Shared {
+ fn window(&self) -> std::sync::MutexGuard<'_, Window> {
+ self.window
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ }
+}
+
+/// Aggregates guest metrics and publishes them, on a task of its own.
+pub struct Metrics {
+ shared: Arc,
+ stop: Arc,
+ task: Mutex