diff --git a/cli/CONTRIBUTING.md b/cli/CONTRIBUTING.md index d6b3696bcac33f..220827f056d3a4 100644 --- a/cli/CONTRIBUTING.md +++ b/cli/CONTRIBUTING.md @@ -31,3 +31,18 @@ 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. 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/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..645f5a395535d1 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,73 @@ 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 { + 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); + fallback + } + }, + Ok(_) => { + warning!(log, "Invalid {}: empty correlation ID", name); + fallback + } + Err(std::env::VarError::NotPresent) => fallback, + Err(error) => { + warning!(log, "Invalid {}: {}", name, error); + fallback + } + } +} + +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(), + ); + 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 +391,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 +409,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 +1390,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..1de201427cb07b 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', { tunnelSessionId: 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..debba0c8978c30 --- /dev/null +++ b/src/vs/platform/remoteTunnel/common/tunnelServiceHeaders.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 { generateUuid } from '../../../base/common/uuid.js'; +import { ITelemetryService } from '../../telemetry/common/telemetry.js'; + +export interface ITunnelServiceCorrelation { + readonly sessionId: string; + readonly operationId: string; +} + +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() }; + if (operation !== undefined) { + telemetryService.publicLog2('tunnelServiceOperation', { + tunnelSessionId: correlation.sessionId, + operationId: correlation.operationId, + 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..7391717cb62ab6 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' }; + 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; + tunnelSessionId?: 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 ? { 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/node/tunnelProcessCoordinator.ts b/src/vs/platform/remoteTunnel/node/tunnelProcessCoordinator.ts index 7eec16ff5d2113..00d0a3b2b12355 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, undefined); 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..103d599c7a8abb --- /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: { 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, + 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..222533cfb05ae2 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, tunnelSessionId: '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..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'; @@ -13,8 +14,10 @@ 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'; +import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; interface TestChildProcess { readonly child: ChildProcess; @@ -88,6 +91,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 +107,51 @@ suite('TunnelProcessCoordinator', () => { ], ['none', 'remoteAccess', 'service']); }); + 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(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, + processCount: processes.length, + hasOperations: true, + correlation: [ + { 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(); + } + 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, }); }); });