diff --git a/.azure-pipelines/templates/Rust.Build.Job.yml b/.azure-pipelines/templates/Rust.Build.Job.yml index ed763f17..663245a0 100644 --- a/.azure-pipelines/templates/Rust.Build.Job.yml +++ b/.azure-pipelines/templates/Rust.Build.Job.yml @@ -83,6 +83,59 @@ jobs: condition: and(eq('${{ item.os }}','linux'), eq('${{ item.arch }}','arm64')) - ${{ if eq(parameters.isOfficialBuild, true) }}: + + # --- Telemetry GUID substitution (official builds only, mirrors WinAppSDK) --- + # For internal builds, the real Microsoft telemetry group GUID is + # compiled in by overwriting WIL's public no-op traceloggingconfig.h + # with MicrosoftTelemetry.h from the private Microsoft.Telemetry.Inbox.Native + # NuGet package. Community/OSS builds skip these steps — build.rs + # compiles against the public stub and events fire as plain ETW. + + # 1. Authenticate to the private NuGet feed hosting the telemetry package. + - task: NuGetAuthenticate@1 + displayName: 'NuGet authenticate for telemetry config' + inputs: + nuGetServiceConnections: 'TelemetryInternal' + condition: and(succeeded(), eq('${{ item.os }}', 'windows')) + + # 2. Restore Microsoft.Telemetry.Inbox.Native into build/telemetry/packages/. + - task: NuGetCommand@2 + displayName: 'Restore Microsoft.Telemetry.Inbox.Native' + inputs: + command: 'custom' + arguments: >- + restore $(Build.SourcesDirectory)/build/telemetry/packages.config + -ConfigFile $(Build.SourcesDirectory)/build/telemetry/nuget.config + -PackagesDirectory $(Build.SourcesDirectory)/build/telemetry/packages + condition: and(succeeded(), eq('${{ item.os }}', 'windows')) + + # 3. Find MicrosoftTelemetry.h and set the env var so build.rs picks it up. + - powershell: | + $srcPath = Get-ChildItem -Path '$(Build.SourcesDirectory)/build/telemetry/packages' ` + -File 'MicrosoftTelemetry.h' -Recurse -ErrorAction SilentlyContinue + if ($srcPath) { + Write-Host "Found telemetry config override: $($srcPath.FullName)" + Write-Host "##vso[task.setvariable variable=MXC_TELEMETRY_CONFIG_OVERRIDE]$($srcPath.FullName)" + } else { + Write-Host "MicrosoftTelemetry.h not found under $(Build.SourcesDirectory)/build/telemetry/packages/" + Get-ChildItem -Path '$(Build.SourcesDirectory)/build/telemetry/packages' -Recurse -ErrorAction SilentlyContinue + } + displayName: 'Set MXC_TELEMETRY_CONFIG_OVERRIDE' + condition: and(succeeded(), eq('${{ item.os }}', 'windows')) + + # --- SDK license override (mirrors telemetry GUID substitution) --- + # For internal npm publishes, the public MIT-only LICENSE.md is replaced + # with a private EULA containing a Section 2 DATA clause (telemetry + # disclosure, GDPR). The private EULA is sourced from an internal + # artifact store and injected via the MXC_LICENSE_OVERRIDE env var. + # Community/OSS builds skip this step — the SDK ships MIT-only. + + # 4. Apply private EULA if MXC_LICENSE_OVERRIDE is set. + - powershell: | + & '$(Build.SourcesDirectory)/scripts/apply-license-override.ps1' + displayName: 'Apply SDK license override' + condition: and(succeeded(), ne(variables['MXC_LICENSE_OVERRIDE'], '')) + - template: Rust.Build.Steps.Official.yml@self parameters: targetTriple: $(triplet) diff --git a/.gitignore b/.gitignore index 57d0331a..ed6e4fef 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,16 @@ output pathrel_test.exe # Claude -.claude/ \ No newline at end of file +.claude/ + +# Private telemetry config (restored from internal NuGet feed at build time). +# The Microsoft.Telemetry.Inbox.Native package provides MicrosoftTelemetry.h +# which contains the real Microsoft telemetry group GUID — must never be +# committed. See docs/telemetry-wil-integration.md for details. +build/telemetry/packages/ + +# Private EULA override (injected at npm-publish time via MXC_LICENSE_OVERRIDE). +# The private EULA includes the Section 2 DATA clause for telemetry disclosure +# and must never be committed. See docs/telemetry-wil-integration.md for details. +sdk/LICENSE.md.public +build/eula/ \ No newline at end of file diff --git a/README.md b/README.md index d1665734..e6199fa6 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,24 @@ wxc-exec.exe --debug config.json See [docs/diagnostics.md](docs/diagnostics.md) for full diagnostics reference. +## Telemetry (Experimental) + +MXC supports optional TraceLogging ETW telemetry for execution observability. When enabled, structured events (`MXC.Execution` and `MXC.Error`) are emitted to the local ETW subsystem via a WIL (Windows Implementation Library) C++ shim — the same pattern used by WinAppSDK. Every event includes Part B common fields (Version, Channel, IsDebugging, `UTCReplace_AppSessionGuid`). + +Telemetry is **experimental** and requires: +1. The `--experimental` CLI flag +2. `"experimental": { "telemetry": { "enabled": true } }` in the JSON config + +On non-Windows platforms, all telemetry functions are no-ops. The WIL headers are automatically downloaded from NuGet at build time (Windows only). + +### Data collection + +This project may collect usage data and send it to Microsoft to help improve our products and services. Note, however, that **no data collection is performed when using your private builds** — the public source code ships with empty TraceLogging provider group macros that do not route events to any Microsoft collection pipeline. + +No PII is collected. Events contain only execution metrics (duration, backend type, exit code) and error context (phase, sanitized message). If you use the SDK to build applications, you are responsible for providing appropriate telemetry notices to your own users. + +Privacy information can be found at https://privacy.microsoft.com. + ## Documentation | Document | Description | @@ -235,6 +253,7 @@ See [docs/diagnostics.md](docs/diagnostics.md) for full diagnostics reference. | [docs/macos-support/seatbelt-backend.md](docs/macos-support/seatbelt-backend.md) | Seatbelt backend (macOS) | | [docs/windows-sandbox/windows-sandbox.md](docs/windows-sandbox/windows-sandbox.md) | Windows Sandbox backend | | [docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md](docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md) | State-aware sandbox lifecycle API | +| [docs/telemetry-wil-integration.md](docs/telemetry-wil-integration.md) | WIL TraceLogging telemetry architecture | ## Contributing @@ -242,4 +261,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines. ## License -See [LICENSE.md](LICENSE.md) for details. \ No newline at end of file +See [LICENSE.md](LICENSE.md) for details. diff --git a/build/telemetry/nuget.config b/build/telemetry/nuget.config new file mode 100644 index 00000000..8af01e1d --- /dev/null +++ b/build/telemetry/nuget.config @@ -0,0 +1,10 @@ + + + + + + + diff --git a/build/telemetry/packages.config b/build/telemetry/packages.config new file mode 100644 index 00000000..d1bde0c2 --- /dev/null +++ b/build/telemetry/packages.config @@ -0,0 +1,17 @@ + + + + + diff --git a/docs/schema.md b/docs/schema.md index 26f29a51..7fe74fb5 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -80,6 +80,9 @@ production configs and the dev schema when working on experimental features: "launchMethod": "exec", // "exec" or "open" (LaunchServices, for Apple-constrained apps) "nestedPty": true, // Allow inner process to allocate its own pty (posix_openpt) "keychainAccess": false // Allow Keychain via securityd / trustd / cfprefsd / lsd.* + }, + "telemetry": { // Telemetry (experimental, Windows only) + "enabled": true // Emit TraceLogging ETW events via WIL C++ shim } } } diff --git a/docs/telemetry-wil-integration.md b/docs/telemetry-wil-integration.md new file mode 100644 index 00000000..14da219b --- /dev/null +++ b/docs/telemetry-wil-integration.md @@ -0,0 +1,255 @@ +# MXC Telemetry — WIL Integration Architecture + +MXC uses the Windows Implementation Library (WIL) for TraceLogging ETW +telemetry, following the same pattern established by WinAppSDK. + +## Overview + +``` +┌──────────────────────────────────────────────────────┐ +│ wxc_common::telemetry │ +│ (Rust — pure logic: config, sanitisation, types) │ +│ │ +│ init() / log_execution() / log_error() / shutdown() │ +└───────────────┬──────────────────────────────────────┘ + │ FFI calls (CString → *const c_char) + ▼ +┌──────────────────────────────────────────────────────┐ +│ mxc_wil_telemetry (Rust crate) │ +│ src/lib.rs — safe wrappers + #[cfg] platform guards │ +│ │ +│ Windows: extern "C" → C++ shim │ +│ Linux/macOS: no-op stubs │ +└───────────────┬──────────────────────────────────────┘ + │ Static linking + ▼ +┌──────────────────────────────────────────────────────┐ +│ C++ shim (mxc_telemetry_shim.cpp) │ +│ Compiled by cc crate in build.rs │ +│ │ +│ MxcTelemetryProvider : wil::TraceLoggingProvider │ +│ ├── IMPLEMENT_TRACELOGGING_CLASS(...) │ +│ ├── TraceLoggingOptionMicrosoftTelemetry() │ +│ └── _MXC_GENERIC_PARTB_FIELDS on every event │ +│ ├── Version │ +│ ├── Channel ("dev" / "release") │ +│ ├── IsDebugging │ +│ └── UTCReplace_AppSessionGuid = true │ +└───────────────┬──────────────────────────────────────┘ + │ Links against WIL headers (header-only) + ▼ +┌──────────────────────────────────────────────────────┐ +│ WIL (Microsoft.Windows.ImplementationLibrary) │ +│ Downloaded from NuGet at build time │ +│ MIT licensed, header-only │ +│ Version: 1.0.260126.7 │ +└──────────────────────────────────────────────────────┘ +``` + +## WIL Acquisition + +The `build.rs` in `mxc_wil_telemetry` downloads the WIL NuGet package +(which is just a `.zip` file) and extracts the `include/wil/` headers. +The download is cached under `OUT_DIR/wil-cache/` so it only happens +once per clean build. + +**No NuGet CLI or .NET SDK required** — the `.nupkg` is fetched via +HTTP and unzipped directly. + +## Part B Common Fields + +Following WinAppSDK's `_GENERIC_PARTB_FIELDS_ENABLED` macro, every MXC +telemetry event includes a `COMMON_MXC_PARAMS` struct with: + +| Field | Type | Description | +|-------|------|-------------| +| `Version` | string | MXC crate version from `CARGO_PKG_VERSION` | +| `Channel` | string | `"dev"` for debug builds, `"release"` for release | +| `IsDebugging` | bool | `IsDebuggerPresent()` at event emission time | +| `UTCReplace_AppSessionGuid` | bool | Always `true` — tells UTC to replace the app session GUID with a per-session identifier for privacy | + +## Provider GUID + +The provider GUID in `mxc_telemetry_shim.cpp` is a **placeholder** for +the open-source build. Internal Microsoft builds replace this GUID at +packaging time with the production GUID registered in the telemetry +pipeline. + +This follows the WinAppSDK pattern described in the WinAppSDK Telemetry +spec (per guidance from Mythilli Srinivasan). + +The **provider group GUID** (set via `TraceLoggingOptionMicrosoftTelemetry()`) +identifies this as a Microsoft first-party TraceLogging telemetry provider. +This is a well-known GUID and is the same across all Microsoft products. + +## Events + +### MXC.Execution + +Emitted on every sandbox execution completion. + +| Field | Type | Description | +|-------|------|-------------| +| `mxc.backend` | string | Containment backend name | +| `mxc.exit_code` | int32 | Process exit code | +| `mxc.outcome` | string | `"success"` or `"failure"` | +| `mxc.duration_ms` | uint64 | Total execution time | +| `mxc.failure_reason` | string | Failure category (if applicable) | + +### MXC.Error + +Emitted on execution errors. + +| Field | Type | Description | +|-------|------|-------------| +| `mxc.backend` | string | Containment backend name | +| `mxc.error_type` | string | Error category (`config_error`, `process_error`, etc.) | +| `mxc.error_message` | string | Sanitized error message (PII-stripped, max 256 chars) | + +## Cross-Platform Behaviour + +| Platform | Behaviour | +|----------|-----------| +| Windows | Full ETW telemetry via WIL C++ shim | +| Linux | No-op — all telemetry functions return immediately | +| macOS | No-op — all telemetry functions return immediately | + +## Private GUID Substitution (Internal Builds) + +MXC follows the same private telemetry GUID substitution pattern as WinAppSDK. +The mechanism is public; only the GUID value is private. + +### Background + +The public WIL NuGet package ships `wil/traceloggingconfig.h` with +`TraceLoggingOptionMicrosoftTelemetry()` defined as an **empty macro** (no-op). +This means community/OSS builds emit plain ETW events with no Microsoft +pipeline routing — which is the correct behaviour for external users. + +Internal Microsoft builds need the **real Microsoft telemetry group GUID** +compiled in so events are tagged as Microsoft first-party TraceLogging telemetry. +This GUID lives in `MicrosoftTelemetry.h` inside the private +`Microsoft.Telemetry.Inbox.Native` NuGet package. + +### How it works + +``` +build.rs execution flow +======================== + +1. Download WIL NuGet from nuget.org +2. Extract headers to OUT_DIR/wil-cache/include/ + └── wil/traceloggingconfig.h ← PUBLIC (empty macro) + +3. Check MXC_TELEMETRY_CONFIG_OVERRIDE env var + ├── NOT set → keep public stub (community build) + └── SET → copy the file it points to over traceloggingconfig.h + └── wil/traceloggingconfig.h ← NOW has real GUID + +4. cc::Build compiles mxc_telemetry_shim.cpp + └── #include + └── #include ← whichever version is on disk +``` + +### CI pipeline steps + +The Azure Pipelines build (`.azure-pipelines/templates/Rust.Build.Job.yml`) +adds three steps before `cargo build` on Windows: + +1. **`NuGetAuthenticate@1`** — authenticates to the `TelemetryInternal` service + connection (an ADO service connection providing credentials to the private feed) +2. **`NuGetCommand@2`** — restores `Microsoft.Telemetry.Inbox.Native` from + `build/telemetry/packages.config` into `build/telemetry/packages/` +3. **PowerShell** — finds `MicrosoftTelemetry.h` in the restored packages and + sets `MXC_TELEMETRY_CONFIG_OVERRIDE` to its path + +These steps do **not** use `continueOnError` — they hard-fail if the private +feed is unavailable, matching the WinAppSDK pattern. Community forks that lack +access to the private feed should not add these pipeline steps; the public +WIL headers are used by default when the env var is unset. + +### Local developer testing + +If you have access to `Microsoft.Telemetry.Inbox.Native`, you can test the +override locally: + +```powershell +# Restore the package manually +nuget restore build\telemetry\packages.config -PackagesDirectory build\telemetry\packages + +# Point build.rs to the private header +$env:MXC_TELEMETRY_CONFIG_OVERRIDE = (Get-ChildItem -Path 'build\telemetry\packages' ` + -File 'MicrosoftTelemetry.h' -Recurse).FullName + +# Build — traceloggingconfig.h will be overwritten +cargo build -p mxc_wil_telemetry +``` + +Without the env var (or without the package), `build.rs` uses the public WIL +headers as-is. No code changes needed. + +### What's public vs. private + +| Item | Public? | Why | +|------|---------|-----| +| Provider GUID `(0x4f50731a...)` | ✅ | Identifies the provider, harmless | +| Provider name `"Microsoft.MXC"` | ✅ | Standard ETW naming | +| `TraceLoggingOptionMicrosoftTelemetry()` call | ✅ | Compiles to no-op without private header | +| `build.rs` override logic | ✅ | Mechanism is public (same as WinAppSDK) | +| `packages.config` (package name/version) | ✅ | WinAppSDK publishes theirs too | +| Pipeline YAML (NuGet restore + env var) | ✅ | WinAppSDK publishes theirs too | +| Env var name `MXC_TELEMETRY_CONFIG_OVERRIDE` | ✅ | Key is public; value is machine-local | +| `MicrosoftTelemetry.h` (group GUID content) | ❌ | Private NuGet feed only | +| `build/telemetry/packages/` (restored files) | ❌ | `.gitignore`d | +| `TelemetryInternal` service connection creds | ❌ | ADO project settings | + +## SDK License Override (EULA for npm Package) + +The public GitHub repo ships `sdk/LICENSE.md` as a plain MIT license. For +internal npm publishes, a separate EULA containing a **Section 2 — DATA** +clause (covering telemetry disclosure, opt-out, and GDPR) is injected at +pack/publish time. This mirrors the WinAppSDK pattern where the NuGet +binary package carries a proprietary EULA while the source remains MIT. + +### How it works + +``` +1. CI pipeline (or local script) sets MXC_LICENSE_OVERRIDE env var + pointing to the private EULA markdown file. + +2. scripts/apply-license-override.ps1 runs: + ├── MXC_LICENSE_OVERRIDE is set: + │ ├── Back up sdk/LICENSE.md → sdk/LICENSE.md.public + │ └── Copy private EULA over sdk/LICENSE.md + └── MXC_LICENSE_OVERRIDE is NOT set: + └── Restore sdk/LICENSE.md from .public backup (if exists) + +3. npm pack / npm publish picks up the private EULA as the LICENSE.md + in the published package (sdk/package.json "files" includes LICENSE.md). + +4. After publish, the revert path restores the MIT license. +``` + +### What the private EULA must contain + +The private EULA should include a DATA section modeled after WinAppSDK's +NuGet license (Microsoft Software License Terms), covering: + +- **Section 2a — Data Collection**: Disclosure that the software may collect + usage data; "Your use of the software operates as your consent to these + practices"; link to https://privacy.microsoft.com +- **Section 2b — Processing of Personal Data**: GDPR commitment referencing + the Online Services Terms +- **Developer responsibility**: Note that developers using the SDK must + comply with applicable law and provide appropriate notices to their users + +### What's public vs. private + +| Item | Public? | Why | +|------|---------|-----| +| `scripts/apply-license-override.ps1` | ✅ | Mechanism is public (same as WinAppSDK) | +| Env var name `MXC_LICENSE_OVERRIDE` | ✅ | Key is public; value is machine-local | +| `sdk/LICENSE.md` (MIT, in repo) | ✅ | Standard open-source license | +| Private EULA file (with DATA section) | ❌ | Internal artifact store only | +| `sdk/LICENSE.md.public` (backup) | ❌ | `.gitignore`d, transient build artifact | +| `build/eula/` (staging directory) | ❌ | `.gitignore`d | diff --git a/schemas/dev/mxc-config.schema.0.7.0-dev.json b/schemas/dev/mxc-config.schema.0.7.0-dev.json index f757c227..e8d0587a 100644 --- a/schemas/dev/mxc-config.schema.0.7.0-dev.json +++ b/schemas/dev/mxc-config.schema.0.7.0-dev.json @@ -489,6 +489,17 @@ "description": "State-aware: deprovision-phase configuration. No fields defined yet." } } + }, + "telemetry": { + "type": "object", + "description": "Telemetry configuration (experimental). When enabled, emits TraceLogging ETW events for execution observability.", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Explicit telemetry override. true = force on, false = force off. Default is off." + } + } } } } diff --git a/scripts/apply-license-override.ps1 b/scripts/apply-license-override.ps1 new file mode 100644 index 00000000..68819993 --- /dev/null +++ b/scripts/apply-license-override.ps1 @@ -0,0 +1,56 @@ +<# +.SYNOPSIS + Applies or reverts the SDK license override for internal npm publishes. + +.DESCRIPTION + Mirrors the telemetry GUID substitution pattern: when the MXC_LICENSE_OVERRIDE + env var is set and points to a valid file, the public MIT-only LICENSE.md in + sdk/ is backed up (.public) and replaced with the private EULA that includes + the Section 2 DATA clause for telemetry disclosure. + + When the env var is unset or empty, the original MIT license is restored from + the .public backup (if one exists). This prevents a stale private EULA from + persisting across incremental builds. + + Usage: + # Apply (CI pipeline sets the env var): + $env:MXC_LICENSE_OVERRIDE = "path\to\private-eula.md" + .\scripts\apply-license-override.ps1 + + # Revert (local dev, env var not set): + .\scripts\apply-license-override.ps1 + +.NOTES + The private EULA file must NEVER be committed to the public repo. + It is sourced from an internal artifact store at publish time. +#> + +$ErrorActionPreference = 'Stop' + +$sdkDir = Join-Path $PSScriptRoot '..' 'sdk' +$licensePath = Join-Path $sdkDir 'LICENSE.md' +$backupPath = "$licensePath.public" + +$overridePath = $env:MXC_LICENSE_OVERRIDE + +if ($overridePath -and (Test-Path $overridePath)) { + # Save the public MIT license if not already backed up. + if (-not (Test-Path $backupPath)) { + Copy-Item $licensePath $backupPath -Force + Write-Host "Backed up public LICENSE.md -> LICENSE.md.public" + } + + Copy-Item $overridePath $licensePath -Force + Write-Host "Applied private EULA from: $overridePath" +} +else { + # Restore public license if a backup exists (revert scenario). + if (Test-Path $backupPath) { + Copy-Item $backupPath $licensePath -Force + Remove-Item $backupPath -Force + Write-Host "Restored public LICENSE.md from backup" + } + else { + Write-Host "No override set and no backup found — LICENSE.md unchanged" + } +} diff --git a/sdk/src/types.ts b/sdk/src/types.ts index d66936ba..efa94cf6 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -240,6 +240,15 @@ export interface PortMapping { protocol?: string; } +/** Telemetry configuration for experimental TraceLogging ETW support. */ +export interface TelemetryConfig { + /** + * Explicit telemetry override. + * `true` = force on, `false` = force off, `undefined` = build-type default (off). + */ + enabled?: boolean; +} + /** * Main WXC configuration */ @@ -278,6 +287,8 @@ export interface ContainerConfig { wslc?: WslcConfig; /** macOS sandbox configuration (macOS only) */ seatbelt?: SeatbeltConfig; + /** Telemetry configuration */ + telemetry?: TelemetryConfig; }; /** Cross-platform UI configuration */ ui?: UiConfig; diff --git a/src/Cargo.lock b/src/Cargo.lock index e37cbc13..6f9092b3 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -893,7 +893,7 @@ dependencies = [ "serde_json", "socket2 0.5.10", "tar", - "ureq", + "ureq 3.3.0", "windows-sys 0.61.2", ] @@ -1407,6 +1407,15 @@ dependencies = [ "nix", ] +[[package]] +name = "mxc_wil_telemetry" +version = "0.6.1" +dependencies = [ + "cc", + "ureq 2.12.1", + "zip", +] + [[package]] name = "nanvix_binaries" version = "0.6.1" @@ -2231,6 +2240,22 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "ureq" version = "3.3.0" @@ -2245,7 +2270,7 @@ dependencies = [ "rustls-pki-types", "ureq-proto", "utf8-zero", - "webpki-roots", + "webpki-roots 1.0.7", ] [[package]] @@ -2451,6 +2476,15 @@ dependencies = [ "semver", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + [[package]] name = "webpki-roots" version = "1.0.7" @@ -2869,6 +2903,7 @@ dependencies = [ "base64", "getrandom 0.2.17", "libc", + "mxc_wil_telemetry", "nanvix_common", "semver", "serde", diff --git a/src/Cargo.toml b/src/Cargo.toml index 4b83d426..15c0086e 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -29,6 +29,7 @@ members = [ "testing/linux_test_proxy", "testing/wxc_ui_probe", "tools/mxc_diagnostic_console", + "mxc_wil_telemetry", ] exclude = ["testing/fuzz"] resolver = "3" @@ -104,6 +105,7 @@ isolation_session_bindings = { path = "backends/isolation_session/bindings" } mxc_pty = { path = "core/mxc_pty" } flatbuffers = "25" sandbox_spec = { path = "core/generated/base_container_specification" } +mxc_wil_telemetry = { path = "mxc_wil_telemetry" } widestring = "1" url = "2" winreg = "0.55" diff --git a/src/core/lxc/src/main.rs b/src/core/lxc/src/main.rs index ad720514..9b66ef03 100644 --- a/src/core/lxc/src/main.rs +++ b/src/core/lxc/src/main.rs @@ -8,8 +8,9 @@ use std::time::Instant; use clap::Parser; use wxc_common::config_parser::load_request; use wxc_common::logger::{Logger, Mode}; -use wxc_common::models::{ContainmentBackend, ExecutionRequest, ScriptResponse}; +use wxc_common::models::{ContainmentBackend, ExecutionRequest, FailurePhase, ScriptResponse}; use wxc_common::script_runner::{handle_dry_run_exit, ScriptRunner}; +use wxc_common::telemetry; #[cfg(target_os = "linux")] use bwrap_common::bwrap_runner::BubblewrapScriptRunner; @@ -206,6 +207,18 @@ fn main() { request.experimental_enabled = cli.experimental; request.dry_run = cli.dry_run; + // ── Telemetry init (experimental) ─────────────────────────────── + let telemetry_active = if request.experimental_enabled { + request + .experimental + .telemetry + .as_ref() + .map(telemetry::init) + .unwrap_or(false) + } else { + false + }; + log_request(&request, &mut logger); // Dispatch by containment backend. On Linux, Bubblewrap is now the @@ -289,6 +302,64 @@ fn main() { display_script_results(&response, &mut logger); + // ── Telemetry emit (experimental) ─────────────────────────────── + if telemetry_active { + let backend_str = match request.containment { + ContainmentBackend::ProcessContainer => "processcontainer", + ContainmentBackend::Lxc => "lxc", + ContainmentBackend::MicroVm => "microvm", + ContainmentBackend::Wslc => "wslc", + ContainmentBackend::WindowsSandbox => "windows_sandbox", + ContainmentBackend::IsolationSession => "isolation_session", + ContainmentBackend::Seatbelt => "seatbelt", + ContainmentBackend::Bubblewrap => "bubblewrap", + ContainmentBackend::Hyperlight => "hyperlight", + ContainmentBackend::Vm => "vm", + }; + let outcome = if response.exit_code == 0 { + "success" + } else { + "failure" + }; + let failure_reason = if response.exit_code != 0 { + Some(match response.failure_phase { + FailurePhase::LaunchFailed => telemetry::FailureReason::InitError, + FailurePhase::ProcessExited | FailurePhase::None => { + telemetry::FailureReason::ProcessError + } + }) + } else { + None + }; + + let elapsed_ms = run_elapsed.as_millis() as u64; + telemetry::log_execution(&telemetry::ExecutionEvent { + backend: backend_str, + exit_code: response.exit_code, + outcome, + duration_ms: elapsed_ms, + version: telemetry::version(), + failure_reason, + }); + + if response.exit_code != 0 && !response.error_message.is_empty() { + let error_reason = match response.failure_phase { + FailurePhase::LaunchFailed => telemetry::FailureReason::InitError, + FailurePhase::ProcessExited | FailurePhase::None => { + telemetry::FailureReason::ProcessError + } + }; + telemetry::log_error( + backend_str, + error_reason, + &response.error_message, + telemetry::version(), + ); + } + + telemetry::shutdown(); + } + print!("{}", response.standard_out); eprint!("{}", response.standard_err); process::exit(response.exit_code); diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index 8bd36e51..8b18b9b3 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -24,13 +24,14 @@ use wxc_common::config_parser::{ }; use wxc_common::diagnostic::DiagnosticConfig; use wxc_common::logger::{Logger, Mode}; -use wxc_common::models::{ContainmentBackend, ExecutionRequest, ScriptResponse}; +use wxc_common::models::{ContainmentBackend, ExecutionRequest, FailurePhase, ScriptResponse}; use wxc_common::mxc_error::{MxcError, ResponseEnvelope}; use wxc_common::script_runner::{handle_dry_run_exit, ScriptRunner}; #[cfg(all(target_os = "windows", feature = "isolation_session"))] use wxc_common::state_aware_dispatch::dispatch_state_aware; use wxc_common::state_aware_dispatch::{resolve_backend, run_state_aware, DispatchOutcome}; use wxc_common::state_aware_request::{MxcRequest, ParsedStateAwareRequest, Phase}; +use wxc_common::telemetry; #[derive(Parser)] #[command(name = "wxc-exec", about = "Windows Container Executor")] @@ -698,6 +699,18 @@ fn main() { request.experimental_enabled = cli.experimental; request.dry_run = cli.dry_run; + // ── Telemetry init (experimental) ─────────────────────────────── + let telemetry_active = if request.experimental_enabled { + request + .experimental + .telemetry + .as_ref() + .map(telemetry::init) + .unwrap_or(false) + } else { + false + }; + // Apply the CLI command-line override to one-shot requests. State-aware // exec is handled above before dispatch. let command_override = match command_override_from_cli( @@ -988,6 +1001,64 @@ fn main() { display_script_results(&response, &mut logger); + // ── Telemetry emit (experimental) ─────────────────────────────── + if telemetry_active { + let backend_str = match request.containment { + ContainmentBackend::ProcessContainer => "processcontainer", + ContainmentBackend::WindowsSandbox => "windows_sandbox", + ContainmentBackend::Lxc => "lxc", + ContainmentBackend::MicroVm => "microvm", + ContainmentBackend::Wslc => "wslc", + ContainmentBackend::IsolationSession => "isolation_session", + ContainmentBackend::Seatbelt => "seatbelt", + ContainmentBackend::Bubblewrap => "bubblewrap", + ContainmentBackend::Hyperlight => "hyperlight", + ContainmentBackend::Vm => "vm", + }; + let outcome = if response.exit_code == 0 { + "success" + } else { + "failure" + }; + let failure_reason = if response.exit_code != 0 { + Some(match response.failure_phase { + FailurePhase::LaunchFailed => telemetry::FailureReason::InitError, + FailurePhase::ProcessExited | FailurePhase::None => { + telemetry::FailureReason::ProcessError + } + }) + } else { + None + }; + + let elapsed_ms = run_elapsed.as_millis() as u64; + telemetry::log_execution(&telemetry::ExecutionEvent { + backend: backend_str, + exit_code: response.exit_code, + outcome, + duration_ms: elapsed_ms, + version: telemetry::version(), + failure_reason, + }); + + if response.exit_code != 0 && !response.error_message.is_empty() { + let error_reason = match response.failure_phase { + FailurePhase::LaunchFailed => telemetry::FailureReason::InitError, + FailurePhase::ProcessExited | FailurePhase::None => { + telemetry::FailureReason::ProcessError + } + }; + telemetry::log_error( + backend_str, + error_reason, + &response.error_message, + telemetry::version(), + ); + } + + telemetry::shutdown(); + } + // Close diagnostic pipe. logger.close_diagnostics(); diff --git a/src/core/wxc_common/Cargo.toml b/src/core/wxc_common/Cargo.toml index 2166dd4c..41a224d0 100644 --- a/src/core/wxc_common/Cargo.toml +++ b/src/core/wxc_common/Cargo.toml @@ -22,6 +22,7 @@ windows = { workspace = true } windows-core = { workspace = true } widestring = { workspace = true } winreg = { workspace = true } +mxc_wil_telemetry = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] libc = { workspace = true } diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index ab7cb91c..85103a41 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -13,7 +13,7 @@ use crate::models::{ ClipboardPolicy, ContainerPolicy, ContainmentBackend, ExecutionRequest, ExperimentalConfig, IsolationSessionConfig, IsolationSessionUser, LifecycleConfig, LxcConfig, NetworkEnforcementMode, NetworkPolicy, PortMapping, ProxyAddress, ProxyConfig, SeatbeltConfig, - TestFeatureConfig, UiPolicy, WindowsSandboxConfig, WslcConfig, + TelemetryConfig, TestFeatureConfig, UiPolicy, WindowsSandboxConfig, WslcConfig, }; use crate::mxc_error::MxcError; use crate::state_aware_request::{MxcRequest, ParsedStateAwareRequest, Phase}; @@ -200,6 +200,13 @@ struct RawExperimental { isolation_session: Option, #[serde(alias = "macos_sandbox")] seatbelt: Option, + telemetry: Option, +} + +#[derive(Deserialize, Default)] +#[serde(default)] +struct RawTelemetry { + enabled: Option, } #[derive(Deserialize, Default)] @@ -1200,12 +1207,16 @@ fn convert_raw_config_inner( keychain_access: raw_sb.keychain_access.unwrap_or(false), extra_mach_lookups: raw_sb.extra_mach_lookups.unwrap_or_default(), }); + let telemetry = raw_exp.telemetry.map(|raw_t| TelemetryConfig { + enabled: raw_t.enabled, + }); ExperimentalConfig { test, windows_sandbox, wslc, isolation_session, seatbelt, + telemetry, } } else { ExperimentalConfig::default() @@ -3591,4 +3602,45 @@ mod tests { let mut logger = test_logger(); load_request(&encoded, &mut logger, true).expect_err("vm has no resolver off Windows"); } + + // ── Telemetry ──────────────────────────────────────────────────── + + #[test] + fn telemetry_not_set() { + let json = r#"{"process":{"commandLine":"echo hi"}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + let req = load_request(&encoded, &mut logger, true).unwrap(); + assert!(req.experimental.telemetry.is_none()); + } + + #[test] + fn telemetry_enabled_true() { + let json = r#"{"process":{"commandLine":"echo hi"},"experimental":{"telemetry":{"enabled":true}}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + let req = load_request(&encoded, &mut logger, true).unwrap(); + let telem = req.experimental.telemetry.expect("telemetry should be set"); + assert_eq!(telem.enabled, Some(true)); + } + + #[test] + fn telemetry_enabled_false() { + let json = r#"{"process":{"commandLine":"echo hi"},"experimental":{"telemetry":{"enabled":false}}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + let req = load_request(&encoded, &mut logger, true).unwrap(); + let telem = req.experimental.telemetry.expect("telemetry should be set"); + assert_eq!(telem.enabled, Some(false)); + } + + #[test] + fn telemetry_empty_object() { + let json = r#"{"process":{"commandLine":"echo hi"},"experimental":{"telemetry":{}}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + let req = load_request(&encoded, &mut logger, true).unwrap(); + let telem = req.experimental.telemetry.expect("telemetry should be set"); + assert_eq!(telem.enabled, None); + } } diff --git a/src/core/wxc_common/src/lib.rs b/src/core/wxc_common/src/lib.rs index 989b381f..731d32fa 100644 --- a/src/core/wxc_common/src/lib.rs +++ b/src/core/wxc_common/src/lib.rs @@ -18,6 +18,7 @@ pub mod script_runner; pub mod state_aware_backend; pub mod state_aware_dispatch; pub mod state_aware_request; +pub mod telemetry; pub mod ui_policy; pub mod validator; diff --git a/src/core/wxc_common/src/models.rs b/src/core/wxc_common/src/models.rs index f9f0f6e5..1d7c0476 100644 --- a/src/core/wxc_common/src/models.rs +++ b/src/core/wxc_common/src/models.rs @@ -550,6 +550,17 @@ pub struct ExperimentalConfig { pub isolation_session: Option, /// Seatbelt (macOS) backend (experimental). pub seatbelt: Option, + /// Telemetry configuration (experimental). + pub telemetry: Option, +} + +/// Telemetry configuration parsed from the JSON config `experimental.telemetry` section. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct TelemetryConfig { + /// Explicit telemetry override. + /// `Some(true)` = force on, `Some(false)` = force off, `None` = default (off). + pub enabled: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/core/wxc_common/src/telemetry/events.rs b/src/core/wxc_common/src/telemetry/events.rs new file mode 100644 index 00000000..abe42abc --- /dev/null +++ b/src/core/wxc_common/src/telemetry/events.rs @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TraceLogging ETW event emission for MXC telemetry. +//! +//! Event-specific data types and emission functions. The actual ETW +//! write is delegated to the `mxc_wil_telemetry` C++ shim, which +//! adds Part B common fields automatically. + +/// Bounded set of failure categories for error classification. +/// Prevents free-form strings that could contain PII. +#[derive(Debug, Clone, Copy)] +pub enum FailureReason { + ConfigError, + PolicyError, + ProcessError, + Timeout, + InitError, + Unknown, +} + +impl FailureReason { + pub fn as_str(&self) -> &'static str { + match self { + Self::ConfigError => "config_error", + Self::PolicyError => "policy_error", + Self::ProcessError => "process_error", + Self::Timeout => "timeout", + Self::InitError => "init_error", + Self::Unknown => "unknown", + } + } +} + +/// Sanitize an error message by stripping potential PII (file paths, usernames). +pub fn sanitize_error_message(msg: &str) -> String { + let mut sanitized = String::with_capacity(msg.len()); + let mut chars = msg.chars().peekable(); + + while let Some(c) = chars.next() { + // Detect Windows paths: letter followed by :\ + if c.is_ascii_alphabetic() { + if let Some(&next) = chars.peek() { + if next == ':' { + // Peek further for backslash + let rest: String = chars.clone().take(2).collect(); + if rest.starts_with(":\\") { + sanitized.push_str(""); + // Skip until whitespace or quote + for ch in chars.by_ref() { + if ch.is_whitespace() || ch == '\'' || ch == '"' { + sanitized.push(ch); + break; + } + } + continue; + } + } + } + sanitized.push(c); + continue; + } + + // Detect Unix paths: /home/, /tmp/, /var/, /usr/, /etc/, /root/, /mnt/, /opt/ + if c == '/' { + let prefixes = [ + "home/", "tmp/", "var/", "usr/", "etc/", "root/", "mnt/", "opt/", + ]; + let upcoming: String = chars.clone().take(5).collect(); + if prefixes.iter().any(|p| upcoming.starts_with(p)) { + sanitized.push_str(""); + // Skip until whitespace or quote + for ch in chars.by_ref() { + if ch.is_whitespace() || ch == '\'' || ch == '"' { + sanitized.push(ch); + break; + } + } + continue; + } + } + + sanitized.push(c); + } + + // Truncate to a reasonable length, respecting UTF-8 char boundaries + // to avoid panics from `String::truncate` on multi-byte characters. + const MAX_LEN: usize = 256; + const ELLIPSIS: &str = "..."; + if sanitized.len() > MAX_LEN { + // Reserve space for the ellipsis so the final output is <= MAX_LEN. + let mut truncate_at = MAX_LEN.saturating_sub(ELLIPSIS.len()); + while !sanitized.is_char_boundary(truncate_at) { + truncate_at -= 1; + } + sanitized.truncate(truncate_at); + sanitized.push_str(ELLIPSIS); + } + + sanitized +} + +/// Data for an MXC.Execution ETW event. +pub struct ExecutionEvent<'a> { + pub backend: &'a str, + pub exit_code: i32, + pub outcome: &'a str, + pub duration_ms: u64, + pub version: &'a str, + pub failure_reason: Option, +} + +/// Log an MXC.Execution ETW event. +/// +/// Delegates to the WIL C++ shim which adds Part B common fields +/// (Version, Channel, IsDebugging, UTCReplace_AppSessionGuid). +pub fn log_execution(event: &ExecutionEvent<'_>) { + let failure_str = event.failure_reason.map(|r| r.as_str()).unwrap_or(""); + + mxc_wil_telemetry::log_execution( + event.backend, + event.exit_code, + event.outcome, + event.duration_ms, + failure_str, + ); +} + +/// Log an MXC.Error ETW event. +/// +/// Delegates to the WIL C++ shim which adds Part B common fields. +pub fn log_error(backend: &str, error_type: FailureReason, error_message: &str, version: &str) { + let _ = version; // Version is now provided by the C++ shim via Part B fields + let sanitized = sanitize_error_message(error_message); + + mxc_wil_telemetry::log_error(backend, error_type.as_str(), &sanitized); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn failure_reason_as_str() { + assert_eq!(FailureReason::ConfigError.as_str(), "config_error"); + assert_eq!(FailureReason::PolicyError.as_str(), "policy_error"); + assert_eq!(FailureReason::ProcessError.as_str(), "process_error"); + assert_eq!(FailureReason::Timeout.as_str(), "timeout"); + assert_eq!(FailureReason::InitError.as_str(), "init_error"); + assert_eq!(FailureReason::Unknown.as_str(), "unknown"); + } + + #[test] + fn sanitize_strips_windows_paths() { + let msg = "Failed to read C:\\Users\\alice\\secret\\config.json"; + let result = sanitize_error_message(msg); + assert!(!result.contains("alice")); + assert!(!result.contains("secret")); + assert!(result.contains("")); + } + + #[test] + fn sanitize_strips_unix_paths() { + let msg = "Cannot open /home/bob/project/data.txt"; + let result = sanitize_error_message(msg); + assert!(!result.contains("bob")); + assert!(result.contains("")); + } + + #[test] + fn sanitize_truncates_long_messages() { + let long_msg = "x".repeat(500); + let result = sanitize_error_message(&long_msg); + assert!(result.len() < 300); + assert!(result.ends_with("...")); + } + + #[test] + fn sanitize_preserves_safe_messages() { + let msg = "Firewall rule creation failed"; + assert_eq!(sanitize_error_message(msg), msg); + } +} diff --git a/src/core/wxc_common/src/telemetry/mod.rs b/src/core/wxc_common/src/telemetry/mod.rs new file mode 100644 index 00000000..2845759e --- /dev/null +++ b/src/core/wxc_common/src/telemetry/mod.rs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TraceLogging ETW telemetry for MXC. +//! +//! Provides structured event emission for execution observability +//! and adoption metrics. Events are emitted to the local ETW subsystem +//! via a C++ shim that uses WIL's `TraceLoggingProvider` class — the +//! same pattern used by WinAppSDK. Every event includes Part B common +//! fields (Version, Channel, IsDebugging, UTCReplace_AppSessionGuid). +//! +//! On non-Windows platforms, all telemetry functions are no-ops. + +pub mod events; + +use crate::models::TelemetryConfig; + +pub use events::{log_error, log_execution, ExecutionEvent, FailureReason}; + +/// MXC version string, set at compile time. +const MXC_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Build channel — `"dev"` for debug builds, `"release"` for release builds. +#[cfg(debug_assertions)] +const MXC_CHANNEL: &str = "dev"; +#[cfg(not(debug_assertions))] +const MXC_CHANNEL: &str = "release"; + +/// Returns the MXC version string. +pub fn version() -> &'static str { + MXC_VERSION +} + +/// Resolve whether telemetry is enabled for this invocation. +/// +/// Resolution: +/// - `experimental.telemetry.enabled` in JSON config — explicit override. +/// - Default: off (telemetry requires explicit opt-in). +/// +/// Note: Consent is the SDK consumer's responsibility. MXC does not implement +/// consent prompts or persistent consent storage. +pub fn is_enabled(config: &TelemetryConfig) -> bool { + config.enabled.unwrap_or(false) +} + +/// Initialize the WIL TraceLogging ETW provider. +/// +/// If telemetry is enabled, registers the `Microsoft.MXC` provider with ETW +/// via the C++ WIL shim. Returns `true` if telemetry was activated, `false` +/// if disabled or on non-Windows platforms. +/// +/// Errors during registration are silently swallowed (telemetry must not +/// affect execution). +pub fn init(config: &TelemetryConfig) -> bool { + if !is_enabled(config) { + return false; + } + + mxc_wil_telemetry::init(MXC_VERSION, MXC_CHANNEL) +} + +/// Unregister the TraceLogging ETW provider. +/// +/// Must be called before process exit if `init()` returned `true`. +pub fn shutdown() { + mxc_wil_telemetry::shutdown(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_enabled_explicit_true() { + let config = TelemetryConfig { + enabled: Some(true), + }; + assert!(is_enabled(&config)); + } + + #[test] + fn is_enabled_explicit_false() { + let config = TelemetryConfig { + enabled: Some(false), + }; + assert!(!is_enabled(&config)); + } + + #[test] + fn is_enabled_default_off() { + let config = TelemetryConfig::default(); + assert!(!is_enabled(&config)); + } + + #[test] + fn version_is_not_empty() { + assert!(!version().is_empty()); + } +} diff --git a/src/mxc_wil_telemetry/Cargo.toml b/src/mxc_wil_telemetry/Cargo.toml new file mode 100644 index 00000000..f3aba726 --- /dev/null +++ b/src/mxc_wil_telemetry/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "mxc_wil_telemetry" +version.workspace = true +edition.workspace = true +description = "WIL-based TraceLogging ETW telemetry for MXC (C++ shim with Rust FFI)" + +[build-dependencies] +cc = "1" +zip = "2" +ureq = "2" diff --git a/src/mxc_wil_telemetry/build.rs b/src/mxc_wil_telemetry/build.rs new file mode 100644 index 00000000..cc4c3853 --- /dev/null +++ b/src/mxc_wil_telemetry/build.rs @@ -0,0 +1,210 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Build script for `mxc_wil_telemetry`. +//! +//! On Windows targets, this script: +//! 1. Downloads the WIL NuGet package (header-only, MIT licensed) +//! 2. Extracts the `include/wil/` headers +//! 3. Applies the telemetry config override (if `MXC_TELEMETRY_CONFIG_OVERRIDE` +//! is set) — this mirrors WinAppSDK's `UpdateTraceloggingConfig` pipeline step +//! 4. Compiles the C++ telemetry shim against those headers +//! +//! On non-Windows targets, nothing is compiled — the Rust library +//! exposes no-op stub functions instead. + +fn main() { + #[cfg(target_os = "windows")] + windows_build::build(); +} + +#[cfg(target_os = "windows")] +mod windows_build { + use std::fs; + use std::io::{self, Read}; + use std::path::{Path, PathBuf}; + + /// WIL NuGet package version — matches WinAppSDK's `Directory.Packages.props`. + const WIL_VERSION: &str = "1.0.260126.7"; + + /// NuGet package download URL. + const WIL_NUGET_URL: &str = concat!( + "https://www.nuget.org/api/v2/package/", + "Microsoft.Windows.ImplementationLibrary/1.0.260126.7" + ); + + /// Cache directory name under `OUT_DIR` so Cargo automatically invalidates + /// the cache when the target or profile changes. + const CACHE_DIR_NAME: &str = "wil-cache"; + + /// Sentinel file that indicates headers have already been extracted. + const SENTINEL_NAME: &str = ".wil-extracted"; + + pub fn build() { + let out_dir = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR not set")); + let cache_dir = out_dir.join(CACHE_DIR_NAME); + let include_dir = cache_dir.join("include"); + let sentinel = cache_dir.join(SENTINEL_NAME); + + // Only download + extract if we haven't already, or if the cached version is stale. + let cached_version = fs::read_to_string(&sentinel).unwrap_or_default(); + if cached_version.trim() != WIL_VERSION { + if cache_dir.exists() { + fs::remove_dir_all(&cache_dir) + .expect("failed to clear stale WIL cache directory"); + } + fs::create_dir_all(&cache_dir).expect("failed to create WIL cache directory"); + download_and_extract_wil(&cache_dir, &include_dir); + fs::write(&sentinel, WIL_VERSION).expect("failed to write WIL sentinel"); + } + + // Apply telemetry configuration override for internal builds. + // This mirrors WinAppSDK's UpdateTraceloggingConfig pipeline step: + // the private MicrosoftTelemetry.h overwrites the public no-op + // traceloggingconfig.h so TraceLoggingOptionMicrosoftTelemetry() + // expands to the real Microsoft telemetry group GUID. + apply_telemetry_config_override(&include_dir); + + // Compile the C++ shim. + let shim_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("shim"); + + cc::Build::new() + .cpp(true) + .file(shim_dir.join("mxc_telemetry_shim.cpp")) + .include(&include_dir) + // WIL needs the Windows SDK headers — MSVC toolchain provides them. + .std("c++17") + // Suppress warnings from WIL headers that we don't control. + .warnings(false) + .compile("mxc_telemetry_shim"); + + // Tell Cargo to re-run if the shim source changes. + println!("cargo::rerun-if-changed=shim/mxc_telemetry_shim.cpp"); + println!("cargo::rerun-if-changed=shim/mxc_telemetry_shim.h"); + + // Link against advapi32 for ETW functions (EventRegister, etc.). + println!("cargo::rustc-link-lib=advapi32"); + } + + /// Download the WIL NuGet package and extract `include/wil/` headers. + fn download_and_extract_wil(cache_dir: &Path, include_dir: &Path) { + eprintln!("Downloading WIL NuGet package v{WIL_VERSION}..."); + + let resp = ureq::get(WIL_NUGET_URL) + .call() + .expect("failed to download WIL NuGet package"); + + let mut body = Vec::new(); + resp.into_reader() + .read_to_end(&mut body) + .expect("failed to read WIL NuGet response body"); + + let cursor = io::Cursor::new(body); + let mut archive = + zip::ZipArchive::new(cursor).expect("WIL NuGet package is not a valid zip"); + + // Extract only files under `include/` — WIL is header-only so this + // is all we need. + for i in 0..archive.len() { + let mut file = archive.by_index(i).unwrap(); + let rel_path = match file.enclosed_name() { + Some(p) => p.to_owned(), + None => continue, + }; + + // NuGet packages use forward-slash paths internally. + if !rel_path.starts_with("include") || file.is_dir() { + continue; + } + + let dest = cache_dir.join(&rel_path); + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).unwrap(); + } + + let mut out = fs::File::create(&dest).unwrap(); + io::copy(&mut file, &mut out).unwrap(); + } + + // Verify that we actually got the headers. + assert!( + include_dir.join("wil").join("tracelogging.h").exists(), + "WIL headers not found after extraction — download may have failed" + ); + + eprintln!("WIL headers extracted to {}", include_dir.display()); + } + + /// Apply — or revert — the telemetry configuration override based on + /// `MXC_TELEMETRY_CONFIG_OVERRIDE`. + /// + /// This is the Cargo equivalent of WinAppSDK's `UpdateTraceloggingConfig` + /// PowerShell step. The env var should point to `MicrosoftTelemetry.h` + /// from the `Microsoft.Telemetry.Inbox.Native` NuGet package. When set, + /// the file is copied over `wil/traceloggingconfig.h` so + /// `TraceLoggingOptionMicrosoftTelemetry()` expands to the real Microsoft + /// telemetry group GUID instead of the public no-op stub. + /// + /// When the env var is **unset**, the function restores the original + /// public header from a `.public` backup. This prevents a stale private + /// header from persisting in `OUT_DIR` across incremental builds. + /// + /// Public/community builds leave the env var unset — the macro stays empty + /// and events fire as plain ETW with no Microsoft pipeline routing. + fn apply_telemetry_config_override(include_dir: &Path) { + // Re-run build.rs whenever this env var changes so toggling between + // public/private builds picks up the new value without a clean build. + println!("cargo::rerun-if-env-changed=MXC_TELEMETRY_CONFIG_OVERRIDE"); + + let dest = include_dir.join("wil").join("traceloggingconfig.h"); + let backup = include_dir.join("wil").join("traceloggingconfig.h.public"); + + // On first run, save the pristine public header so we can restore it + // later when toggling back from a private build. + if !backup.exists() && dest.exists() { + fs::copy(&dest, &backup).unwrap_or_else(|e| { + panic!("failed to back up public traceloggingconfig.h: {e}"); + }); + } + + let override_path = match std::env::var("MXC_TELEMETRY_CONFIG_OVERRIDE") { + Ok(p) if !p.is_empty() => PathBuf::from(p), + _ => { + // Not set or empty — restore the public stub if a private + // override was applied in a previous build. + if backup.exists() { + fs::copy(&backup, &dest).unwrap_or_else(|e| { + panic!("failed to restore public traceloggingconfig.h: {e}"); + }); + eprintln!("Restored public (no-op) traceloggingconfig.h"); + } + return; + } + }; + + if !override_path.exists() { + panic!( + "MXC_TELEMETRY_CONFIG_OVERRIDE points to non-existent file: {}\n\ + Verify that the Microsoft.Telemetry.Inbox.Native NuGet package \ + was restored correctly.", + override_path.display() + ); + } + + fs::copy(&override_path, &dest).unwrap_or_else(|e| { + panic!( + "failed to apply telemetry config override {} -> {}: {e}", + override_path.display(), + dest.display() + ); + }); + + // Also tell Cargo to re-run if the override file itself changes. + println!("cargo::rerun-if-changed={}", override_path.display()); + + eprintln!( + "Applied telemetry config override from {}", + override_path.display() + ); + } +} diff --git a/src/mxc_wil_telemetry/shim/mxc_telemetry_shim.cpp b/src/mxc_wil_telemetry/shim/mxc_telemetry_shim.cpp new file mode 100644 index 00000000..ad20acdd --- /dev/null +++ b/src/mxc_wil_telemetry/shim/mxc_telemetry_shim.cpp @@ -0,0 +1,185 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// MXC telemetry shim — WIL-based TraceLogging provider. +// +// This file implements the same pattern used by WinAppSDK +// (WindowsAppRuntimeInsights.h / DeploymentTraceLogging.h): +// +// 1. Provider class via IMPLEMENT_TRACELOGGING_CLASS +// 2. Part B common fields on every event (_MXC_GENERIC_PARTB_FIELDS) +// 3. UTCReplace_AppSessionGuid for privacy +// 4. Flat extern "C" surface for Rust FFI +// +// WIL is header-only (MIT licensed), acquired from NuGet at build time. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "mxc_telemetry_shim.h" + +// --------------------------------------------------------------------------- +// Provider definition +// --------------------------------------------------------------------------- + +// The provider GUID below is a PLACEHOLDER for the open-source build. +// Internal Microsoft builds replace this GUID at packaging time with the +// production GUID registered in the telemetry pipeline. This mirrors the +// WinAppSDK pattern described in the WinAppSDK Telemetry spec. +// +// The provider group GUID (set via TraceLoggingOptionMicrosoftTelemetry()) +// identifies this as a Microsoft first-party TraceLogging telemetry provider. +// It is part of the IMPLEMENT_TRACELOGGING_CLASS macro when using the +// *MicrosoftTelemetry variant. + +class MxcTelemetryProvider final : public wil::TraceLoggingProvider +{ + IMPLEMENT_TRACELOGGING_CLASS( + MxcTelemetryProvider, + "Microsoft.MXC", + // Placeholder provider GUID for OSS — (4f50731a-89cf-4782-b3e0-dce8c90476ba) + // Replace at packaging for internal builds. + (0x4f50731a, 0x89cf, 0x4782, 0xb3, 0xe0, 0xdc, 0xe8, 0xc9, 0x04, 0x76, 0xba), + TraceLoggingOptionMicrosoftTelemetry()); +}; + +// --------------------------------------------------------------------------- +// Cached state — set at init, read on every event. +// Protected by an SRWLOCK for thread safety across the FFI boundary. +// --------------------------------------------------------------------------- + +static SRWLOCK s_lock = SRWLOCK_INIT; +static std::string s_version; +static std::string s_channel; + +// --------------------------------------------------------------------------- +// Part B common fields macro +// --------------------------------------------------------------------------- +// Modelled on WinAppSDK's _GENERIC_PARTB_FIELDS_ENABLED. +// Every event includes: +// - Version: MXC crate version (e.g., "0.3.0") +// - Channel: build channel ("dev" or "release") +// - IsDebugging: whether a debugger is attached +// - UTCReplace_AppSessionGuid: tells UTC to replace the app session GUID +// for privacy (per-session GUID instead of persistent identifier) +// +// Callers must snapshot s_version/s_channel under the read lock and pass +// them as local variables `snap_version` and `snap_channel`. + +#define _MXC_GENERIC_PARTB_FIELDS \ + TraceLoggingStruct(4, "COMMON_MXC_PARAMS"), \ + TraceLoggingString(snap_version.c_str(), "Version"), \ + TraceLoggingString(snap_channel.c_str(), "Channel"), \ + TraceLoggingBool(!!IsDebuggerPresent(), "IsDebugging"), \ + TraceLoggingBool(true, "UTCReplace_AppSessionGuid") + +// --------------------------------------------------------------------------- +// extern "C" API for Rust FFI +// --------------------------------------------------------------------------- + +extern "C" bool mxc_telemetry_init(const char* version, const char* channel) +{ + if (!version || !channel) + { + return false; + } + + AcquireSRWLockExclusive(&s_lock); + s_version = version; + s_channel = channel; + ReleaseSRWLockExclusive(&s_lock); + + // Provider registration happens lazily via WIL's singleton pattern + // on first TraceLoggingWrite. Return true to indicate init succeeded + // (IsEnabled() only checks if an ETW session is listening, which is + // orthogonal to whether the provider is correctly registered). + return true; +} + +extern "C" void mxc_telemetry_shutdown() +{ + // WIL providers are process-lifetime singletons — they unregister + // automatically at DLL/EXE unload. This function is provided for + // symmetry with the Rust API and to allow explicit cleanup. + AcquireSRWLockExclusive(&s_lock); + s_version.clear(); + s_channel.clear(); + ReleaseSRWLockExclusive(&s_lock); +} + +extern "C" void mxc_telemetry_log_execution( + const char* backend, + int exit_code, + const char* outcome, + unsigned long long duration_ms, + const char* failure_reason) +{ + if (!MxcTelemetryProvider::IsEnabled()) + { + return; + } + + const char* safe_backend = backend ? backend : ""; + const char* safe_outcome = outcome ? outcome : ""; + const char* safe_failure = failure_reason ? failure_reason : ""; + + // Snapshot cached strings under the shared (read) lock so we don't + // race with init/shutdown which acquire the exclusive (write) lock. + AcquireSRWLockShared(&s_lock); + std::string snap_version = s_version; + std::string snap_channel = s_channel; + ReleaseSRWLockShared(&s_lock); + + TraceLoggingWrite( + MxcTelemetryProvider::Provider(), + "MXC.Execution", + TraceLoggingKeyword(MICROSOFT_KEYWORD_MEASURES), + TraceLoggingLevel(TRACE_LEVEL_INFORMATION), + // Part B common fields + _MXC_GENERIC_PARTB_FIELDS, + // Event-specific fields + TraceLoggingString(safe_backend, "mxc.backend"), + TraceLoggingInt32(exit_code, "mxc.exit_code"), + TraceLoggingString(safe_outcome, "mxc.outcome"), + TraceLoggingUInt64(duration_ms, "mxc.duration_ms"), + TraceLoggingString(safe_failure, "mxc.failure_reason")); +} + +extern "C" void mxc_telemetry_log_error( + const char* backend, + const char* error_type, + const char* error_message) +{ + if (!MxcTelemetryProvider::IsEnabled()) + { + return; + } + + const char* safe_backend = backend ? backend : ""; + const char* safe_type = error_type ? error_type : ""; + const char* safe_message = error_message ? error_message : ""; + + // Snapshot cached strings under the shared (read) lock. + AcquireSRWLockShared(&s_lock); + std::string snap_version = s_version; + std::string snap_channel = s_channel; + ReleaseSRWLockShared(&s_lock); + + TraceLoggingWrite( + MxcTelemetryProvider::Provider(), + "MXC.Error", + TraceLoggingKeyword(MICROSOFT_KEYWORD_MEASURES), + TraceLoggingLevel(TRACE_LEVEL_WARNING), + // Part B common fields + _MXC_GENERIC_PARTB_FIELDS, + // Event-specific fields + TraceLoggingString(safe_backend, "mxc.backend"), + TraceLoggingString(safe_type, "mxc.error_type"), + TraceLoggingString(safe_message, "mxc.error_message")); +} diff --git a/src/mxc_wil_telemetry/shim/mxc_telemetry_shim.h b/src/mxc_wil_telemetry/shim/mxc_telemetry_shim.h new file mode 100644 index 00000000..9db24705 --- /dev/null +++ b/src/mxc_wil_telemetry/shim/mxc_telemetry_shim.h @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// MXC telemetry shim — C++ layer that wraps WIL TraceLogging helpers +// and exposes a flat extern "C" API for Rust FFI. +// +// Design follows the WinAppSDK pattern: +// - Provider class inherits wil::TraceLoggingProvider +// - Every event includes Part B common fields (version, channel, +// IsDebugging, UTCReplace_AppSessionGuid) +// - Provider GUID is a placeholder for OSS — replaced at packaging +// for internal builds + +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/// Register the ETW provider and store the version/channel strings. +/// Returns true on success. +bool mxc_telemetry_init(const char* version, const char* channel); + +/// Unregister the ETW provider. +void mxc_telemetry_shutdown(void); + +/// Emit an MXC.Execution event. +void mxc_telemetry_log_execution( + const char* backend, + int exit_code, + const char* outcome, + unsigned long long duration_ms, + const char* failure_reason); + +/// Emit an MXC.Error event. +void mxc_telemetry_log_error( + const char* backend, + const char* error_type, + const char* error_message); + +#ifdef __cplusplus +} +#endif diff --git a/src/mxc_wil_telemetry/src/lib.rs b/src/mxc_wil_telemetry/src/lib.rs new file mode 100644 index 00000000..2a35d834 --- /dev/null +++ b/src/mxc_wil_telemetry/src/lib.rs @@ -0,0 +1,281 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! WIL-based TraceLogging ETW telemetry for MXC. +//! +//! This crate provides a Rust-safe interface to MXC's C++ telemetry shim, +//! which uses the Windows Implementation Library (WIL) `TraceLoggingProvider` +//! class — the same pattern used by WinAppSDK. +//! +//! # Platform behaviour +//! +//! - **Windows**: Functions call into the compiled C++ shim via FFI, which +//! emits ETW events with Part B common fields and `UTCReplace_AppSessionGuid`. +//! - **Non-Windows**: All functions are no-ops — telemetry is a Windows-only feature. +//! +//! # Thread safety +//! +//! The underlying C++ shim protects shared state (`s_version`, `s_channel`) +//! with an SRWLOCK — readers (event loggers) take a shared lock while writers +//! (`init`, `shutdown`) take an exclusive lock. The FFI functions are safe to +//! call from any thread. + +use std::ffi::CString; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Convert a `&str` to a `CString`, truncating at the first interior NUL byte +/// instead of dropping the entire string. +fn to_cstring_lossy(s: &str) -> CString { + match CString::new(s) { + Ok(c) => c, + Err(e) => { + let pos = e.nul_position(); + // The prefix up to the first NUL is guaranteed NUL-free. + CString::new(&s[..pos]).unwrap_or_default() + } + } +} + +// --------------------------------------------------------------------------- +// FFI declarations (Windows only) +// --------------------------------------------------------------------------- + +#[cfg(target_os = "windows")] +extern "C" { + fn mxc_telemetry_init( + version: *const std::ffi::c_char, + channel: *const std::ffi::c_char, + ) -> bool; + fn mxc_telemetry_shutdown(); + fn mxc_telemetry_log_execution( + backend: *const std::ffi::c_char, + exit_code: i32, + outcome: *const std::ffi::c_char, + duration_ms: u64, + failure_reason: *const std::ffi::c_char, + ); + fn mxc_telemetry_log_error( + backend: *const std::ffi::c_char, + error_type: *const std::ffi::c_char, + error_message: *const std::ffi::c_char, + ); +} + +// --------------------------------------------------------------------------- +// Safe Rust wrappers +// --------------------------------------------------------------------------- + +/// Initialize the WIL TraceLogging provider. +/// +/// Must be called once at process startup. Returns `true` if the provider +/// was successfully registered, `false` if arguments were invalid or on +/// non-Windows platforms. +/// +/// Note: A `true` return means the provider is registered — it does *not* +/// guarantee an ETW session is actively listening. +/// +/// # Arguments +/// +/// * `version` — MXC version string (e.g., from `CARGO_PKG_VERSION`) +/// * `channel` — Build channel (`"dev"` or `"release"`) +pub fn init(version: &str, channel: &str) -> bool { + #[cfg(target_os = "windows")] + { + let c_version = to_cstring_lossy(version); + let c_channel = to_cstring_lossy(channel); + // SAFETY: The C++ shim copies the strings into `std::string` storage + // under an exclusive lock. The CStrings remain valid for the call. + unsafe { mxc_telemetry_init(c_version.as_ptr(), c_channel.as_ptr()) } + } + #[cfg(not(target_os = "windows"))] + { + let _ = (version, channel); + false + } +} + +/// Shut down the WIL TraceLogging provider. +/// +/// Should be called before process exit for clean resource release, +/// although WIL providers auto-unregister at process termination. +pub fn shutdown() { + #[cfg(target_os = "windows")] + { + // SAFETY: The C++ shim clears cached strings under an exclusive lock; + // the WIL provider singleton handles its own thread-safe cleanup. + unsafe { + mxc_telemetry_shutdown(); + } + } +} + +/// Emit an `MXC.Execution` ETW event with Part B common fields. +/// +/// All string arguments are copied by the C++ shim — no lifetime +/// requirements beyond the function call. +pub fn log_execution( + backend: &str, + exit_code: i32, + outcome: &str, + duration_ms: u64, + failure_reason: &str, +) { + #[cfg(target_os = "windows")] + { + let c_backend = to_cstring_lossy(backend); + let c_outcome = to_cstring_lossy(outcome); + let c_failure = to_cstring_lossy(failure_reason); + // SAFETY: All pointers are valid CStrings; the C++ shim reads them + // synchronously under a shared lock and does not retain references. + unsafe { + mxc_telemetry_log_execution( + c_backend.as_ptr(), + exit_code, + c_outcome.as_ptr(), + duration_ms, + c_failure.as_ptr(), + ); + } + } + #[cfg(not(target_os = "windows"))] + { + let _ = (backend, exit_code, outcome, duration_ms, failure_reason); + } +} + +/// Emit an `MXC.Error` ETW event with Part B common fields. +pub fn log_error(backend: &str, error_type: &str, error_message: &str) { + #[cfg(target_os = "windows")] + { + let c_backend = to_cstring_lossy(backend); + let c_type = to_cstring_lossy(error_type); + let c_message = to_cstring_lossy(error_message); + // SAFETY: All pointers are valid CStrings; the C++ shim reads them + // synchronously under a shared lock and does not retain references. + unsafe { + mxc_telemetry_log_error(c_backend.as_ptr(), c_type.as_ptr(), c_message.as_ptr()); + } + } + #[cfg(not(target_os = "windows"))] + { + let _ = (backend, error_type, error_message); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + /// The C++ shim uses global state protected by an SRWLOCK, so tests that + /// call `init`/`shutdown` must not run concurrently. This mutex serialises + /// all FFI-touching tests without adding an external crate dependency. + static FFI_LOCK: Mutex<()> = Mutex::new(()); + + // ------------------------------------------------------------------- + // to_cstring_lossy tests (pure Rust, no FFI — can run in parallel) + // ------------------------------------------------------------------- + + #[test] + fn to_cstring_lossy_normal_string() { + let c = to_cstring_lossy("hello"); + assert_eq!(c.to_bytes(), b"hello"); + } + + #[test] + fn to_cstring_lossy_interior_nul() { + let c = to_cstring_lossy("ab\0cd"); + assert_eq!(c.to_bytes(), b"ab"); + } + + #[test] + fn to_cstring_lossy_empty_string() { + let c = to_cstring_lossy(""); + assert_eq!(c.to_bytes(), b""); + } + + #[test] + fn to_cstring_lossy_leading_nul() { + let c = to_cstring_lossy("\0rest"); + assert_eq!(c.to_bytes(), b""); + } + + // ------------------------------------------------------------------- + // FFI lifecycle tests (serialised via FFI_LOCK) + // ------------------------------------------------------------------- + + #[test] + fn init_shutdown_roundtrip() { + let _lock = FFI_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let ok = init("0.0.0-test", "dev"); + // On Windows this registers the ETW provider; on other platforms + // it returns false (no-op). + if cfg!(target_os = "windows") { + assert!(ok, "init should succeed on Windows"); + } else { + assert!(!ok, "init should be a no-op on non-Windows"); + } + shutdown(); + } + + #[test] + fn double_init_is_safe() { + let _lock = FFI_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _ = init("0.0.0-test", "dev"); + let _ = init("0.0.0-test", "dev"); + shutdown(); + } + + #[test] + fn shutdown_without_init() { + let _lock = FFI_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // Must not panic or crash. + shutdown(); + } + + #[test] + fn log_execution_after_init() { + let _lock = FFI_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _ = init("0.0.0-test", "dev"); + log_execution("test_backend", 0, "success", 100, ""); + shutdown(); + } + + #[test] + fn log_error_after_init() { + let _lock = FFI_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _ = init("0.0.0-test", "dev"); + log_error("test_backend", "config_error", "test error message"); + shutdown(); + } + + #[test] + fn log_without_init() { + let _lock = FFI_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // Must be safe no-ops — no init called. + log_execution("test_backend", 0, "success", 50, "none"); + log_error("test_backend", "unknown", "no init"); + } + + #[test] + fn log_after_shutdown() { + let _lock = FFI_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _ = init("0.0.0-test", "dev"); + shutdown(); + // Must be safe no-ops — provider already unregistered. + log_execution("test_backend", 1, "failure", 200, "timeout"); + log_error("test_backend", "process_error", "after shutdown"); + } + + #[test] + fn handles_empty_strings() { + let _lock = FFI_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _ = init("", ""); + log_execution("", 0, "", 0, ""); + log_error("", "", ""); + shutdown(); + } +} diff --git a/src/testing/wxc_e2e_tests/src/lib.rs b/src/testing/wxc_e2e_tests/src/lib.rs index 8c7a317d..8d7d9f00 100644 --- a/src/testing/wxc_e2e_tests/src/lib.rs +++ b/src/testing/wxc_e2e_tests/src/lib.rs @@ -434,7 +434,7 @@ fn command_result(label: &str, output: Output, wall_time_ms: u128) -> CommandRes } } -/// Run `wxc-exec.exe` with the supplied config file and extra arguments. +/// Run `wxc-exec.exe` with a config file from `tests/configs/` and extra arguments. pub fn run_wxc_config(config_file: &str, extra_args: &[&str]) -> CommandResult { let exe = find_binary("wxc-exec.exe").expect("wxc-exec.exe should be available"); let config = test_configs_dir().join(config_file); @@ -444,6 +444,16 @@ pub fn run_wxc_config(config_file: &str, extra_args: &[&str]) -> CommandResult { run_executable(config_file, &exe, args) } +/// Run `wxc-exec.exe` with a config file from `tests/examples/` and extra arguments. +pub fn run_wxc_example(config_file: &str, extra_args: &[&str]) -> CommandResult { + let exe = find_binary("wxc-exec.exe").expect("wxc-exec.exe should be available"); + let config = examples_dir().join(config_file); + let mut args: Vec = extra_args.iter().map(|arg| (*arg).to_string()).collect(); + args.push(config.display().to_string()); + + run_executable(config_file, &exe, args) +} + /// Run `wxc-exec.exe` with a state-aware request envelope. The JSON value is /// serialised, base64-encoded, and passed via `--config-base64`. Used by the /// state-aware smoke tests. diff --git a/src/testing/wxc_e2e_tests/tests/e2e_windows.rs b/src/testing/wxc_e2e_tests/tests/e2e_windows.rs index 741875e8..6ce6d242 100644 --- a/src/testing/wxc_e2e_tests/tests/e2e_windows.rs +++ b/src/testing/wxc_e2e_tests/tests/e2e_windows.rs @@ -18,8 +18,8 @@ use wxc_e2e_tests::{ assert_exit, assert_pwsh, assert_python, assert_success, assert_success_or_skip_missing_prerequisite, examples_dir, find_binary, has_daemon, has_hyperlight_snapshot, has_nanvix_binaries, has_test_driver, has_windows_sandbox_feature, - has_wxc_exe, repo_root, run_test_driver, run_wxc_config, run_wxc_state_aware, test_configs_dir, - TempDirs, + has_wxc_exe, repo_root, run_test_driver, run_wxc_config, run_wxc_example, run_wxc_state_aware, + test_configs_dir, TempDirs, }; static HAS_WXC_EXE: OnceLock = OnceLock::new(); @@ -285,6 +285,41 @@ fn test_on_repeat() { }); } +// --------------------------------------------------------------------------- +// Telemetry tests +// --------------------------------------------------------------------------- + +fn telemetry_enabled() { + let result = run_wxc_example("28_telemetry_enabled.json", &["--debug", "--experimental"]); + assert_success_or_skip_missing_prerequisite(&result); +} + +fn telemetry_disabled() { + // Run a basic config without telemetry — verifies the disabled path doesn't + // regress when telemetry code is linked in. + assert_wxc_success("basic_processcontainer.json", &["--debug"]); +} + +#[test] +#[ignore] // Requires AppContainer support +fn test_telemetry_enabled() { + if !cached_has_wxc_exe() { + return; + } + assert_python(); + with_test_lock(telemetry_enabled); +} + +#[test] +#[ignore] // Requires AppContainer support +fn test_telemetry_disabled() { + if !cached_has_wxc_exe() { + return; + } + assert_python(); + with_test_lock(telemetry_disabled); +} + // --------------------------------------------------------------------------- // Windows Sandbox suite // --------------------------------------------------------------------------- diff --git a/tests/examples/28_telemetry_enabled.json b/tests/examples/28_telemetry_enabled.json new file mode 100644 index 00000000..d12c5465 --- /dev/null +++ b/tests/examples/28_telemetry_enabled.json @@ -0,0 +1,10 @@ +{ + "$schema": "../../schemas/dev/mxc-config.schema.0.7.0-dev.json", + "containment": "processcontainer", + "command": ["cmd.exe", "/c", "echo Hello from telemetry-enabled sandbox"], + "experimental": { + "telemetry": { + "enabled": true + } + } +} diff --git a/tests/scripts/run_telemetry_build_override_test.ps1 b/tests/scripts/run_telemetry_build_override_test.ps1 new file mode 100644 index 00000000..8b99cd9d --- /dev/null +++ b/tests/scripts/run_telemetry_build_override_test.ps1 @@ -0,0 +1,110 @@ +<# +.SYNOPSIS + Validates the MXC telemetry build.rs override mechanism. + +.DESCRIPTION + Exercises three scenarios for the MXC_TELEMETRY_CONFIG_OVERRIDE mechanism: + 1. Public build (no env var) — WIL's default traceloggingconfig.h is used. + 2. Override build — a dummy .h file is copied over the WIL header. + 3. Revert build — removing the env var restores the .public backup. + + All tests use a harmless dummy header (no private GUIDs). + + Requires: cargo, Rust toolchain, MSVC. Run from the repo root. +#> + +[CmdletBinding()] +param( + [switch]$SkipClean +) + +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$srcDir = Join-Path $repoRoot 'src' +$crateDir = Join-Path $srcDir 'mxc_wil_telemetry' + +Write-Host "=== Telemetry Build Override Validation ===" -ForegroundColor Cyan +Write-Host "Repo root: $repoRoot" +Write-Host "Crate dir: $crateDir" + +# Create a dummy override header with safe placeholder content. +$dummyDir = Join-Path $env:TEMP 'mxc_telemetry_test' +if (Test-Path $dummyDir) { Remove-Item -Recurse -Force $dummyDir } +New-Item -ItemType Directory -Path $dummyDir -Force | Out-Null +$dummyHeader = Join-Path $dummyDir 'MicrosoftTelemetry.h' +Set-Content -Path $dummyHeader -Value @" +// Dummy override header for build validation test. +// This is NOT a real telemetry config — it is a safe placeholder. +#pragma once +#define DUMMY_TELEMETRY_BUILD_TEST 1 +"@ + +# --------------------------------------------------------------------------- +# Scenario 1: Public build (no override) +# --------------------------------------------------------------------------- +Write-Host "`n--- Scenario 1: Public build (no env var) ---" -ForegroundColor Yellow + +# Ensure env var is not set. +$env:MXC_TELEMETRY_CONFIG_OVERRIDE = $null +Remove-Item Env:\MXC_TELEMETRY_CONFIG_OVERRIDE -ErrorAction SilentlyContinue + +Push-Location $srcDir +try { + Write-Host "Building mxc_wil_telemetry (public stub)..." + cargo build -p mxc_wil_telemetry 2>&1 | Out-Host + if ($LASTEXITCODE -ne 0) { + throw "Scenario 1 FAILED: public build should succeed" + } + Write-Host "Scenario 1 PASSED: public build succeeded" -ForegroundColor Green +} finally { + Pop-Location +} + +# --------------------------------------------------------------------------- +# Scenario 2: Override build (dummy header) +# --------------------------------------------------------------------------- +Write-Host "`n--- Scenario 2: Override build (dummy header) ---" -ForegroundColor Yellow + +$env:MXC_TELEMETRY_CONFIG_OVERRIDE = $dummyHeader +Write-Host "MXC_TELEMETRY_CONFIG_OVERRIDE = $dummyHeader" + +Push-Location $srcDir +try { + Write-Host "Building mxc_wil_telemetry (override)..." + cargo build -p mxc_wil_telemetry 2>&1 | Out-Host + if ($LASTEXITCODE -ne 0) { + throw "Scenario 2 FAILED: override build should succeed" + } + Write-Host "Scenario 2 PASSED: override build succeeded" -ForegroundColor Green +} finally { + Pop-Location +} + +# --------------------------------------------------------------------------- +# Scenario 3: Revert build (remove override, .public backup restored) +# --------------------------------------------------------------------------- +Write-Host "`n--- Scenario 3: Revert build (no env var, .public restored) ---" -ForegroundColor Yellow + +$env:MXC_TELEMETRY_CONFIG_OVERRIDE = $null +Remove-Item Env:\MXC_TELEMETRY_CONFIG_OVERRIDE -ErrorAction SilentlyContinue + +Push-Location $srcDir +try { + Write-Host "Building mxc_wil_telemetry (revert)..." + cargo build -p mxc_wil_telemetry 2>&1 | Out-Host + if ($LASTEXITCODE -ne 0) { + throw "Scenario 3 FAILED: revert build should succeed" + } + Write-Host "Scenario 3 PASSED: revert build succeeded" -ForegroundColor Green +} finally { + Pop-Location +} + +# --------------------------------------------------------------------------- +# Cleanup +# --------------------------------------------------------------------------- +if (-not $SkipClean) { + Remove-Item -Recurse -Force $dummyDir -ErrorAction SilentlyContinue +} + +Write-Host "`n=== All telemetry build override scenarios PASSED ===" -ForegroundColor Green diff --git a/tests/scripts/run_telemetry_etw_smoke_test.ps1 b/tests/scripts/run_telemetry_etw_smoke_test.ps1 new file mode 100644 index 00000000..c777274f --- /dev/null +++ b/tests/scripts/run_telemetry_etw_smoke_test.ps1 @@ -0,0 +1,174 @@ +<# +.SYNOPSIS + ETW capture smoke test for MXC telemetry. + +.DESCRIPTION + Starts an ETW trace session targeting the MXC public provider GUID, + runs wxc-exec with telemetry enabled, stops the session, and verifies + that at least one event was captured. + + This test uses the PUBLIC provider GUID (already in the open-source + code) — it does NOT depend on or reveal the private telemetry group GUID. + + Requires: Administrator privileges (for ETW session creation), + wxc-exec.exe built, logman.exe (ships with Windows). + + Run from the repo root. +#> + +[CmdletBinding()] +param( + [switch]$SkipClean +) + +$ErrorActionPreference = 'Stop' + +# MXC public provider GUID (from mxc_telemetry_shim.cpp — this is NOT private). +$providerGuid = '{4f50731a-89cf-4782-b3e0-dce8c90476ba}' +$sessionName = 'MxcTelemetryTest' +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + +Write-Host "=== MXC ETW Capture Smoke Test ===" -ForegroundColor Cyan + +# --------------------------------------------------------------------------- +# Pre-flight: elevation check +# --------------------------------------------------------------------------- +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = New-Object Security.Principal.WindowsPrincipal($identity) +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + Write-Host "SKIPPED: this test requires Administrator privileges for ETW session creation." -ForegroundColor Yellow + exit 0 +} + +# --------------------------------------------------------------------------- +# Pre-flight: locate wxc-exec.exe +# --------------------------------------------------------------------------- +$srcDir = Join-Path $repoRoot 'src' +$candidates = @( + (Join-Path $srcDir 'target\debug\wxc-exec.exe'), + (Join-Path $srcDir 'target\release\wxc-exec.exe'), + (Join-Path $srcDir 'target\x86_64-pc-windows-msvc\debug\wxc-exec.exe'), + (Join-Path $srcDir 'target\x86_64-pc-windows-msvc\release\wxc-exec.exe'), + (Join-Path $srcDir 'target\aarch64-pc-windows-msvc\debug\wxc-exec.exe'), + (Join-Path $srcDir 'target\aarch64-pc-windows-msvc\release\wxc-exec.exe') +) +$wxcExe = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $wxcExe) { + Write-Host "SKIPPED: wxc-exec.exe not found. Build first with build.bat." -ForegroundColor Yellow + exit 0 +} +Write-Host "Using wxc-exec: $wxcExe" + +# --------------------------------------------------------------------------- +# Pre-flight: locate telemetry example config +# --------------------------------------------------------------------------- +$configFile = Join-Path $repoRoot 'tests\examples\28_telemetry_enabled.json' +if (-not (Test-Path $configFile)) { + throw "Config not found: $configFile" +} + +# --------------------------------------------------------------------------- +# Setup: ETL output path +# --------------------------------------------------------------------------- +$etlDir = Join-Path $env:TEMP 'mxc_etw_test' +if (Test-Path $etlDir) { Remove-Item -Recurse -Force $etlDir } +New-Item -ItemType Directory -Path $etlDir -Force | Out-Null +$etlFile = Join-Path $etlDir 'mxc_trace.etl' + +# --------------------------------------------------------------------------- +# Step 1: Start ETW trace session +# --------------------------------------------------------------------------- +Write-Host "`n--- Starting ETW trace session '$sessionName' ---" -ForegroundColor Yellow + +# Remove any stale session from a previous interrupted run. +logman stop $sessionName -ets 2>$null | Out-Null +logman delete $sessionName -ets 2>$null | Out-Null + +logman create trace $sessionName -ets -o $etlFile -p $providerGuid 2>&1 | Out-Host +if ($LASTEXITCODE -ne 0) { + throw "Failed to create ETW trace session" +} +Write-Host "ETW session started, writing to $etlFile" + +# --------------------------------------------------------------------------- +# Step 2: Run wxc-exec with telemetry enabled +# --------------------------------------------------------------------------- +Write-Host "`n--- Running wxc-exec with telemetry ---" -ForegroundColor Yellow + +try { + # Run with --experimental to enable the telemetry section. + # The sandbox may fail (AppContainer prerequisites), but telemetry + # init/emit happens before execution — an error event should still fire. + $proc = Start-Process -FilePath $wxcExe ` + -ArgumentList "--debug", "--experimental", $configFile ` + -PassThru -NoNewWindow -Wait + Write-Host "wxc-exec exited with code $($proc.ExitCode)" +} catch { + Write-Host "wxc-exec failed to run: $_" -ForegroundColor Yellow + # Continue — even a crash after init may have emitted events. +} + +# Brief pause for ETW buffers to flush. +Start-Sleep -Seconds 2 + +# --------------------------------------------------------------------------- +# Step 3: Stop ETW trace session +# --------------------------------------------------------------------------- +Write-Host "`n--- Stopping ETW trace session ---" -ForegroundColor Yellow +logman stop $sessionName -ets 2>&1 | Out-Host + +# --------------------------------------------------------------------------- +# Step 4: Validate captured events +# --------------------------------------------------------------------------- +Write-Host "`n--- Validating captured events ---" -ForegroundColor Yellow + +if (-not (Test-Path $etlFile)) { + throw "ETL file not found: $etlFile" +} + +$etlSize = (Get-Item $etlFile).Length +Write-Host "ETL file size: $etlSize bytes" + +if ($etlSize -eq 0) { + Write-Host "WARNING: ETL file is empty — no events captured." -ForegroundColor Yellow + Write-Host "This may happen if the sandbox failed before telemetry init." -ForegroundColor Yellow + Write-Host "TEST INCONCLUSIVE (not a failure)." -ForegroundColor Yellow + exit 0 +} + +# Convert .etl to XML for inspection. +$xmlFile = Join-Path $etlDir 'mxc_trace.xml' +tracerpt $etlFile -o $xmlFile -of XML -y 2>&1 | Out-Host + +if (-not (Test-Path $xmlFile)) { + throw "tracerpt failed to produce XML output" +} + +$xmlContent = Get-Content -Path $xmlFile -Raw +$eventCount = ([regex]::Matches($xmlContent, '