From 1dab92b56bb9575fb5316c8133ae766977e7a360 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 9 Oct 2026 12:09:58 -0700 Subject: [PATCH 1/4] Add telemetry correlation headers to dev tunnel service requests Use tunnel services' injected telemetry sessions, record operation correlation, and generate fresh request IDs for management requests and relay handshakes. Pass host correlation into the CLI and preserve non-UUID session IDs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/CONTRIBUTING.md | 13 ++ cli/Cargo.lock | 2 +- cli/Cargo.toml | 2 +- cli/src/tunnels/dev_tunnels.rs | 130 +++++++++++++++++- package-lock.json | 43 +++--- package.json | 6 +- .../agentHost/node/tunnelAgentHostService.ts | 15 +- .../test/node/tunnelAgentHostService.test.ts | 43 +++++- .../common/tunnelServiceHeaders.ts | 34 +++++ .../remoteTunnel/node/remoteTunnelService.ts | 7 +- .../node/tunnelProcessCoordinator.ts | 23 +++- .../test/common/tunnelServiceHeaders.test.ts | 49 +++++++ .../test/node/remoteTunnelService.test.ts | 16 ++- .../node/tunnelProcessCoordinator.test.ts | 39 +++++- .../browser/browserTunnelAgentHostService.ts | 17 ++- .../browser/devTunnelsWebLoader.ts | 1 + .../webTunnelAgentHostService.contribution.ts | 3 + .../browserTunnelAgentHostService.test.ts | 11 +- 18 files changed, 396 insertions(+), 58 deletions(-) create mode 100644 src/vs/platform/remoteTunnel/common/tunnelServiceHeaders.ts create mode 100644 src/vs/platform/remoteTunnel/test/common/tunnelServiceHeaders.test.ts diff --git a/cli/CONTRIBUTING.md b/cli/CONTRIBUTING.md index d6b3696bcac33f..7915673af9157e 100644 --- a/cli/CONTRIBUTING.md +++ b/cli/CONTRIBUTING.md @@ -31,3 +31,16 @@ an interactive prompt. Without `--tunnel`, `code agent` and `code agent host` still start or reuse a local agent host supervisor. + +## Tunnel request correlation + +Management requests and relay WebSocket handshakes carry the +`X-Tunnels-VSCode-Session-Id`, `X-Tunnels-VSCode-Client-Operation-Id`, and +`X-Tunnels-VSCode-Client-Request-Id` headers. Each request has a fresh request ID. +VS Code passes its telemetry session and operation IDs through +`VSCODE_TUNNEL_SESSION_ID` and `VSCODE_TUNNEL_OPERATION_ID` when launching the CLI. +These IDs are preserved verbatim; telemetry session IDs are not necessarily UUIDs. +Standalone commands use a process-local session ID and a new operation ID for each +management client. Trace logging records both IDs for diagnostics. +Attaching to an already-running tunnel does not replace that process's correlation IDs; +its service requests retain the IDs of the original host process. diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 6891fd4823017c..1adb1908c98dd9 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -3344,7 +3344,7 @@ dependencies = [ [[package]] name = "tunnels" version = "0.1.0" -source = "git+https://github.com/microsoft/dev-tunnels?rev=64048c1409ff56cb958b879de7ea069ec71edc8b#64048c1409ff56cb958b879de7ea069ec71edc8b" +source = "git+https://github.com/microsoft/dev-tunnels?rev=b73b0eb774b86a5b519c300f2f41c9072a66f663#b73b0eb774b86a5b519c300f2f41c9072a66f663" dependencies = [ "async-trait", "futures-util", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 48c1fcb28694f2..f1b3e46118b9ac 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -33,7 +33,7 @@ jiff = { version = "0.2", default-features = false, features = ["std", "serde"] http = "1" gethostname = "0.4.3" libc = "0.2.144" -tunnels = { git = "https://github.com/microsoft/dev-tunnels", rev = "64048c1409ff56cb958b879de7ea069ec71edc8b", default-features = false, features = ["connections"] } +tunnels = { git = "https://github.com/microsoft/dev-tunnels", rev = "b73b0eb774b86a5b519c300f2f41c9072a66f663", default-features = false, features = ["connections"] } keyring = { version = "2.0.3", default-features = false, features = ["linux-secret-service-rt-tokio-crypto-openssl", "platform-windows", "platform-macos", "linux-keyutils"] } dialoguer = "0.10.4" hyper = { version = "1", features = ["server", "http1", "client"] } diff --git a/cli/src/tunnels/dev_tunnels.rs b/cli/src/tunnels/dev_tunnels.rs index 6c039124266fbc..9bf4e7f2a30441 100644 --- a/cli/src/tunnels/dev_tunnels.rs +++ b/cli/src/tunnels/dev_tunnels.rs @@ -14,6 +14,7 @@ use crate::util::errors::{ use crate::util::input::prompt_placeholder; use futures::future::BoxFuture; use futures::{FutureExt, TryFutureExt}; +use http::header::{HeaderName, HeaderValue}; use http::StatusCode; use rand::prelude::IteratorRandom; use regex::Regex; @@ -30,12 +31,80 @@ use tunnels::contracts::{ TUNNEL_ACCESS_SCOPES_CONNECT, TUNNEL_PROTOCOL_AUTO, }; use tunnels::management::{ - new_tunnel_management, HttpError, TunnelLocator, TunnelManagementClient, TunnelRequestOptions, - NO_REQUEST_OPTIONS, + new_tunnel_management, HttpError, TunnelClientBuilder, TunnelLocator, TunnelManagementClient, + TunnelRequestOptions, NO_REQUEST_OPTIONS, }; static TUNNEL_COUNT_LIMIT_NAME: &str = "TunnelsPerUserPerLocation"; static TUNNEL_PORT_PROTOCOL_CONFLICT_DETAIL: &str = "The tunnel port protocol cannot be changed."; +static TUNNEL_SESSION_ID: LazyLock = LazyLock::new(uuid::Uuid::new_v4); + +fn tunnel_correlation_id( + log: &log::Logger, + name: &str, + value: Result, + fallback: uuid::Uuid, +) -> HeaderValue { + match value { + Ok(value) if !value.is_empty() => match HeaderValue::from_str(&value) { + Ok(id) => id, + Err(error) => { + warning!(log, "Invalid {}: {}", name, error); + HeaderValue::from_str(&fallback.to_string()).unwrap() + } + }, + Ok(_) => { + warning!(log, "Invalid {}: empty correlation ID", name); + HeaderValue::from_str(&fallback.to_string()).unwrap() + } + Err(std::env::VarError::NotPresent) => { + HeaderValue::from_str(&fallback.to_string()).unwrap() + } + Err(error) => { + warning!(log, "Invalid {}: {}", name, error); + HeaderValue::from_str(&fallback.to_string()).unwrap() + } + } +} + +fn tunnel_management_client(log: &log::Logger) -> TunnelClientBuilder { + let session_id = tunnel_correlation_id( + log, + "VSCODE_TUNNEL_SESSION_ID", + std::env::var("VSCODE_TUNNEL_SESSION_ID"), + *TUNNEL_SESSION_ID, + ); + let operation_id = tunnel_correlation_id( + log, + "VSCODE_TUNNEL_OPERATION_ID", + std::env::var("VSCODE_TUNNEL_OPERATION_ID"), + uuid::Uuid::new_v4(), + ); + trace!( + log, + "Tunnel service correlation: session={:?}, operation={:?}", + session_id, + operation_id + ); + let mut client = new_tunnel_management(&TUNNEL_SERVICE_USER_AGENT); + client.additional_headers(vec![ + ( + HeaderName::from_static("x-tunnels-vscode-session-id"), + session_id, + ), + ( + HeaderName::from_static("x-tunnels-vscode-client-operation-id"), + operation_id, + ), + ]); + client.additional_headers_provider(|| { + vec![( + HeaderName::from_static("x-tunnels-vscode-client-request-id"), + HeaderValue::from_str(&uuid::Uuid::new_v4().to_string()).unwrap(), + )] + }); + client +} #[allow(dead_code)] mod tunnel_flags { @@ -329,7 +398,7 @@ impl DevTunnels { auth: auth::Auth, paths: &LauncherPaths, ) -> DevTunnels { - let mut client = new_tunnel_management(&TUNNEL_SERVICE_USER_AGENT); + let mut client = tunnel_management_client(log); client.authorization_provider(auth.clone()); DevTunnels { @@ -347,7 +416,7 @@ impl DevTunnels { auth: auth::Auth, paths: &LauncherPaths, ) -> DevTunnels { - let mut client = new_tunnel_management(&TUNNEL_SERVICE_USER_AGENT); + let mut client = tunnel_management_client(log); client.authorization_provider(auth.clone()); DevTunnels { @@ -1328,6 +1397,59 @@ fn tunnel_has_host_connection(tunnel: &Tunnel) -> bool { mod test { use super::*; + #[test] + fn test_tunnel_correlation_preserves_telemetry_session_id() { + let session_id = "7c86e247-3438-4f26-959d-061b3299f4de1791567000000"; + assert_eq!( + tunnel_correlation_id( + &log::Logger::test(), + "VSCODE_TUNNEL_SESSION_ID", + Ok(session_id.to_string()), + uuid::Uuid::new_v4(), + ), + HeaderValue::from_static(session_id), + ); + } + + #[test] + fn test_tunnel_correlation_rejects_invalid_header_values() { + let fallback = uuid::Uuid::new_v4(); + for value in ["", "session\r\ninjected-header: value"] { + assert_eq!( + tunnel_correlation_id( + &log::Logger::test(), + "VSCODE_TUNNEL_SESSION_ID", + Ok(value.to_string()), + fallback, + ), + HeaderValue::from_str(&fallback.to_string()).unwrap(), + ); + } + } + + #[test] + fn test_tunnel_request_correlation() { + let client: TunnelManagementClient = tunnel_management_client(&log::Logger::test()).into(); + let first = client.request_headers(); + let second = client.request_headers(); + assert_eq!( + first + .iter() + .map(|(name, _)| name.as_str()) + .collect::>(), + vec![ + "x-tunnels-vscode-session-id", + "x-tunnels-vscode-client-operation-id", + "x-tunnels-vscode-client-request-id", + ] + ); + for (_, value) in &first { + assert!(uuid::Uuid::parse_str(value.to_str().unwrap()).is_ok()); + } + assert_eq!(&first[..2], &second[..2]); + assert_ne!(first[2].1, second[2].1); + } + #[test] fn test_clean_hostname_for_tunnel() { assert_eq!( diff --git a/package-lock.json b/package-lock.json index e3ac3babd978b1..5299353e945c27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,9 +16,9 @@ "@hpke/core": "^1.9.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@microsoft/dev-tunnels-connections": "^1.3.41", - "@microsoft/dev-tunnels-contracts": "^1.3.41", - "@microsoft/dev-tunnels-management": "^1.3.41", + "@microsoft/dev-tunnels-connections": "^1.3.61", + "@microsoft/dev-tunnels-contracts": "^1.3.61", + "@microsoft/dev-tunnels-management": "^1.3.61", "@microsoft/dev-tunnels-ssh": "^3.12.22", "@microsoft/dev-tunnels-ssh-tcp": "^3.12.22", "@parcel/watcher": "^2.5.6", @@ -2205,13 +2205,13 @@ "integrity": "sha512-PoHEgsnmcqruLNHZ/amACqdJ6YYQpED0KSRe6J7gIJTtpZC1FfFU9b1fmDKDKtFoUSrPzEh1qzO3kmRZP0betg==" }, "node_modules/@microsoft/dev-tunnels-connections": { - "version": "1.3.60", - "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-connections/-/dev-tunnels-connections-1.3.60.tgz", - "integrity": "sha512-UrFueOBz856tJix0b68weAkea65cLaG5IbDlQtTet163TcdJwTGqgdAv/kDcMn3xAnSB51FJh4Wk+50cs9SBEw==", + "version": "1.3.61", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-connections/-/dev-tunnels-connections-1.3.61.tgz", + "integrity": "sha512-Y1/K9IciafblRMS+XDY8uXoA/12bAxF/40Fiw2hd4QF1fi0Jlze4s0UARP/bHppJ4cqD5tqiV97StRJ+xnGvjw==", "license": "MIT", "dependencies": { - "@microsoft/dev-tunnels-contracts": "1.3.60", - "@microsoft/dev-tunnels-management": "1.3.60", + "@microsoft/dev-tunnels-contracts": "1.3.61", + "@microsoft/dev-tunnels-management": "1.3.61", "await-semaphore": "^0.1.3", "buffer": "^5.2.1", "debug": "^4.1.1", @@ -2235,9 +2235,9 @@ } }, "node_modules/@microsoft/dev-tunnels-contracts": { - "version": "1.3.60", - "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-contracts/-/dev-tunnels-contracts-1.3.60.tgz", - "integrity": "sha512-ElmrEzJHk1NrmFq7IAIH19hbZXBeTsLzh6NGf8AkFPeJCL2M8lYh6y3WiaxJKIVJOF/Qct/Avx4I24FFCKWBWw==", + "version": "1.3.61", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-contracts/-/dev-tunnels-contracts-1.3.61.tgz", + "integrity": "sha512-WAqWIl9TG9jrZQnWdPsWHoG8m4C/E4xkDqiiRPilvdr+W0oNVZwnxteio0KDcysPSFOk/Iyu0KpMakUkvFdeDA==", "license": "MIT", "dependencies": { "buffer": "^5.2.1", @@ -2255,12 +2255,12 @@ } }, "node_modules/@microsoft/dev-tunnels-management": { - "version": "1.3.60", - "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-management/-/dev-tunnels-management-1.3.60.tgz", - "integrity": "sha512-KDX72VyTB1Nf0fNFD53EqkJe3wmsFyvq1QmVlSsZP4UKWTOPcXZmA2jBuRTE/9JC1B50tgy7fLTpwR7aMtFuwQ==", + "version": "1.3.61", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-management/-/dev-tunnels-management-1.3.61.tgz", + "integrity": "sha512-Yy3f1SBxsUo4fnTiUIFEmvKoWqP4a8Wqbnvr5oec/R+KdZdINQJHVWEUIYoWg0NHbX074om16O3iamldWnBV4g==", "license": "MIT", "dependencies": { - "@microsoft/dev-tunnels-contracts": "1.3.60", + "@microsoft/dev-tunnels-contracts": "1.3.61", "axios": "^1.8.4", "buffer": "^5.2.1", "debug": "^4.1.1", @@ -6176,7 +6176,8 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k= sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" }, "node_modules/atob": { "version": "2.1.2", @@ -7379,6 +7380,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" }, @@ -7389,7 +7391,8 @@ "node_modules/combined-stream/node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk= sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", "engines": { "node": ">=0.4.0" } @@ -10066,9 +10069,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", "funding": [ { "type": "individual", diff --git a/package.json b/package.json index 72bca2f2669f84..12e8a7fcfa704f 100644 --- a/package.json +++ b/package.json @@ -106,9 +106,9 @@ "@hpke/core": "^1.9.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@microsoft/dev-tunnels-connections": "^1.3.41", - "@microsoft/dev-tunnels-contracts": "^1.3.41", - "@microsoft/dev-tunnels-management": "^1.3.41", + "@microsoft/dev-tunnels-connections": "^1.3.61", + "@microsoft/dev-tunnels-contracts": "^1.3.61", + "@microsoft/dev-tunnels-management": "^1.3.61", "@microsoft/dev-tunnels-ssh": "^3.12.22", "@microsoft/dev-tunnels-ssh-tcp": "^3.12.22", "@parcel/watcher": "^2.5.6", diff --git a/src/vs/platform/agentHost/node/tunnelAgentHostService.ts b/src/vs/platform/agentHost/node/tunnelAgentHostService.ts index 364d943f1c0859..56432ba82ca596 100644 --- a/src/vs/platform/agentHost/node/tunnelAgentHostService.ts +++ b/src/vs/platform/agentHost/node/tunnelAgentHostService.ts @@ -9,6 +9,8 @@ import type WebSocket from 'ws'; import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; import { ILogService } from '../../log/common/log.js'; +import { ITelemetryService } from '../../telemetry/common/telemetry.js'; +import { createTunnelServiceCorrelation, tunnelServiceHeaders } from '../../remoteTunnel/common/tunnelServiceHeaders.js'; import { PendingGatewaySelection, TunnelAgentHostConnector, @@ -185,10 +187,11 @@ export class TunnelAgentHostMainService extends Disposable implements ITunnelAge constructor( @ILogService private readonly _logService: ILogService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); this._connector = this._register(new TunnelAgentHostConnector( - new NodeTunnelRelayClientFactory((token, authProvider) => this._createManagementClient(token, authProvider)), + new NodeTunnelRelayClientFactory((token, authProvider) => this._createManagementClient(token, authProvider, 'connect')), new NodeTunnelSocketFactory(), this._logService, )); @@ -198,7 +201,7 @@ export class TunnelAgentHostMainService extends Disposable implements ITunnelAge } async listTunnels(token: string, authProvider: 'github' | 'microsoft', additionalTunnelNames?: string[]): Promise { - const client = await this._createManagementClient(token, authProvider); + const client = await this._createManagementClient(token, authProvider, 'list'); const results: ITunnelInfo[] = []; const seen = new Set(); @@ -248,7 +251,7 @@ export class TunnelAgentHostMainService extends Disposable implements ITunnelAge } async deleteTunnel(token: string, authProvider: 'github' | 'microsoft', tunnelId: string, clusterId: string): Promise { - const client = await this._createManagementClient(token, authProvider); + const client = await this._createManagementClient(token, authProvider, 'delete'); this._logService.info(`${LOG_PREFIX} Deleting tunnel ${tunnelId} in cluster ${clusterId}...`); await client.deleteTunnel({ tunnelId, clusterId }); this._connector.closeTunnelConnections(tunnelId, 'deleting'); @@ -279,14 +282,16 @@ export class TunnelAgentHostMainService extends Disposable implements ITunnelAge return this._connector.disconnect(connectionId); } - private async _createManagementClient(token: string, authProvider: 'github' | 'microsoft'): Promise { + private async _createManagementClient(token: string, authProvider: 'github' | 'microsoft', operation: 'list' | 'connect' | 'delete'): Promise { const management = await import('@microsoft/dev-tunnels-management'); const authHeader = authProvider === 'github' ? `github ${token}` : `Bearer ${token}`; - return new management.TunnelManagementHttpClient( + const client = new management.TunnelManagementHttpClient( 'vscode-sessions', management.ManagementApiVersions.Version20230927preview, async () => authHeader, ); + client.additionalRequestHeaders = tunnelServiceHeaders(createTunnelServiceCorrelation(this._telemetryService, operation)); + return client; } } diff --git a/src/vs/platform/agentHost/test/node/tunnelAgentHostService.test.ts b/src/vs/platform/agentHost/test/node/tunnelAgentHostService.test.ts index a409d81e53660c..4b3f067c8156e0 100644 --- a/src/vs/platform/agentHost/test/node/tunnelAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/node/tunnelAgentHostService.test.ts @@ -4,11 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import sinon from 'sinon'; +import { TunnelManagementHttpClient } from '@microsoft/dev-tunnels-management'; import * as net from 'net'; import { DeferredPromise, timeout } from '../../../../base/common/async.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { toDisposable } from '../../../../base/common/lifecycle.js'; import { NullLogService } from '../../../log/common/log.js'; +import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { isTunnelGatewaySelectionRejectedError, TUNNEL_GATEWAY_SELECTION_REJECTED_ERROR_NAME } from '../../common/tunnelAgentHost.js'; @@ -80,6 +83,34 @@ class FakeRelayClient implements ITunnelRelayClient { } } +suite('TunnelAgentHostService - correlation', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + teardown(() => sinon.restore()); + + test('uses the injected telemetry session and retains the operation across enumeration requests', async () => { + const requests: Record[] = []; + sinon.stub(TunnelManagementHttpClient.prototype, 'listTunnels').callsFake(async function captureHeaders(this: TunnelManagementHttpClient) { + requests.push({ ...this.additionalRequestHeaders }); + return []; + }); + const telemetry = sinon.spy(NullTelemetryService, 'publicLog2'); + const service = store.add(new TunnelAgentHostMainService(new NullLogService(), NullTelemetryService)); + await service.listTunnels('token', 'github', ['additional-tunnel']); + const operationId = requests[0]['X-Tunnels-VSCode-Client-Operation-Id']; + assert.deepStrictEqual({ + telemetry: telemetry.firstCall.args, + sessions: requests.map(request => request['X-Tunnels-VSCode-Session-Id']), + operations: requests.map(request => request['X-Tunnels-VSCode-Client-Operation-Id']), + uniqueRequests: new Set(requests.map(request => request['X-Tunnels-VSCode-Client-Request-Id'])).size, + }, { + telemetry: ['tunnelServiceOperation', { sessionId: NullTelemetryService.sessionId, operationId, operation: 'list' }], + sessions: [NullTelemetryService.sessionId, NullTelemetryService.sessionId], + operations: [operationId, operationId], + uniqueRequests: 2, + }); + }); +}); + suite('TunnelAgentHostService - withTimeout', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -143,7 +174,7 @@ suite('TunnelAgentHostService - gateway selection', () => { ensureNoDisposablesAreLeakedInTestSuite(); test('completeSelection sends the selection immediately, then resolves once the gateway acknowledges', async () => { - const service = new TunnelAgentHostMainService(new NullLogService()); + const service = new TunnelAgentHostMainService(new NullLogService(), NullTelemetryService); try { const { ws, pending } = createPending(); setPendingGatewaySelectionForTests(service, 'sel1', pending); @@ -182,7 +213,7 @@ suite('TunnelAgentHostService - gateway selection', () => { }); test('completeSelection throws for an unknown selection id', async () => { - const service = new TunnelAgentHostMainService(new NullLogService()); + const service = new TunnelAgentHostMainService(new NullLogService(), NullTelemetryService); try { await assert.rejects( () => service.completeSelection('does-not-exist', { instanceId: 'x' }), @@ -194,7 +225,7 @@ suite('TunnelAgentHostService - gateway selection', () => { }); test('completeSelection surfaces a gateway rejection and closes pending resources without switching targets', async () => { - const service = new TunnelAgentHostMainService(new NullLogService()); + const service = new TunnelAgentHostMainService(new NullLogService(), NullTelemetryService); try { const { ws, relayClient, pending } = createPending(); setPendingGatewaySelectionForTests(service, 'sel1', pending); @@ -222,7 +253,7 @@ suite('TunnelAgentHostService - gateway selection', () => { }); test('completeSelection reports a transport failure as a plain error, never as a gateway rejection', async () => { - const service = new TunnelAgentHostMainService(new NullLogService()); + const service = new TunnelAgentHostMainService(new NullLogService(), NullTelemetryService); try { const { ws, pending } = createPending(); setPendingGatewaySelectionForTests(service, 'sel1', pending); @@ -238,7 +269,7 @@ suite('TunnelAgentHostService - gateway selection', () => { }); test('cancelSelection disposes the pending socket and relay client, and is safe to call again or with an unknown id', async () => { - const service = new TunnelAgentHostMainService(new NullLogService()); + const service = new TunnelAgentHostMainService(new NullLogService(), NullTelemetryService); try { const { ws, relayClient, pending } = createPending(); setPendingGatewaySelectionForTests(service, 'sel1', pending); @@ -258,7 +289,7 @@ suite('TunnelAgentHostService - gateway selection', () => { }); test('completeSelection fails once the pending socket has already closed unexpectedly', async () => { - const service = new TunnelAgentHostMainService(new NullLogService()); + const service = new TunnelAgentHostMainService(new NullLogService(), NullTelemetryService); try { // Wire the unexpected-close callback the same way prepareSelection // does in production: remove the entry from the pending map. diff --git a/src/vs/platform/remoteTunnel/common/tunnelServiceHeaders.ts b/src/vs/platform/remoteTunnel/common/tunnelServiceHeaders.ts new file mode 100644 index 00000000000000..f7281404f1b0ad --- /dev/null +++ b/src/vs/platform/remoteTunnel/common/tunnelServiceHeaders.ts @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { generateUuid } from '../../../base/common/uuid.js'; +import { ITelemetryService } from '../../telemetry/common/telemetry.js'; + +export interface ITunnelServiceCorrelation { + readonly sessionId: string; + readonly operationId: string; +} + +export function createTunnelServiceCorrelation(telemetryService: ITelemetryService, operation: 'list' | 'connect' | 'delete' | 'host'): ITunnelServiceCorrelation { + const correlation = { sessionId: telemetryService.sessionId, operationId: generateUuid() }; + type TunnelServiceOperationClassification = { + owner: 'connor4312'; + comment: 'Correlates a tunnel service operation with management and relay requests.'; + sessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The originating VS Code telemetry session ID.' }; + operationId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'A random ID shared by requests belonging to this tunnel operation.' }; + operation: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The kind of tunnel operation.' }; + }; + telemetryService.publicLog2('tunnelServiceOperation', { ...correlation, operation }); + return correlation; +} + +/** The SDK copies these headers separately for each management request and Node.js relay handshake. */ +export function tunnelServiceHeaders(correlation: ITunnelServiceCorrelation): Record { + return { + 'X-Tunnels-VSCode-Session-Id': correlation.sessionId, + 'X-Tunnels-VSCode-Client-Operation-Id': correlation.operationId, + get 'X-Tunnels-VSCode-Client-Request-Id'() { return generateUuid(); }, + }; +} diff --git a/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts b/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts index 53a88fbd770754..a31f1dc2f8b536 100644 --- a/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts +++ b/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts @@ -37,12 +37,16 @@ type RemoteTunnelConnectedClassification = { owner: 'aeschli'; comment: 'Reporting when a Remote Tunnel connection is established'; tunnelName: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the connected tunnel' }; - isAttached: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the connection is attached to an existing tunnel process' }; + isAttached: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the connection is attached to an existing tunnel process' }; + sessionId?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The VS Code telemetry session ID supplied to the CLI.' }; + operationId?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The operation ID supplied to the CLI for tunnel service requests.' }; }; type RemoteTunnelConnectedEvent = { tunnelName: string; isAttached: boolean; + sessionId?: string; + operationId?: string; }; const restartTunnelOnConfigurationChanges: readonly string[] = [ @@ -231,6 +235,7 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ this.telemetryService.publicLog2('remoteTunnel.connected', { tunnelName: info.tunnelName, isAttached: info.isAttached, + ...event.correlation, }); this.setTunnelStatus(TunnelStates.connected(info, this.tunnelProcessCoordinator.getStatus().serviceInstallFailed)); } else if (event.status.type === 'tokenError') { diff --git a/src/vs/platform/remoteTunnel/node/tunnelProcessCoordinator.ts b/src/vs/platform/remoteTunnel/node/tunnelProcessCoordinator.ts index 7eec16ff5d2113..87a86dfaa27812 100644 --- a/src/vs/platform/remoteTunnel/node/tunnelProcessCoordinator.ts +++ b/src/vs/platform/remoteTunnel/node/tunnelProcessCoordinator.ts @@ -14,6 +14,8 @@ import { CONFIGURATION_KEY_HOST_NAME, CONFIGURATION_KEY_PREVENT_SLEEP, normalize import { parseTunnelMachineStatus, TunnelMachineStatus } from '../common/tunnelMachineStatus.js'; import { CodeTunnelCli, CodeTunnelCliOutput, ICodeTunnelCliRun } from './codeTunnelCliProcess.js'; import { hostname } from 'os'; +import { ITelemetryService } from '../../telemetry/common/telemetry.js'; +import { createTunnelServiceCorrelation, ITunnelServiceCorrelation } from '../common/tunnelServiceHeaders.js'; type TunnelCliFactory = (onLog: (message: string) => void) => CodeTunnelCli; @@ -59,6 +61,7 @@ export interface ITunnelProcessOutput { export interface ITunnelProcessMachineStatus { readonly mode: TunnelProcessMode; readonly status: TunnelMachineStatus; + readonly correlation: ITunnelServiceCorrelation | undefined; cancel(): void; } @@ -132,6 +135,7 @@ export class TunnelProcessCoordinator extends Disposable implements ITunnelProce @IConfigurationService private readonly configurationService: IConfigurationService, @INativeEnvironmentService private readonly environmentService: INativeEnvironmentService, @IProductService productService: IProductService, + @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super(); this._tunnelCli = tunnelCliFactory?.(() => { }) ?? new CodeTunnelCli({ @@ -340,7 +344,11 @@ export class TunnelProcessCoordinator extends Disposable implements ITunnelProce } private _startTunnel(args: readonly string[], mode: TunnelProcessMode, generation: number): void { - const tunnelRun = this._tunnelCli.run('tunnel', args, (message, isError) => this._fireOutput(mode, message, isError, true, () => tunnelRun.result.cancel(), generation), { VSCODE_CLI_MACHINE_STATUS: '1' }); + const correlation = createTunnelServiceCorrelation(this.telemetryService, 'host'); + const tunnelRun = this._tunnelCli.run('tunnel', args, (message, isError) => this._fireOutput(mode, message, isError, true, () => tunnelRun.result.cancel(), generation, correlation), { + VSCODE_CLI_MACHINE_STATUS: '1', + ...this._correlationEnvironment(correlation), + }); this._currentProcess = tunnelRun; const onSettled = () => { if (this._currentProcess === tunnelRun) { @@ -355,10 +363,11 @@ export class TunnelProcessCoordinator extends Disposable implements ITunnelProce } private async _runTransient(logLabel: string, args: readonly string[], mode: TunnelProcessMode, generation: number, env?: Record, onOutput?: CodeTunnelCliOutput): Promise { + const correlation = createTunnelServiceCorrelation(this.telemetryService, 'host'); const run = this._tunnelCli.run(logLabel, args, (message, isError) => { onOutput?.(message, isError); - this._fireOutput(mode, message, isError, false, () => run.result.cancel()); - }, env); + this._fireOutput(mode, message, isError, false, () => run.result.cancel(), generation, correlation); + }, { ...env, ...this._correlationEnvironment(correlation) }); this._currentProcess = run; try { return await run.result; @@ -371,6 +380,10 @@ export class TunnelProcessCoordinator extends Disposable implements ITunnelProce } } + private _correlationEnvironment(correlation: ITunnelServiceCorrelation): Record { + return { VSCODE_TUNNEL_SESSION_ID: correlation.sessionId, VSCODE_TUNNEL_OPERATION_ID: correlation.operationId }; + } + private async _stopCurrentProcess(): Promise { const run = this._currentProcess; if (!run) { @@ -383,7 +396,7 @@ export class TunnelProcessCoordinator extends Disposable implements ITunnelProce } } - private _fireOutput(mode: TunnelProcessMode, message: string, isError: boolean, isTunnelProcess: boolean, cancel: () => void, generation?: number): void { + private _fireOutput(mode: TunnelProcessMode, message: string, isError: boolean, isTunnelProcess: boolean, cancel: () => void, generation: number, correlation: ITunnelServiceCorrelation): void { this._onDidOutput.fire({ mode, message, isError }); if (!isError && isTunnelProcess && generation === this._generation) { const status = parseTunnelMachineStatus(message); @@ -391,7 +404,7 @@ export class TunnelProcessCoordinator extends Disposable implements ITunnelProce if (status.type === 'connected' && this._status.mode === mode) { this._setStatus({ ...this._status, tunnelId: status.tunnelId, connectionState: 'connected' }); } - this._onDidMachineStatus.fire({ mode, status, cancel }); + this._onDidMachineStatus.fire({ mode, status, cancel, correlation: status.type === 'connected' && status.isAttached ? undefined : correlation }); } } } diff --git a/src/vs/platform/remoteTunnel/test/common/tunnelServiceHeaders.test.ts b/src/vs/platform/remoteTunnel/test/common/tunnelServiceHeaders.test.ts new file mode 100644 index 00000000000000..189d61686300f7 --- /dev/null +++ b/src/vs/platform/remoteTunnel/test/common/tunnelServiceHeaders.test.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { isUUID } from '../../../../base/common/uuid.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { ITelemetryData } from '../../../telemetry/common/telemetry.js'; +import { NullTelemetryServiceShape } from '../../../telemetry/common/telemetryUtils.js'; +import { createTunnelServiceCorrelation, tunnelServiceHeaders } from '../../common/tunnelServiceHeaders.js'; + +suite('Tunnel service headers', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('correlates service headers with telemetry and refreshes the request ID on every copy', () => { + const events: { name: string | undefined; data: ITelemetryData | undefined }[] = []; + const telemetry = new class extends NullTelemetryServiceShape { + override publicLog2(name?: string, data?: ITelemetryData): void { + events.push({ name, data }); + } + }(); + const correlation = createTunnelServiceCorrelation(telemetry, 'connect'); + const headers = tunnelServiceHeaders(correlation); + const first = { ...headers }; + const second = { ...headers }; + const next = createTunnelServiceCorrelation(telemetry, 'list'); + assert.deepStrictEqual({ + events, + session: first['X-Tunnels-VSCode-Session-Id'], + operation: first['X-Tunnels-VSCode-Client-Operation-Id'], + stableOperation: first['X-Tunnels-VSCode-Client-Operation-Id'] === second['X-Tunnels-VSCode-Client-Operation-Id'], + freshRequests: first['X-Tunnels-VSCode-Client-Request-Id'] !== second['X-Tunnels-VSCode-Client-Request-Id'], + validRequestIds: [first, second].every(request => isUUID(request['X-Tunnels-VSCode-Client-Request-Id'])), + differentOperations: correlation.operationId !== next.operationId, + }, { + events: [ + { name: 'tunnelServiceOperation', data: { ...correlation, operation: 'connect' } }, + { name: 'tunnelServiceOperation', data: { ...next, operation: 'list' } }, + ], + session: telemetry.sessionId, + operation: correlation.operationId, + stableOperation: true, + freshRequests: true, + validRequestIds: true, + differentOperations: true, + }); + }); +}); diff --git a/src/vs/platform/remoteTunnel/test/node/remoteTunnelService.test.ts b/src/vs/platform/remoteTunnel/test/node/remoteTunnelService.test.ts index 090ae5a64f7f10..c9b65d903e55cd 100644 --- a/src/vs/platform/remoteTunnel/test/node/remoteTunnelService.test.ts +++ b/src/vs/platform/remoteTunnel/test/node/remoteTunnelService.test.ts @@ -15,8 +15,10 @@ import { LogLevel, NullLoggerService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; import { InMemoryStorageService } from '../../../storage/common/storage.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; +import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { ActiveTunnelMode, TunnelMode, TunnelStatus } from '../../common/remoteTunnel.js'; import { TunnelMachineStatus } from '../../common/tunnelMachineStatus.js'; +import { ITunnelServiceCorrelation } from '../../common/tunnelServiceHeaders.js'; import { RemoteTunnelService } from '../../node/remoteTunnelService.js'; import { ITunnelProcessCoordinator, ITunnelProcessMachineStatus, ITunnelProcessOutput, ITunnelProcessStatus, TunnelProcessConnectionState, TunnelProcessMode } from '../../node/tunnelProcessCoordinator.js'; import sinon from 'sinon'; @@ -62,8 +64,8 @@ class TestTunnelProcessCoordinator implements ITunnelProcessCoordinator { this._onDidChangeStatus.fire(this._status); } - fireMachineStatus(mode: TunnelProcessMode, status: TunnelMachineStatus, cancel = () => { }): void { - this._onDidMachineStatus.fire({ mode, status, cancel }); + fireMachineStatus(mode: TunnelProcessMode, status: TunnelMachineStatus, cancel = () => { }, correlation?: ITunnelServiceCorrelation): void { + this._onDidMachineStatus.fire({ mode, status, cancel, correlation }); } dispose(): void { @@ -80,7 +82,8 @@ suite('Remote tunnel', () => { const coordinator = new TestTunnelProcessCoordinator(); const loggerService = new NullLoggerService(); const storageService = new InMemoryStorageService(); - const publicLog2 = sinon.spy(NullTelemetryService, 'publicLog2'); + const telemetryService: ITelemetryService = NullTelemetryService; + const publicLog2 = sinon.spy(telemetryService, 'publicLog2'); const service = new RemoteTunnelService( NullTelemetryService, { tunnelApplicationName: 'code-tunnel' } as IProductService, @@ -106,7 +109,7 @@ suite('Remote tunnel', () => { let didCancel = false; coordinator.fireMachineStatus('remoteAccess', { type: 'connected', tunnelName: 'test_host', tunnelId: 'tunnel-id', isAttached: true, link: 'https://vscode.dev/tunnel/test_host', domain: 'vscode.dev' }); const linkedStatus = await service.getTunnelStatus(); - coordinator.fireMachineStatus('remoteAccess', { type: 'connected', tunnelName: 'test_host', isAttached: false }); + coordinator.fireMachineStatus('remoteAccess', { type: 'connected', tunnelName: 'test_host', isAttached: false }, undefined, { sessionId: 'telemetry-session', operationId: 'operation-id' }); const noLinkStatus = await service.getTunnelStatus(); coordinator.fireMachineStatus('remoteAccess', { type: 'tokenError', message: 'token expired' }, () => didCancel = true); const disconnectedStatus = await service.getTunnelStatus(); @@ -119,7 +122,12 @@ suite('Remote tunnel', () => { tokenFailures, didCancel, telemetryCallCount: publicLog2.callCount, + connectedTelemetry: publicLog2.getCalls().filter(call => call.args[0] === 'remoteTunnel.connected').map(call => call.args[1]), }, { + connectedTelemetry: [ + { tunnelName: 'test_host', isAttached: true }, + { tunnelName: 'test_host', isAttached: false, sessionId: 'telemetry-session', operationId: 'operation-id' }, + ], linkedStatus: { type: 'connected', info: { diff --git a/src/vs/platform/remoteTunnel/test/node/tunnelProcessCoordinator.test.ts b/src/vs/platform/remoteTunnel/test/node/tunnelProcessCoordinator.test.ts index 563d0c5facb9f3..b6658ca124ea73 100644 --- a/src/vs/platform/remoteTunnel/test/node/tunnelProcessCoordinator.test.ts +++ b/src/vs/platform/remoteTunnel/test/node/tunnelProcessCoordinator.test.ts @@ -13,8 +13,9 @@ import { INativeEnvironmentService } from '../../../environment/common/environme import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; import { ActiveTunnelMode, INACTIVE_TUNNEL_MODE } from '../../common/remoteTunnel.js'; import { CodeTunnelCli, CodeTunnelSpawn } from '../../node/codeTunnelCliProcess.js'; -import { resolveTunnelProcessMode, TunnelProcessCoordinator } from '../../node/tunnelProcessCoordinator.js'; +import { ITunnelProcessMachineStatus, resolveTunnelProcessMode, TunnelProcessCoordinator } from '../../node/tunnelProcessCoordinator.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; interface TestChildProcess { readonly child: ChildProcess; @@ -88,6 +89,7 @@ function createCoordinator(exitOnKill = true, ordering?: string[], installExitCo new TestConfigurationService({ 'remote.tunnels.access.hostNameOverride': 'Test_Host' }), environmentService, { tunnelApplicationName: 'code-tunnel' } as IProductService, + NullTelemetryService, ); return { coordinator, processes }; } @@ -103,6 +105,41 @@ suite('TunnelProcessCoordinator', () => { ], ['none', 'remoteAccess', 'service']); }); + test('passes telemetry correlation to every CLI invocation', async () => { + const { coordinator, processes } = createCoordinator(); + const statuses: ITunnelProcessMachineStatus[] = []; + const listener = coordinator.onDidMachineStatus(status => statuses.push(status)); + try { + await coordinator.setRemoteAccess(activeMode(), LogLevel.Info); + const tunnel = processes.find(process => process.args.includes('--accept-server-license-terms'))!; + tunnel.stdout.write('__VSCODE_CLI_STATUS__{"type":"connected","tunnelName":"test_host","isAttached":false}\n'); + tunnel.stdout.write('__VSCODE_CLI_STATUS__{"type":"connected","tunnelName":"test_host","isAttached":true}\n'); + assert.deepStrictEqual({ + sessionIds: [...new Set(processes.map(process => process.env?.VSCODE_TUNNEL_SESSION_ID))], + uniqueOperations: new Set(processes.map(process => process.env?.VSCODE_TUNNEL_OPERATION_ID)).size, + processCount: processes.length, + hasOperations: processes.every(process => !!process.env?.VSCODE_TUNNEL_OPERATION_ID), + correlation: statuses.map(status => status.correlation), + }, { + sessionIds: [NullTelemetryService.sessionId], + uniqueOperations: processes.length, + processCount: processes.length, + hasOperations: true, + correlation: [ + { sessionId: tunnel.env?.VSCODE_TUNNEL_SESSION_ID, operationId: tunnel.env?.VSCODE_TUNNEL_OPERATION_ID }, + undefined, + ], + }); + } finally { + listener.dispose(); + for (const process of processes) { + process.emitExit(); + } + await new Promise(resolve => setImmediate(resolve)); + coordinator.dispose(); + } + }); + test('stops the tunnel instead of resuming a narrower mode when Remote Tunnel Access is disabled', async () => { const { coordinator, processes } = createCoordinator(); try { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts index fe70653c770e9d..0ed0a50ff69bef 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts @@ -54,6 +54,8 @@ import { type IDevTunnelsWeb, type IDevTunnelsWebManagementClient, type IDevTunn import { TunnelAgentHostStorage } from './tunnelAgentHostStorage.js'; import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from '../../../../../platform/agentHost/common/transportConstants.js'; import { traceConnectionOperation, type ConnectionDiagnosticObserver } from '../../../../../platform/agentHost/common/connectionDiagnostics.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { createTunnelServiceCorrelation, tunnelServiceHeaders } from '../../../../../platform/remoteTunnel/common/tunnelServiceHeaders.js'; const LOG_PREFIX = '[BrowserTunnelAgentHost]'; @@ -141,12 +143,13 @@ class BrowserTunnelConnectionFactory extends Disposable implements IRemoteAgentH export class BrowserTunnelRelayClientFactory implements ITunnelRelayClientFactory { constructor( private readonly _loadDevTunnelsWeb: () => Promise, + private readonly _createHeaders: () => Record, ) { } async getTunnel(tunnelId: string, clusterId: string, authProvider: 'github' | 'microsoft', token: string): Promise { const devTunnels = await this._loadDevTunnelsWeb(); - const managementClient = createManagementClient(devTunnels, token, authProvider); + const managementClient = createManagementClient(devTunnels, token, authProvider, this._createHeaders()); const tunnel = await managementClient.getTunnel({ tunnelId, clusterId }, { includePorts: true, tokenScopes: ['connect'], @@ -246,6 +249,7 @@ export class BrowserTunnelAgentHostService extends Disposable implements ITunnel @IStorageService private readonly _storageService: IStorageService, @IRemoteAgentHostLocationPreferenceService private readonly _locationPreferenceService: IRemoteAgentHostLocationPreferenceService, @IDialogService private readonly _dialogService: IDialogService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, options: IBrowserTunnelAgentHostServiceOptions = {}, ) { super(); @@ -259,7 +263,7 @@ export class BrowserTunnelAgentHostService extends Disposable implements ITunnel const load = options.loadDevTunnelsWeb ?? loadDevTunnelsWeb; this._loadDevTunnelsWeb = load; this._connector = options.connector ?? this._register(new TunnelAgentHostConnector( - new BrowserTunnelRelayClientFactory(load), + new BrowserTunnelRelayClientFactory(load, () => tunnelServiceHeaders(createTunnelServiceCorrelation(this._telemetryService, 'connect'))), new BrowserTunnelSocketFactory(), this._logService, )); @@ -284,7 +288,7 @@ export class BrowserTunnelAgentHostService extends Disposable implements ITunnel try { const sdk = await traceConnectionOperation(options?.onDiagnostic, 'discovery.sdk', () => this._loadDevTunnelsWeb()); - const managementClient = createManagementClient(sdk, auth.token, auth.provider); + const managementClient = createManagementClient(sdk, auth.token, auth.provider, tunnelServiceHeaders(createTunnelServiceCorrelation(this._telemetryService, 'list'))); const tunnels = await traceConnectionOperation(options?.onDiagnostic, 'discovery.enumeration', () => managementClient.listTunnels(undefined, undefined, { labels: [TUNNEL_LAUNCHER_LABEL], requireAllLabels: true, @@ -435,7 +439,7 @@ export class BrowserTunnelAgentHostService extends Disposable implements ITunnel if (!auth) { throw new Error('No authentication available'); } - const managementClient = createManagementClient(await this._loadDevTunnelsWeb(), auth.token, auth.provider); + const managementClient = createManagementClient(await this._loadDevTunnelsWeb(), auth.token, auth.provider, tunnelServiceHeaders(createTunnelServiceCorrelation(this._telemetryService, 'delete'))); await managementClient.deleteTunnel(tunnel); this.removeCachedTunnel(tunnel.tunnelId); } @@ -686,11 +690,14 @@ function createManagementClient( devTunnels: IDevTunnelsWeb, token: string, authProvider: 'github' | 'microsoft', + headers: Record, ): IDevTunnelsWebManagementClient { const authorization = authProvider === 'github' ? `github ${token}` : `Bearer ${token}`; - return new devTunnels.TunnelManagementHttpClient( + const client = new devTunnels.TunnelManagementHttpClient( 'vscode-sessions', devTunnels.ManagementApiVersions.Version20230927preview, async () => authorization, ); + client.additionalRequestHeaders = headers; + return client; } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devTunnelsWebLoader.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devTunnelsWebLoader.ts index 4a11f016697332..663f2d3898ac51 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devTunnelsWebLoader.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devTunnelsWebLoader.ts @@ -27,6 +27,7 @@ export interface IDevTunnelsWebRequestOptions { /** Browser-compatible subset of the Dev Tunnels management client. */ export interface IDevTunnelsWebManagementClient { + additionalRequestHeaders?: Record; listTunnels( clusterId: string | undefined, domain: string | undefined, diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.contribution.ts index 72d5d98487cc28..346567f940dc3b 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.contribution.ts @@ -15,6 +15,7 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { ILogService } from '../../../../../platform/log/common/log.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IAuthenticationService } from '../../../../../workbench/services/authentication/common/authentication.js'; import { IBrowserWorkbenchEnvironmentService } from '../../../../../workbench/services/environment/browser/environmentService.js'; import { BrowserTunnelAgentHostService } from './browserTunnelAgentHostService.js'; @@ -40,6 +41,7 @@ class BrowserTunnelAgentHostServiceSelector extends Disposable implements ITunne @IStorageService storageService: IStorageService, @IRemoteAgentHostLocationPreferenceService locationPreferenceService: IRemoteAgentHostLocationPreferenceService, @IDialogService dialogService: IDialogService, + @ITelemetryService telemetryService: ITelemetryService, ) { super(); this._delegate = this._register(environmentService.options?.tunnelDiscoveryProvider @@ -62,6 +64,7 @@ class BrowserTunnelAgentHostServiceSelector extends Disposable implements ITunne storageService, locationPreferenceService, dialogService, + telemetryService, )); this.onDidChangeTunnels = this._delegate.onDidChangeTunnels; } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/browserTunnelAgentHostService.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/browserTunnelAgentHostService.test.ts index 0ae27d56950d19..9413502c31397e 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/browserTunnelAgentHostService.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/browserTunnelAgentHostService.test.ts @@ -21,6 +21,8 @@ import { TestInstantiationService } from '../../../../../../platform/instantiati import { NullLogService } from '../../../../../../platform/log/common/log.js'; import { type IProductService } from '../../../../../../platform/product/common/productService.js'; import { InMemoryStorageService } from '../../../../../../platform/storage/common/storage.js'; +import { NullTelemetryService } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; +import { tunnelServiceHeaders } from '../../../../../../platform/remoteTunnel/common/tunnelServiceHeaders.js'; import { type IDiscoveredTunnel, type ITunnelDiscoveryProvider } from '../../../../../../workbench/browser/web.api.js'; import { IBrowserWorkbenchEnvironmentService } from '../../../../../../workbench/services/environment/browser/environmentService.js'; import { type AuthenticationSession, IAuthenticationService } from '../../../../../../workbench/services/authentication/common/authentication.js'; @@ -191,6 +193,7 @@ function createBrowserTunnelService( override getPreference() { return undefined; } }(), connectionOptions?.dialogService ?? new class extends mock() { }(), + NullTelemetryService, { connector: connectionOptions?.connector ?? new FakeConnector(undefined), loadDevTunnelsWeb: async () => bundle }, )); } @@ -456,6 +459,7 @@ suite('BrowserTunnelAgentHostService', () => { const requests: IDevTunnelsWebRequestOptions[] = []; let authorization = ''; let relay: FakeRelayClient | undefined; + let managementClient: IDevTunnelsWebManagementClient | undefined; class FakeManagementClient implements IDevTunnelsWebManagementClient { private readonly _userTokenCallback: () => Promise; @@ -485,6 +489,7 @@ suite('BrowserTunnelAgentHostService', () => { constructor(_managementClient: IDevTunnelsWebManagementClient) { relay = this; + managementClient = _managementClient; } connect(_tunnel: IDevTunnelsWebTunnel): Promise { @@ -509,14 +514,16 @@ suite('BrowserTunnelAgentHostService', () => { TunnelRelayTunnelClient: FakeRelayClient, TunnelAccessScopes: {}, }; - const session = await new BrowserTunnelRelayClientFactory(async () => bundle).getTunnel('tunnel-id', 'cluster-id', 'github', 'token'); + const headers = tunnelServiceHeaders({ sessionId: 'session', operationId: 'operation' }); + const session = await new BrowserTunnelRelayClientFactory(async () => bundle, () => headers).getTunnel('tunnel-id', 'cluster-id', 'github', 'token'); await session!.createRelayClient(); - assert.deepStrictEqual({ authorization, requests, acceptsLocal: relay?.acceptLocalConnectionsForForwardedPorts, endpoints: relay?.endpoints }, { + assert.deepStrictEqual({ authorization, requests, acceptsLocal: relay?.acceptLocalConnectionsForForwardedPorts, endpoints: relay?.endpoints, headers: managementClient?.additionalRequestHeaders }, { authorization: 'github token', requests: [{ includePorts: true, tokenScopes: ['connect'] }], acceptsLocal: false, endpoints: { relay: 'endpoint' }, + headers, }); }); }); From e73a75b62c1e80bc1c8de7fc849519f790a7f8cb Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 9 Oct 2026 12:17:30 -0700 Subject: [PATCH 2/4] Avoid common telemetry session ID collisions in tunnel events Use tunnelSessionId for explicit tunnel correlation without shadowing the reserved common sessionID property. Preserve request header values and operation IDs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/node/tunnelAgentHostService.test.ts | 2 +- .../remoteTunnel/common/tunnelServiceHeaders.ts | 13 +++++++++++-- .../remoteTunnel/node/remoteTunnelService.ts | 6 +++--- .../test/common/tunnelServiceHeaders.test.ts | 4 ++-- .../test/node/remoteTunnelService.test.ts | 2 +- 5 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/vs/platform/agentHost/test/node/tunnelAgentHostService.test.ts b/src/vs/platform/agentHost/test/node/tunnelAgentHostService.test.ts index 4b3f067c8156e0..1de201427cb07b 100644 --- a/src/vs/platform/agentHost/test/node/tunnelAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/node/tunnelAgentHostService.test.ts @@ -103,7 +103,7 @@ suite('TunnelAgentHostService - correlation', () => { operations: requests.map(request => request['X-Tunnels-VSCode-Client-Operation-Id']), uniqueRequests: new Set(requests.map(request => request['X-Tunnels-VSCode-Client-Request-Id'])).size, }, { - telemetry: ['tunnelServiceOperation', { sessionId: NullTelemetryService.sessionId, operationId, operation: 'list' }], + telemetry: ['tunnelServiceOperation', { tunnelSessionId: NullTelemetryService.sessionId, operationId, operation: 'list' }], sessions: [NullTelemetryService.sessionId, NullTelemetryService.sessionId], operations: [operationId, operationId], uniqueRequests: 2, diff --git a/src/vs/platform/remoteTunnel/common/tunnelServiceHeaders.ts b/src/vs/platform/remoteTunnel/common/tunnelServiceHeaders.ts index f7281404f1b0ad..ed5b961d13e304 100644 --- a/src/vs/platform/remoteTunnel/common/tunnelServiceHeaders.ts +++ b/src/vs/platform/remoteTunnel/common/tunnelServiceHeaders.ts @@ -13,14 +13,23 @@ export interface ITunnelServiceCorrelation { export function createTunnelServiceCorrelation(telemetryService: ITelemetryService, operation: 'list' | 'connect' | 'delete' | 'host'): ITunnelServiceCorrelation { const correlation = { sessionId: telemetryService.sessionId, operationId: generateUuid() }; + type TunnelServiceOperationEvent = { + tunnelSessionId: string; + operationId: string; + operation: string; + }; type TunnelServiceOperationClassification = { owner: 'connor4312'; comment: 'Correlates a tunnel service operation with management and relay requests.'; - sessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The originating VS Code telemetry session ID.' }; + tunnelSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The originating VS Code telemetry session ID.' }; operationId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'A random ID shared by requests belonging to this tunnel operation.' }; operation: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The kind of tunnel operation.' }; }; - telemetryService.publicLog2('tunnelServiceOperation', { ...correlation, operation }); + telemetryService.publicLog2('tunnelServiceOperation', { + tunnelSessionId: correlation.sessionId, + operationId: correlation.operationId, + operation, + }); return correlation; } diff --git a/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts b/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts index a31f1dc2f8b536..7391717cb62ab6 100644 --- a/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts +++ b/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts @@ -38,14 +38,14 @@ type RemoteTunnelConnectedClassification = { comment: 'Reporting when a Remote Tunnel connection is established'; tunnelName: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the connected tunnel' }; isAttached: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the connection is attached to an existing tunnel process' }; - sessionId?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The VS Code telemetry session ID supplied to the CLI.' }; + tunnelSessionId?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The VS Code telemetry session ID supplied to the CLI.' }; operationId?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The operation ID supplied to the CLI for tunnel service requests.' }; }; type RemoteTunnelConnectedEvent = { tunnelName: string; isAttached: boolean; - sessionId?: string; + tunnelSessionId?: string; operationId?: string; }; @@ -235,7 +235,7 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ this.telemetryService.publicLog2('remoteTunnel.connected', { tunnelName: info.tunnelName, isAttached: info.isAttached, - ...event.correlation, + ...(event.correlation ? { tunnelSessionId: event.correlation.sessionId, operationId: event.correlation.operationId } : {}), }); this.setTunnelStatus(TunnelStates.connected(info, this.tunnelProcessCoordinator.getStatus().serviceInstallFailed)); } else if (event.status.type === 'tokenError') { diff --git a/src/vs/platform/remoteTunnel/test/common/tunnelServiceHeaders.test.ts b/src/vs/platform/remoteTunnel/test/common/tunnelServiceHeaders.test.ts index 189d61686300f7..103d599c7a8abb 100644 --- a/src/vs/platform/remoteTunnel/test/common/tunnelServiceHeaders.test.ts +++ b/src/vs/platform/remoteTunnel/test/common/tunnelServiceHeaders.test.ts @@ -35,8 +35,8 @@ suite('Tunnel service headers', () => { differentOperations: correlation.operationId !== next.operationId, }, { events: [ - { name: 'tunnelServiceOperation', data: { ...correlation, operation: 'connect' } }, - { name: 'tunnelServiceOperation', data: { ...next, operation: 'list' } }, + { name: 'tunnelServiceOperation', data: { tunnelSessionId: correlation.sessionId, operationId: correlation.operationId, operation: 'connect' } }, + { name: 'tunnelServiceOperation', data: { tunnelSessionId: next.sessionId, operationId: next.operationId, operation: 'list' } }, ], session: telemetry.sessionId, operation: correlation.operationId, diff --git a/src/vs/platform/remoteTunnel/test/node/remoteTunnelService.test.ts b/src/vs/platform/remoteTunnel/test/node/remoteTunnelService.test.ts index c9b65d903e55cd..222533cfb05ae2 100644 --- a/src/vs/platform/remoteTunnel/test/node/remoteTunnelService.test.ts +++ b/src/vs/platform/remoteTunnel/test/node/remoteTunnelService.test.ts @@ -126,7 +126,7 @@ suite('Remote tunnel', () => { }, { connectedTelemetry: [ { tunnelName: 'test_host', isAttached: true }, - { tunnelName: 'test_host', isAttached: false, sessionId: 'telemetry-session', operationId: 'operation-id' }, + { tunnelName: 'test_host', isAttached: false, tunnelSessionId: 'telemetry-session', operationId: 'operation-id' }, ], linkedStatus: { type: 'connected', From 1039aba9907cbe8b444ea05a09065605a5a5daa5 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 9 Oct 2026 12:18:47 -0700 Subject: [PATCH 3/4] Keep tunnel correlation IDs out of CLI logs Send session and operation IDs only in tunnel service headers, removing the debug log flagged by CodeQL. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/CONTRIBUTING.md | 2 +- cli/src/tunnels/dev_tunnels.rs | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/cli/CONTRIBUTING.md b/cli/CONTRIBUTING.md index 7915673af9157e..221e908d9ae0bd 100644 --- a/cli/CONTRIBUTING.md +++ b/cli/CONTRIBUTING.md @@ -41,6 +41,6 @@ VS Code passes its telemetry session and operation IDs through `VSCODE_TUNNEL_SESSION_ID` and `VSCODE_TUNNEL_OPERATION_ID` when launching the CLI. These IDs are preserved verbatim; telemetry session IDs are not necessarily UUIDs. Standalone commands use a process-local session ID and a new operation ID for each -management client. Trace logging records both IDs for diagnostics. +management client. Correlation IDs are sent in service headers, not written to CLI logs. Attaching to an already-running tunnel does not replace that process's correlation IDs; its service requests retain the IDs of the original host process. diff --git a/cli/src/tunnels/dev_tunnels.rs b/cli/src/tunnels/dev_tunnels.rs index 9bf4e7f2a30441..676959aa91122b 100644 --- a/cli/src/tunnels/dev_tunnels.rs +++ b/cli/src/tunnels/dev_tunnels.rs @@ -80,12 +80,6 @@ fn tunnel_management_client(log: &log::Logger) -> TunnelClientBuilder { std::env::var("VSCODE_TUNNEL_OPERATION_ID"), uuid::Uuid::new_v4(), ); - trace!( - log, - "Tunnel service correlation: session={:?}, operation={:?}", - session_id, - operation_id - ); let mut client = new_tunnel_management(&TUNNEL_SERVICE_USER_AGENT); client.additional_headers(vec![ ( From 41dc0a4c6706fc579e8c5386225302764bd9e0ef Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 9 Oct 2026 12:38:11 -0700 Subject: [PATCH 4/4] Address tunnel correlation review feedback Keep correlation IDs for every CLI invocation but emit host-operation telemetry only for actual host launches. Reuse a single Rust fallback header and share the narrow operation type across telemetry declarations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/CONTRIBUTING.md | 2 + cli/src/tunnels/dev_tunnels.rs | 11 +++-- .../common/tunnelServiceHeaders.ts | 42 +++++++++++-------- .../node/tunnelProcessCoordinator.ts | 2 +- .../node/tunnelProcessCoordinator.test.ts | 18 ++++++-- 5 files changed, 47 insertions(+), 28 deletions(-) diff --git a/cli/CONTRIBUTING.md b/cli/CONTRIBUTING.md index 221e908d9ae0bd..220827f056d3a4 100644 --- a/cli/CONTRIBUTING.md +++ b/cli/CONTRIBUTING.md @@ -42,5 +42,7 @@ VS Code passes its telemetry session and operation IDs through These IDs are preserved verbatim; telemetry session IDs are not necessarily UUIDs. Standalone commands use a process-local session ID and a new operation ID for each management client. Correlation IDs are sent in service headers, not written to CLI logs. +Local CLI status, login, and service-management invocations retain correlation IDs but +do not emit host-operation telemetry. Attaching to an already-running tunnel does not replace that process's correlation IDs; its service requests retain the IDs of the original host process. diff --git a/cli/src/tunnels/dev_tunnels.rs b/cli/src/tunnels/dev_tunnels.rs index 676959aa91122b..645f5a395535d1 100644 --- a/cli/src/tunnels/dev_tunnels.rs +++ b/cli/src/tunnels/dev_tunnels.rs @@ -45,24 +45,23 @@ fn tunnel_correlation_id( value: Result, fallback: uuid::Uuid, ) -> HeaderValue { + let fallback = HeaderValue::from_str(&fallback.to_string()).unwrap(); match value { Ok(value) if !value.is_empty() => match HeaderValue::from_str(&value) { Ok(id) => id, Err(error) => { warning!(log, "Invalid {}: {}", name, error); - HeaderValue::from_str(&fallback.to_string()).unwrap() + fallback } }, Ok(_) => { warning!(log, "Invalid {}: empty correlation ID", name); - HeaderValue::from_str(&fallback.to_string()).unwrap() - } - Err(std::env::VarError::NotPresent) => { - HeaderValue::from_str(&fallback.to_string()).unwrap() + fallback } + Err(std::env::VarError::NotPresent) => fallback, Err(error) => { warning!(log, "Invalid {}: {}", name, error); - HeaderValue::from_str(&fallback.to_string()).unwrap() + fallback } } } diff --git a/src/vs/platform/remoteTunnel/common/tunnelServiceHeaders.ts b/src/vs/platform/remoteTunnel/common/tunnelServiceHeaders.ts index ed5b961d13e304..debba0c8978c30 100644 --- a/src/vs/platform/remoteTunnel/common/tunnelServiceHeaders.ts +++ b/src/vs/platform/remoteTunnel/common/tunnelServiceHeaders.ts @@ -11,25 +11,31 @@ export interface ITunnelServiceCorrelation { readonly operationId: string; } -export function createTunnelServiceCorrelation(telemetryService: ITelemetryService, operation: 'list' | 'connect' | 'delete' | 'host'): ITunnelServiceCorrelation { +type TunnelServiceOperation = 'list' | 'connect' | 'delete' | 'host'; + +type TunnelServiceOperationEvent = { + tunnelSessionId: string; + operationId: string; + operation: TunnelServiceOperation; +}; + +type TunnelServiceOperationClassification = { + owner: 'connor4312'; + comment: 'Correlates a tunnel service operation with management and relay requests.'; + tunnelSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The originating VS Code telemetry session ID.' }; + operationId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'A random ID shared by requests belonging to this tunnel operation.' }; + operation: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The kind of tunnel operation.' }; +}; + +export function createTunnelServiceCorrelation(telemetryService: ITelemetryService, operation: TunnelServiceOperation | undefined): ITunnelServiceCorrelation { const correlation = { sessionId: telemetryService.sessionId, operationId: generateUuid() }; - type TunnelServiceOperationEvent = { - tunnelSessionId: string; - operationId: string; - operation: string; - }; - type TunnelServiceOperationClassification = { - owner: 'connor4312'; - comment: 'Correlates a tunnel service operation with management and relay requests.'; - tunnelSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The originating VS Code telemetry session ID.' }; - operationId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'A random ID shared by requests belonging to this tunnel operation.' }; - operation: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The kind of tunnel operation.' }; - }; - telemetryService.publicLog2('tunnelServiceOperation', { - tunnelSessionId: correlation.sessionId, - operationId: correlation.operationId, - operation, - }); + if (operation !== undefined) { + telemetryService.publicLog2('tunnelServiceOperation', { + tunnelSessionId: correlation.sessionId, + operationId: correlation.operationId, + operation, + }); + } return correlation; } diff --git a/src/vs/platform/remoteTunnel/node/tunnelProcessCoordinator.ts b/src/vs/platform/remoteTunnel/node/tunnelProcessCoordinator.ts index 87a86dfaa27812..00d0a3b2b12355 100644 --- a/src/vs/platform/remoteTunnel/node/tunnelProcessCoordinator.ts +++ b/src/vs/platform/remoteTunnel/node/tunnelProcessCoordinator.ts @@ -363,7 +363,7 @@ export class TunnelProcessCoordinator extends Disposable implements ITunnelProce } private async _runTransient(logLabel: string, args: readonly string[], mode: TunnelProcessMode, generation: number, env?: Record, onOutput?: CodeTunnelCliOutput): Promise { - const correlation = createTunnelServiceCorrelation(this.telemetryService, 'host'); + const correlation = createTunnelServiceCorrelation(this.telemetryService, undefined); const run = this._tunnelCli.run(logLabel, args, (message, isError) => { onOutput?.(message, isError); this._fireOutput(mode, message, isError, false, () => run.result.cancel(), generation, correlation); diff --git a/src/vs/platform/remoteTunnel/test/node/tunnelProcessCoordinator.test.ts b/src/vs/platform/remoteTunnel/test/node/tunnelProcessCoordinator.test.ts index b6658ca124ea73..b8f0d025ba7c31 100644 --- a/src/vs/platform/remoteTunnel/test/node/tunnelProcessCoordinator.test.ts +++ b/src/vs/platform/remoteTunnel/test/node/tunnelProcessCoordinator.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import sinon from 'sinon'; import { ChildProcess, SpawnOptions } from 'child_process'; import { EventEmitter } from 'events'; import { PassThrough } from 'stream'; @@ -16,6 +17,7 @@ import { CodeTunnelCli, CodeTunnelSpawn } from '../../node/codeTunnelCliProcess. import { ITunnelProcessMachineStatus, resolveTunnelProcessMode, TunnelProcessCoordinator } from '../../node/tunnelProcessCoordinator.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; +import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; interface TestChildProcess { readonly child: ChildProcess; @@ -105,21 +107,25 @@ suite('TunnelProcessCoordinator', () => { ], ['none', 'remoteAccess', 'service']); }); - test('passes telemetry correlation to every CLI invocation', async () => { + test('passes correlation to every CLI invocation but only logs operations for host launches', async () => { const { coordinator, processes } = createCoordinator(); const statuses: ITunnelProcessMachineStatus[] = []; const listener = coordinator.onDidMachineStatus(status => statuses.push(status)); + const telemetryService: ITelemetryService = NullTelemetryService; + const telemetry = sinon.spy(telemetryService, 'publicLog2'); try { - await coordinator.setRemoteAccess(activeMode(), LogLevel.Info); - const tunnel = processes.find(process => process.args.includes('--accept-server-license-terms'))!; + await coordinator.setRemoteAccess(activeMode(true), LogLevel.Info); + const tunnel = processes.find(process => process.args.includes('--parent-process-id'))!; tunnel.stdout.write('__VSCODE_CLI_STATUS__{"type":"connected","tunnelName":"test_host","isAttached":false}\n'); tunnel.stdout.write('__VSCODE_CLI_STATUS__{"type":"connected","tunnelName":"test_host","isAttached":true}\n'); + await coordinator.setRemoteAccess(INACTIVE_TUNNEL_MODE, LogLevel.Info); assert.deepStrictEqual({ sessionIds: [...new Set(processes.map(process => process.env?.VSCODE_TUNNEL_SESSION_ID))], uniqueOperations: new Set(processes.map(process => process.env?.VSCODE_TUNNEL_OPERATION_ID)).size, processCount: processes.length, hasOperations: processes.every(process => !!process.env?.VSCODE_TUNNEL_OPERATION_ID), correlation: statuses.map(status => status.correlation), + telemetry: telemetry.getCalls().map(call => call.args), }, { sessionIds: [NullTelemetryService.sessionId], uniqueOperations: processes.length, @@ -129,8 +135,14 @@ suite('TunnelProcessCoordinator', () => { { sessionId: tunnel.env?.VSCODE_TUNNEL_SESSION_ID, operationId: tunnel.env?.VSCODE_TUNNEL_OPERATION_ID }, undefined, ], + telemetry: [['tunnelServiceOperation', { + tunnelSessionId: NullTelemetryService.sessionId, + operationId: tunnel.env?.VSCODE_TUNNEL_OPERATION_ID, + operation: 'host', + }]], }); } finally { + telemetry.restore(); listener.dispose(); for (const process of processes) { process.emitExit();