From 51b48c09a83157d10d10f701ffc32e2ecc235f49 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 3 Jul 2026 11:44:09 -0400 Subject: [PATCH 1/3] Send GitHub telemetry forwarding opt-in on the connect handshake The runtime moved the `enableGitHubTelemetryForwarding` opt-in from `session.create` to the connection-level `connect` handshake, so it can forward the first session's un-replayable `session.start` event. SDKs only sent the flag on session.create/resume, so against a post-move runtime nothing opted the connection in and GitHub telemetry forwarding timed out. Dual-send the flag across all six SDKs: send it on `connect` (when a GitHub telemetry handler is registered) in addition to the existing session.create/resume send. This is backward and forward compatible; unknown fields are ignored by both old and new runtimes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 14 ++++- dotnet/src/Generated/Rpc.cs | 4 ++ dotnet/test/Unit/GitHubTelemetryTests.cs | 52 +++++++++++++++--- go/client.go | 10 +++- go/client_test.go | 46 ++++++++++++++++ go/rpc/zrpc.go | 2 + .../com/github/copilot/CopilotClient.java | 21 +++++--- .../github/copilot/GitHubTelemetryTest.java | 27 ++++++++-- nodejs/src/client.ts | 11 +++- nodejs/src/generated/rpc.ts | 4 ++ nodejs/test/client.test.ts | 34 ++++++++++++ python/copilot/client.py | 11 +++- python/copilot/generated/rpc.py | 14 ++++- python/test_client.py | 31 ++++++++++- rust/src/lib.rs | 38 +++++++++++-- rust/tests/session_test.rs | 53 +++++++++++++++++++ 16 files changed, 345 insertions(+), 27 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 6041fe239..b1f06c4ba 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1802,7 +1802,19 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio _ => null, }; var connectResponse = await InvokeRpcAsync( - connection.Rpc, "connect", [new ConnectRequest { Token = token }], connection.StderrBuffer, cancellationToken); + connection.Rpc, + "connect", + [new ConnectRequest + { + Token = token, + // Opt in to GitHub telemetry forwarding at the connection level when a + // handler is registered (mirrors the runtime, which reads this flag on the + // `connect` handshake so the first session's un-replayable `session.start` + // event is forwarded). Also sent on session.create/resume for older CLIs. + EnableGitHubTelemetryForwarding = _options.OnGitHubTelemetry != null ? true : null, + }], + connection.StderrBuffer, + cancellationToken); serverVersion = (int)connectResponse.ProtocolVersion; } catch (IOException ex) when (ex.InnerException is RemoteRpcException remoteEx && IsUnsupportedConnectMethod(remoteEx)) diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index f46986d4c..2a68a04e4 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -65,6 +65,10 @@ internal sealed class ConnectResult [Experimental(Diagnostics.Experimental)] internal sealed class ConnectRequest { + /// Opt this connection in to GitHub telemetry forwarding for its lifetime. When set, the runtime forwards every internal telemetry event it emits — across all sessions, plus sessionless events — to this connection over the gitHubTelemetry.event notification, in addition to the runtime's normal GitHub/CTS emission (dual-write). Intended for first-party hosts that re-emit the events into their own telemetry stores. Both unrestricted and restricted events are forwarded, each tagged with a restricted discriminator; a backstop drops restricted events when restricted telemetry is disabled. + [JsonPropertyName("enableGitHubTelemetryForwarding")] + public bool? EnableGitHubTelemetryForwarding { get; set; } + /// Connection token; required when the server was started with COPILOT_CONNECTION_TOKEN. [JsonPropertyName("token")] public string? Token { get; set; } diff --git a/dotnet/test/Unit/GitHubTelemetryTests.cs b/dotnet/test/Unit/GitHubTelemetryTests.cs index 4a41c1cb8..a7f9e96b7 100644 --- a/dotnet/test/Unit/GitHubTelemetryTests.cs +++ b/dotnet/test/Unit/GitHubTelemetryTests.cs @@ -53,6 +53,38 @@ public async Task ResumeSession_Opts_Into_Forwarding_When_Handler_Provided() Assert.True(flag.GetBoolean()); } + [Fact] + public async Task Connect_Opts_Into_Forwarding_When_Handler_Provided() + { + await using var server = await FakeTelemetryServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions + { + Connection = RuntimeConnection.ForUri(server.Url), + OnGitHubTelemetry = _ => Task.CompletedTask, + }); + await client.StartAsync(); + + var connectParams = server.LastConnectParams ?? throw new InvalidOperationException("connect was not captured."); + Assert.True(connectParams.TryGetProperty("enableGitHubTelemetryForwarding", out var flag)); + Assert.True(flag.GetBoolean()); + } + + [Fact] + public async Task Connect_Does_Not_Opt_In_Without_Handler() + { + await using var server = await FakeTelemetryServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions + { + Connection = RuntimeConnection.ForUri(server.Url), + }); + await client.StartAsync(); + + var connectParams = server.LastConnectParams ?? throw new InvalidOperationException("connect was not captured."); + var optedIn = connectParams.TryGetProperty("enableGitHubTelemetryForwarding", out var flag) + && flag.ValueKind == JsonValueKind.True; + Assert.False(optedIn); + } + [Fact] public async Task CreateSession_Does_Not_Opt_In_Without_Handler() { @@ -187,6 +219,8 @@ public string Url public JsonElement? LastResumeParams { get; private set; } + public JsonElement? LastConnectParams { get; private set; } + public static Task StartAsync() { var listener = new TcpListener(IPAddress.Loopback, 0); @@ -267,12 +301,7 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel object? result = method switch { - "connect" => new Dictionary - { - ["ok"] = true, - ["protocolVersion"] = 3, - ["version"] = "test", - }, + "connect" => CaptureConnect(request), "session.create" => CaptureCreate(request), "session.resume" => CaptureResume(request), "session.send" => new Dictionary { ["messageId"] = "message-1" }, @@ -289,6 +318,17 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel }, cancellationToken); } + private Dictionary CaptureConnect(JsonElement request) + { + LastConnectParams = request.TryGetProperty("params", out var p) ? p.Clone() : null; + return new Dictionary + { + ["ok"] = true, + ["protocolVersion"] = 3, + ["version"] = "test", + }; + } + private Dictionary CaptureCreate(JsonElement request) { LastCreateParams = request.TryGetProperty("params", out var p) ? p.Clone() : null; diff --git a/go/client.go b/go/client.go index 1bbf615d7..5f5d0b825 100644 --- a/go/client.go +++ b/go/client.go @@ -1685,7 +1685,15 @@ func (c *Client) verifyProtocolVersion(ctx context.Context) error { t := c.effectiveConnectionToken tokenPtr = &t } - connectResult, err := c.internalRPC.Connect(ctx, &rpc.ConnectRequest{Token: tokenPtr}) + connectReq := &rpc.ConnectRequest{Token: tokenPtr} + // Opt in to GitHub telemetry forwarding at the connection level when a handler is + // registered (mirrors the runtime, which reads this flag on the `connect` handshake + // so the first session's un-replayable `session.start` event is forwarded). Also + // sent on session.create/resume for older CLIs. + if c.options.OnGitHubTelemetry != nil { + connectReq.EnableGitHubTelemetryForwarding = Bool(true) + } + connectResult, err := c.internalRPC.Connect(ctx, connectReq) if err != nil { var rpcErr *jsonrpc2.Error if errors.As(err, &rpcErr) && (rpcErr.Code == jsonrpc2.ErrMethodNotFound.Code || rpcErr.Message == "Unhandled method connect") { diff --git a/go/client_test.go b/go/client_test.go index f7d5f50c6..fedcb68bf 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -2487,6 +2487,52 @@ func assertForwardingFlagAbsent(t *testing.T, params json.RawMessage) { } } +func TestClient_ForwardsGitHubTelemetryForwardingOnConnect(t *testing.T) { + rpcClient, server, _ := newRuntimeShutdownRpcPair(t) + t.Cleanup(server.Stop) + client := &Client{ + client: rpcClient, + RPC: rpc.NewServerRPC(rpcClient), + internalRPC: rpc.NewInternalServerRPC(rpcClient), + sessions: make(map[string]*Session), + options: ClientOptions{OnGitHubTelemetry: func(*rpc.GitHubTelemetryNotification) {}}, + } + + connectParams := make(chan json.RawMessage, 1) + server.SetRequestHandler("connect", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + connectParams <- append(json.RawMessage(nil), params...) + return []byte(`{"ok":true,"protocolVersion":3,"version":"test"}`), nil + }) + + if err := client.verifyProtocolVersion(t.Context()); err != nil { + t.Fatalf("verifyProtocolVersion failed: %v", err) + } + assertForwardingFlagTrue(t, <-connectParams) +} + +func TestClient_OmitsGitHubTelemetryForwardingOnConnectWhenNoHandler(t *testing.T) { + rpcClient, server, _ := newRuntimeShutdownRpcPair(t) + t.Cleanup(server.Stop) + client := &Client{ + client: rpcClient, + RPC: rpc.NewServerRPC(rpcClient), + internalRPC: rpc.NewInternalServerRPC(rpcClient), + sessions: make(map[string]*Session), + options: ClientOptions{}, + } + + connectParams := make(chan json.RawMessage, 1) + server.SetRequestHandler("connect", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + connectParams <- append(json.RawMessage(nil), params...) + return []byte(`{"ok":true,"protocolVersion":3,"version":"test"}`), nil + }) + + if err := client.verifyProtocolVersion(t.Context()); err != nil { + t.Fatalf("verifyProtocolVersion failed: %v", err) + } + assertForwardingFlagAbsent(t, <-connectParams) +} + func TestGitHubTelemetryNotificationRoutesToCallback(t *testing.T) { // The runtime forwards telemetry via a JSON-RPC *notification* (no id). // Drive a real Content-Length-framed notification through the transport and diff --git a/go/rpc/zrpc.go b/go/rpc/zrpc.go index f6b0d31ac..d514fafa9 100644 --- a/go/rpc/zrpc.go +++ b/go/rpc/zrpc.go @@ -1345,6 +1345,8 @@ type ConnectRemoteSessionParams struct { // Experimental: ConnectRequest is part of an experimental API and may change or be removed. // Internal: ConnectRequest is an internal SDK API and is not part of the public surface. type ConnectRequest struct { + // Opt this connection in to GitHub telemetry forwarding for its lifetime. When set, the runtime forwards every internal telemetry event it emits — across all sessions, plus sessionless events — to this connection over the `gitHubTelemetry.event` notification, in addition to the runtime's normal GitHub/CTS emission (dual-write). Intended for first-party hosts that re-emit the events into their own telemetry stores. Both unrestricted and restricted events are forwarded, each tagged with a `restricted` discriminator; a backstop drops restricted events when restricted telemetry is disabled. + EnableGitHubTelemetryForwarding *bool `json:"enableGitHubTelemetryForwarding,omitempty"` // Connection token; required when the server was started with COPILOT_CONNECTION_TOKEN Token *string `json:"token,omitempty"` } diff --git a/java/src/main/java/com/github/copilot/CopilotClient.java b/java/src/main/java/com/github/copilot/CopilotClient.java index 31a892914..b90ccd545 100644 --- a/java/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/CopilotClient.java @@ -26,7 +26,7 @@ import com.github.copilot.rpc.CreateSessionResponse; import com.github.copilot.generated.rpc.SessionOptionsUpdateParams; import com.github.copilot.generated.rpc.SessionInstalledPlugin; -import com.github.copilot.generated.rpc.ConnectParams; +import com.github.copilot.generated.rpc.ConnectResult; import com.github.copilot.generated.rpc.GitHubTelemetryNotification; import com.github.copilot.generated.rpc.ServerRpc; import com.github.copilot.generated.rpc.SessionEventLogRegisterInterestParams; @@ -306,11 +306,20 @@ private void verifyProtocolVersion(Connection connection) throws Exception { Integer serverVersion; try { - // Try the new 'connect' RPC which supports connection tokens - var connectParams = new ConnectParams(effectiveConnectionToken); - var connectResponse = connection.rpc - .invoke("connect", connectParams, com.github.copilot.generated.rpc.ConnectResult.class) - .get(30, TimeUnit.SECONDS); + // Try the new 'connect' RPC which supports connection tokens. + var connectParams = new HashMap(); + if (effectiveConnectionToken != null) { + connectParams.put("token", effectiveConnectionToken); + } + // Opt into GitHub telemetry forwarding at the connection level when a handler + // is registered, so the runtime can forward the first session's un-replayable + // start event. Also sent on session create/resume for backward compatibility + // with servers that read the flag there instead. + if (this.options.getOnGitHubTelemetry() != null) { + connectParams.put("enableGitHubTelemetryForwarding", true); + } + var connectResponse = connection.rpc.invoke("connect", connectParams, ConnectResult.class).get(30, + TimeUnit.SECONDS); serverVersion = connectResponse.protocolVersion() != null ? connectResponse.protocolVersion().intValue() : null; diff --git a/java/src/test/java/com/github/copilot/GitHubTelemetryTest.java b/java/src/test/java/com/github/copilot/GitHubTelemetryTest.java index ad950b823..8e35bd9a9 100644 --- a/java/src/test/java/com/github/copilot/GitHubTelemetryTest.java +++ b/java/src/test/java/com/github/copilot/GitHubTelemetryTest.java @@ -32,8 +32,9 @@ /** * Exercises the hand-written GitHub telemetry forwarding surface: the * {@code gitHubTelemetry.event} notification adapter, the - * {@code enableGitHubTelemetryForwarding} capability flag on the create/resume - * requests, and the {@code onGitHubTelemetry} client option. + * {@code enableGitHubTelemetryForwarding} capability flag on the connect + * handshake and the create/resume requests, and the {@code onGitHubTelemetry} + * client option. */ @AllowCopilotExperimental class GitHubTelemetryTest { @@ -146,6 +147,12 @@ void clientOptsSessionsIntoForwardingAndReceivesEvents() throws Exception { client.start().get(15, TimeUnit.SECONDS); + // Connecting must opt into telemetry forwarding at the connection level so + // the runtime can forward the first session's un-replayable start event. + JsonNode connectParams = server.awaitConnect(); + assertTrue(connectParams.path("enableGitHubTelemetryForwarding").asBoolean(), + "connect request should carry enableGitHubTelemetryForwarding=true"); + // Creating a session must opt it into telemetry forwarding. client.createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(15, TimeUnit.SECONDS); @@ -178,6 +185,10 @@ void clientOmitsForwardingWhenNoHandler() throws Exception { client.start().get(15, TimeUnit.SECONDS); + JsonNode connectParams = server.awaitConnect(); + assertFalse(connectParams.has("enableGitHubTelemetryForwarding"), + "connect request should omit the flag when no handler is registered"); + client.createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(15, TimeUnit.SECONDS); JsonNode createParams = server.awaitCreate(); @@ -214,6 +225,7 @@ private static final class FakeRuntimeServer implements AutoCloseable { private final ServerSocket serverSocket; private final Thread acceptThread; private final CompletableFuture ready = new CompletableFuture<>(); + private final CompletableFuture connectParams = new CompletableFuture<>(); private final CompletableFuture createParams = new CompletableFuture<>(); private final CompletableFuture resumeParams = new CompletableFuture<>(); @@ -228,6 +240,10 @@ String url() { return "127.0.0.1:" + serverSocket.getLocalPort(); } + JsonNode awaitConnect() throws Exception { + return connectParams.get(15, TimeUnit.SECONDS); + } + JsonNode awaitCreate() throws Exception { return createParams.get(15, TimeUnit.SECONDS); } @@ -244,8 +260,10 @@ private void acceptLoop() { try { Socket socket = serverSocket.accept(); JsonRpcClient server = JsonRpcClient.fromSocket(socket); - server.registerMethodHandler("connect", - (id, params) -> respond(server, id, Map.of("protocolVersion", 2))); + server.registerMethodHandler("connect", (id, params) -> { + connectParams.complete(params); + respond(server, id, Map.of("protocolVersion", 2)); + }); server.registerMethodHandler("session.create", (id, params) -> { createParams.complete(params); respond(server, id, Map.of("sessionId", params.path("sessionId").asText("created"), "workspacePath", @@ -261,6 +279,7 @@ private void acceptLoop() { ready.complete(server); } catch (IOException e) { ready.completeExceptionally(e); + connectParams.completeExceptionally(e); createParams.completeExceptionally(e); resumeParams.completeExceptionally(e); } diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 160a12d48..5ea6796fc 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1847,7 +1847,16 @@ export class CopilotClient { let serverVersion: number | undefined; try { const result = await raceAgainstExit( - this.internalRpc.connect({ token: this.effectiveConnectionToken }) + this.internalRpc.connect({ + token: this.effectiveConnectionToken, + // Opt in to GitHub telemetry forwarding at the connection level when a + // handler is registered (mirrors the runtime, which reads this flag on the + // `connect` handshake so the first session's un-replayable `session.start` + // event is forwarded). Also sent on session.create/resume for older CLIs. + ...(this.onGitHubTelemetry != null + ? { enableGitHubTelemetryForwarding: true } + : {}), + }) ); serverVersion = result.protocolVersion; } catch (err) { diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index cd60dedcb..bc6593617 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -3697,6 +3697,10 @@ export interface ConnectRemoteSessionParams { /** @experimental */ /** @internal */ export interface ConnectRequest { + /** + * Opt this connection in to GitHub telemetry forwarding for its lifetime. When set, the runtime forwards every internal telemetry event it emits — across all sessions, plus sessionless events — to this connection over the `gitHubTelemetry.event` notification, in addition to the runtime's normal GitHub/CTS emission (dual-write). Intended for first-party hosts that re-emit the events into their own telemetry stores. Both unrestricted and restricted events are forwarded, each tagged with a `restricted` discriminator; a backstop drops restricted events when restricted telemetry is disabled. + */ + enableGitHubTelemetryForwarding?: boolean; /** * Connection token; required when the server was started with COPILOT_CONNECTION_TOKEN */ diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index b17449454..96c32a595 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -488,6 +488,40 @@ describe("CopilotClient", () => { expect(resumePayload.enableGitHubTelemetryForwarding).toBe(true); }); + it("opts into GitHub telemetry forwarding on the connect handshake when a handler is provided", async () => { + const client = new CopilotClient({ onGitHubTelemetry: () => {} }); + onTestFinished(() => client.forceStop()); + + const sendRequest = vi.fn(async (method: string) => { + if (method === "connect") return { ok: true, protocolVersion: 3, version: "test" }; + throw new Error(`Unexpected method: ${method}`); + }); + (client as any).connection = { sendRequest }; + + await (client as any).verifyProtocolVersion(); + + const connectCall = sendRequest.mock.calls.find(([method]) => method === "connect"); + expect(connectCall).toBeDefined(); + expect((connectCall![1] as any).enableGitHubTelemetryForwarding).toBe(true); + }); + + it("does not opt into GitHub telemetry forwarding on the connect handshake without a handler", async () => { + const client = new CopilotClient(); + onTestFinished(() => client.forceStop()); + + const sendRequest = vi.fn(async (method: string) => { + if (method === "connect") return { ok: true, protocolVersion: 3, version: "test" }; + throw new Error(`Unexpected method: ${method}`); + }); + (client as any).connection = { sendRequest }; + + await (client as any).verifyProtocolVersion(); + + const connectCall = sendRequest.mock.calls.find(([method]) => method === "connect"); + expect(connectCall).toBeDefined(); + expect((connectCall![1] as any).enableGitHubTelemetryForwarding).toBeUndefined(); + }); + it("does not opt into GitHub telemetry forwarding without a handler", async () => { const client = new CopilotClient(); await client.start(); diff --git a/python/copilot/client.py b/python/copilot/client.py index 269aaf96c..9d33186d9 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -3304,7 +3304,16 @@ async def _verify_protocol_version(self) -> None: server_version: int | None try: connect_result = await _InternalServerRpc(self._client)._connect( - _ConnectRequest(token=self._effective_connection_token) + _ConnectRequest( + token=self._effective_connection_token, + # Opt in to GitHub telemetry forwarding at the connection level when a + # handler is registered (mirrors the runtime, which reads this flag on the + # `connect` handshake so the first session's un-replayable `session.start` + # event is forwarded). Also sent on session.create/resume for older CLIs. + enable_github_telemetry_forwarding=( + True if self._on_github_telemetry is not None else None + ), + ) ) server_version = connect_result.protocol_version except JsonRpcError as err: diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index 436f53211..a0414a137 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -1229,17 +1229,29 @@ def to_dict(self) -> dict: class _ConnectRequest: """Optional connection token presented by the SDK client during the handshake.""" + enable_github_telemetry_forwarding: bool | None = None + """Opt this connection in to GitHub telemetry forwarding for its lifetime. When set, the + runtime forwards every internal telemetry event it emits — across all sessions, plus + sessionless events — to this connection over the gitHubTelemetry.event notification, in + addition to the runtime's normal GitHub/CTS emission (dual-write). Intended for first-party + hosts that re-emit the events into their own telemetry stores. Both unrestricted and + restricted events are forwarded, each tagged with a restricted discriminator; a backstop + drops restricted events when restricted telemetry is disabled.""" + token: str | None = None """Connection token; required when the server was started with COPILOT_CONNECTION_TOKEN""" @staticmethod def from_dict(obj: Any) -> '_ConnectRequest': assert isinstance(obj, dict) + enable_github_telemetry_forwarding = from_union([from_bool, from_none], obj.get("enableGitHubTelemetryForwarding")) token = from_union([from_str, from_none], obj.get("token")) - return _ConnectRequest(token) + return _ConnectRequest(enable_github_telemetry_forwarding, token) def to_dict(self) -> dict: result: dict = {} + if self.enable_github_telemetry_forwarding is not None: + result["enableGitHubTelemetryForwarding"] = from_union([from_bool, from_none], self.enable_github_telemetry_forwarding) if self.token is not None: result["token"] = from_union([from_str, from_none], self.token) return result diff --git a/python/test_client.py b/python/test_client.py index 13fc50e73..9f518a4d4 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -2382,7 +2382,36 @@ async def mock_request(method, params, **kwargs): await client.force_stop() @pytest.mark.asyncio - async def test_event_routes_to_handler(self): + async def test_connect_enables_forwarding_when_handler_registered(self): + client = CopilotClient( + connection=RuntimeConnection.for_stdio(path=CLI_PATH), + on_github_telemetry=lambda _notification: None, + ) + captured = {} + + class _FakeClient: + async def request(self, method, params, **kwargs): + captured[method] = params + return {"ok": True, "protocolVersion": 3, "version": "test"} + + client._client = _FakeClient() + await client._verify_protocol_version() + assert captured["connect"]["enableGitHubTelemetryForwarding"] is True + + @pytest.mark.asyncio + async def test_connect_omits_forwarding_without_handler(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + captured = {} + + class _FakeClient: + async def request(self, method, params, **kwargs): + captured[method] = params + return {"ok": True, "protocolVersion": 3, "version": "test"} + + client._client = _FakeClient() + await client._verify_protocol_version() + assert "enableGitHubTelemetryForwarding" not in captured["connect"] + from copilot.generated.rpc import GitHubTelemetryNotification received: list = [] diff --git a/rust/src/lib.rs b/rust/src/lib.rs index c31e80dc5..8e8a5491c 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1818,12 +1818,40 @@ impl Client { /// param. Server-side, the token is required when the server was /// started with `COPILOT_CONNECTION_TOKEN`. async fn connect_handshake(&self) -> Result> { - let result = self - .rpc() - .connect(crate::generated::api_types::ConnectRequest { - token: self.inner.effective_connection_token.clone(), - }) + // Built inline rather than via the generated `ConnectRequest` so we can + // carry the connection-level telemetry opt-in without hand-editing + // generated code. The runtime reads `enableGitHubTelemetryForwarding` on + // this handshake to forward `gitHubTelemetry.event` for the connection's + // lifetime, which lets the first session's un-replayable `session.start` + // event be forwarded. It is also sent on session.create/resume so older + // CLIs that only read it there still opt in. + #[derive(serde::Serialize)] + #[serde(rename_all = "camelCase")] + struct ConnectParams { + #[serde(skip_serializing_if = "Option::is_none")] + token: Option, + #[serde( + rename = "enableGitHubTelemetryForwarding", + skip_serializing_if = "Option::is_none" + )] + enable_github_telemetry_forwarding: Option, + } + + let params = ConnectParams { + token: self.inner.effective_connection_token.clone(), + enable_github_telemetry_forwarding: self + .inner + .on_github_telemetry + .is_some() + .then_some(true), + }; + let value = self + .call( + crate::generated::api_types::rpc_methods::CONNECT, + Some(serde_json::to_value(params)?), + ) .await?; + let result: crate::generated::api_types::ConnectResult = serde_json::from_value(value)?; Ok(u32::try_from(result.protocol_version).ok()) } diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 08f8a7653..c9a196971 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -911,6 +911,59 @@ async fn resume_session_omits_github_telemetry_forwarding_without_callback() { timeout(TIMEOUT, resume_handle).await.unwrap().unwrap(); } +#[tokio::test] +async fn connect_sends_github_telemetry_forwarding_when_callback_registered() { + let callback: github_copilot_sdk::github_telemetry::GitHubTelemetryCallback = + Arc::new(|_notification| {}); + let (client, mut server_read, mut server_write) = make_client_with_telemetry(callback); + + let handle = tokio::spawn({ + let client = client.clone(); + async move { client.verify_protocol_version().await.unwrap() } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "connect"); + assert_eq!(request["params"]["enableGitHubTelemetryForwarding"], true); + + let id = request["id"].as_u64().unwrap(); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "ok": true, "protocolVersion": 3, "version": "test" }, + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + timeout(TIMEOUT, handle).await.unwrap().unwrap(); +} + +#[tokio::test] +async fn connect_omits_github_telemetry_forwarding_without_callback() { + let (client, mut server_read, mut server_write) = make_client(); + + let handle = tokio::spawn({ + let client = client.clone(); + async move { client.verify_protocol_version().await.unwrap() } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "connect"); + assert!( + request["params"] + .get("enableGitHubTelemetryForwarding") + .is_none_or(Value::is_null), + "forwarding flag should be omitted when no callback is registered" + ); + + let id = request["id"].as_u64().unwrap(); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "ok": true, "protocolVersion": 3, "version": "test" }, + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + timeout(TIMEOUT, handle).await.unwrap().unwrap(); +} + #[tokio::test] async fn github_telemetry_event_dispatches_to_callback() { use github_copilot_sdk::github_telemetry::GitHubTelemetryNotification; From 8b9164dffcc1af5f5798265ab78692df40f07459 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 3 Jul 2026 16:21:56 -0400 Subject: [PATCH 2/3] Address PR review: fix Python test split and tighten C# omit assertion - python/test_client.py: restore test_event_routes_to_handler as its own test method; the connect-omit test had accidentally absorbed the telemetry dispatch body, so keep it limited to the connect assertion. - dotnet/test/Unit/GitHubTelemetryTests.cs: tighten Connect_Does_Not_Opt_In_Without_Handler to require the flag be absent or null, so it fails if `false` is ever sent (matches the other SDKs). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/test/Unit/GitHubTelemetryTests.cs | 7 ++++--- python/test_client.py | 2 ++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/dotnet/test/Unit/GitHubTelemetryTests.cs b/dotnet/test/Unit/GitHubTelemetryTests.cs index a7f9e96b7..f82e0db6e 100644 --- a/dotnet/test/Unit/GitHubTelemetryTests.cs +++ b/dotnet/test/Unit/GitHubTelemetryTests.cs @@ -80,9 +80,10 @@ public async Task Connect_Does_Not_Opt_In_Without_Handler() await client.StartAsync(); var connectParams = server.LastConnectParams ?? throw new InvalidOperationException("connect was not captured."); - var optedIn = connectParams.TryGetProperty("enableGitHubTelemetryForwarding", out var flag) - && flag.ValueKind == JsonValueKind.True; - Assert.False(optedIn); + var present = connectParams.TryGetProperty("enableGitHubTelemetryForwarding", out var flag); + Assert.True( + !present || flag.ValueKind == JsonValueKind.Null, + "connect request should omit enableGitHubTelemetryForwarding (or send null) when no handler is registered"); } [Fact] diff --git a/python/test_client.py b/python/test_client.py index 9f518a4d4..5e1b8be63 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -2412,6 +2412,8 @@ async def request(self, method, params, **kwargs): await client._verify_protocol_version() assert "enableGitHubTelemetryForwarding" not in captured["connect"] + @pytest.mark.asyncio + async def test_event_routes_to_handler(self): from copilot.generated.rpc import GitHubTelemetryNotification received: list = [] From be6293919b14fb54793a88e690d3a326b7ea1336 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 3 Jul 2026 17:07:40 -0400 Subject: [PATCH 3/3] Avoid hand-editing generated connect types Move connect telemetry forwarding onto SDK-owned handshake payloads so generated RPC files stay aligned with the published schema while preserving the wire field name. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 13 ++++++++----- dotnet/src/Generated/Rpc.cs | 4 ---- go/client.go | 13 +++++++++++-- go/rpc/zrpc.go | 2 -- nodejs/src/client.ts | 24 ++++++++++++------------ nodejs/src/generated/rpc.ts | 4 ---- python/copilot/client.py | 25 ++++++++++++------------- python/copilot/generated/rpc.py | 14 +------------- 8 files changed, 44 insertions(+), 55 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index b1f06c4ba..72408e5c2 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1804,15 +1804,13 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio var connectResponse = await InvokeRpcAsync( connection.Rpc, "connect", - [new ConnectRequest - { - Token = token, + [new ConnectHandshakeRequest( + token, // Opt in to GitHub telemetry forwarding at the connection level when a // handler is registered (mirrors the runtime, which reads this flag on the // `connect` handshake so the first session's un-replayable `session.start` // event is forwarded). Also sent on session.create/resume for older CLIs. - EnableGitHubTelemetryForwarding = _options.OnGitHubTelemetry != null ? true : null, - }], + _options.OnGitHubTelemetry != null ? true : null)], connection.StderrBuffer, cancellationToken); serverVersion = (int)connectResponse.ProtocolVersion; @@ -2651,6 +2649,10 @@ internal record GetSessionMetadataRequest( internal record GetSessionMetadataResponse( SessionMetadata? Session); + internal record ConnectHandshakeRequest( + string? Token, + [property: JsonPropertyName("enableGitHubTelemetryForwarding")] bool? EnableGitHubTelemetryForwarding = null); + internal record SetForegroundSessionRequest( string SessionId); @@ -2685,6 +2687,7 @@ internal record HooksInvokeResponse( [JsonSerializable(typeof(ListSessionsResponse))] [JsonSerializable(typeof(GetSessionMetadataRequest))] [JsonSerializable(typeof(GetSessionMetadataResponse))] + [JsonSerializable(typeof(ConnectHandshakeRequest))] [JsonSerializable(typeof(McpOAuthTokenStorageMode))] [JsonSerializable(typeof(EmbeddingCacheStorageMode))] [JsonSerializable(typeof(ModelCapabilitiesOverride))] diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index 2a68a04e4..f46986d4c 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -65,10 +65,6 @@ internal sealed class ConnectResult [Experimental(Diagnostics.Experimental)] internal sealed class ConnectRequest { - /// Opt this connection in to GitHub telemetry forwarding for its lifetime. When set, the runtime forwards every internal telemetry event it emits — across all sessions, plus sessionless events — to this connection over the gitHubTelemetry.event notification, in addition to the runtime's normal GitHub/CTS emission (dual-write). Intended for first-party hosts that re-emit the events into their own telemetry stores. Both unrestricted and restricted events are forwarded, each tagged with a restricted discriminator; a backstop drops restricted events when restricted telemetry is disabled. - [JsonPropertyName("enableGitHubTelemetryForwarding")] - public bool? EnableGitHubTelemetryForwarding { get; set; } - /// Connection token; required when the server was started with COPILOT_CONNECTION_TOKEN. [JsonPropertyName("token")] public string? Token { get; set; } diff --git a/go/client.go b/go/client.go index 5f5d0b825..5357f2858 100644 --- a/go/client.go +++ b/go/client.go @@ -1685,7 +1685,7 @@ func (c *Client) verifyProtocolVersion(ctx context.Context) error { t := c.effectiveConnectionToken tokenPtr = &t } - connectReq := &rpc.ConnectRequest{Token: tokenPtr} + connectReq := &connectHandshakeRequest{Token: tokenPtr} // Opt in to GitHub telemetry forwarding at the connection level when a handler is // registered (mirrors the runtime, which reads this flag on the `connect` handshake // so the first session's un-replayable `session.start` event is forwarded). Also @@ -1693,7 +1693,7 @@ func (c *Client) verifyProtocolVersion(ctx context.Context) error { if c.options.OnGitHubTelemetry != nil { connectReq.EnableGitHubTelemetryForwarding = Bool(true) } - connectResult, err := c.internalRPC.Connect(ctx, connectReq) + rawConnectResult, err := c.client.Request(ctx, "connect", connectReq) if err != nil { var rpcErr *jsonrpc2.Error if errors.As(err, &rpcErr) && (rpcErr.Code == jsonrpc2.ErrMethodNotFound.Code || rpcErr.Message == "Unhandled method connect") { @@ -1708,6 +1708,10 @@ func (c *Client) verifyProtocolVersion(ctx context.Context) error { return err } } else { + var connectResult rpc.ConnectResult + if err := json.Unmarshal(rawConnectResult, &connectResult); err != nil { + return err + } v := int(connectResult.ProtocolVersion) serverVersion = &v } @@ -1724,6 +1728,11 @@ func (c *Client) verifyProtocolVersion(ctx context.Context) error { return nil } +type connectHandshakeRequest struct { + Token *string `json:"token,omitempty"` + EnableGitHubTelemetryForwarding *bool `json:"enableGitHubTelemetryForwarding,omitempty"` +} + // stderrBufferSize is the maximum number of bytes kept from the CLI process's // stderr. Only the tail is retained so that memory stays bounded even when the // process produces a large amount of diagnostic output. diff --git a/go/rpc/zrpc.go b/go/rpc/zrpc.go index d514fafa9..f6b0d31ac 100644 --- a/go/rpc/zrpc.go +++ b/go/rpc/zrpc.go @@ -1345,8 +1345,6 @@ type ConnectRemoteSessionParams struct { // Experimental: ConnectRequest is part of an experimental API and may change or be removed. // Internal: ConnectRequest is an internal SDK API and is not part of the public surface. type ConnectRequest struct { - // Opt this connection in to GitHub telemetry forwarding for its lifetime. When set, the runtime forwards every internal telemetry event it emits — across all sessions, plus sessionless events — to this connection over the `gitHubTelemetry.event` notification, in addition to the runtime's normal GitHub/CTS emission (dual-write). Intended for first-party hosts that re-emit the events into their own telemetry stores. Both unrestricted and restricted events are forwarded, each tagged with a `restricted` discriminator; a backstop drops restricted events when restricted telemetry is disabled. - EnableGitHubTelemetryForwarding *bool `json:"enableGitHubTelemetryForwarding,omitempty"` // Connection token; required when the server was started with COPILOT_CONNECTION_TOKEN Token *string `json:"token,omitempty"` } diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 5ea6796fc..9f430600c 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1846,18 +1846,18 @@ export class CopilotClient { let serverVersion: number | undefined; try { - const result = await raceAgainstExit( - this.internalRpc.connect({ - token: this.effectiveConnectionToken, - // Opt in to GitHub telemetry forwarding at the connection level when a - // handler is registered (mirrors the runtime, which reads this flag on the - // `connect` handshake so the first session's un-replayable `session.start` - // event is forwarded). Also sent on session.create/resume for older CLIs. - ...(this.onGitHubTelemetry != null - ? { enableGitHubTelemetryForwarding: true } - : {}), - }) - ); + const connectParams: { + token?: string; + enableGitHubTelemetryForwarding?: boolean; + } = { token: this.effectiveConnectionToken }; + // Opt in to GitHub telemetry forwarding at the connection level when a + // handler is registered (mirrors the runtime, which reads this flag on the + // `connect` handshake so the first session's un-replayable `session.start` + // event is forwarded). Also sent on session.create/resume for older CLIs. + if (this.onGitHubTelemetry != null) { + connectParams.enableGitHubTelemetryForwarding = true; + } + const result = await raceAgainstExit(this.internalRpc.connect(connectParams)); serverVersion = result.protocolVersion; } catch (err) { if ( diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index bc6593617..cd60dedcb 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -3697,10 +3697,6 @@ export interface ConnectRemoteSessionParams { /** @experimental */ /** @internal */ export interface ConnectRequest { - /** - * Opt this connection in to GitHub telemetry forwarding for its lifetime. When set, the runtime forwards every internal telemetry event it emits — across all sessions, plus sessionless events — to this connection over the `gitHubTelemetry.event` notification, in addition to the runtime's normal GitHub/CTS emission (dual-write). Intended for first-party hosts that re-emit the events into their own telemetry stores. Both unrestricted and restricted events are forwarded, each tagged with a `restricted` discriminator; a backstop drops restricted events when restricted telemetry is disabled. - */ - enableGitHubTelemetryForwarding?: boolean; /** * Connection token; required when the server was started with COPILOT_CONNECTION_TOKEN */ diff --git a/python/copilot/client.py b/python/copilot/client.py index 9d33186d9..55d01c5b5 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -70,8 +70,7 @@ OpenCanvasInstance, RemoteSessionMode, ServerRpc, - _ConnectRequest, - _InternalServerRpc, + _ConnectResult, from_datetime, register_client_global_api_handlers, register_client_session_api_handlers, @@ -3303,17 +3302,17 @@ async def _verify_protocol_version(self) -> None: server_version: int | None try: - connect_result = await _InternalServerRpc(self._client)._connect( - _ConnectRequest( - token=self._effective_connection_token, - # Opt in to GitHub telemetry forwarding at the connection level when a - # handler is registered (mirrors the runtime, which reads this flag on the - # `connect` handshake so the first session's un-replayable `session.start` - # event is forwarded). Also sent on session.create/resume for older CLIs. - enable_github_telemetry_forwarding=( - True if self._on_github_telemetry is not None else None - ), - ) + connect_params: dict[str, Any] = {} + if self._effective_connection_token is not None: + connect_params["token"] = self._effective_connection_token + # Opt in to GitHub telemetry forwarding at the connection level when a + # handler is registered (mirrors the runtime, which reads this flag on the + # `connect` handshake so the first session's un-replayable `session.start` + # event is forwarded). Also sent on session.create/resume for older CLIs. + if self._on_github_telemetry is not None: + connect_params["enableGitHubTelemetryForwarding"] = True + connect_result = _ConnectResult.from_dict( + await self._client.request("connect", connect_params) ) server_version = connect_result.protocol_version except JsonRpcError as err: diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index a0414a137..436f53211 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -1229,29 +1229,17 @@ def to_dict(self) -> dict: class _ConnectRequest: """Optional connection token presented by the SDK client during the handshake.""" - enable_github_telemetry_forwarding: bool | None = None - """Opt this connection in to GitHub telemetry forwarding for its lifetime. When set, the - runtime forwards every internal telemetry event it emits — across all sessions, plus - sessionless events — to this connection over the gitHubTelemetry.event notification, in - addition to the runtime's normal GitHub/CTS emission (dual-write). Intended for first-party - hosts that re-emit the events into their own telemetry stores. Both unrestricted and - restricted events are forwarded, each tagged with a restricted discriminator; a backstop - drops restricted events when restricted telemetry is disabled.""" - token: str | None = None """Connection token; required when the server was started with COPILOT_CONNECTION_TOKEN""" @staticmethod def from_dict(obj: Any) -> '_ConnectRequest': assert isinstance(obj, dict) - enable_github_telemetry_forwarding = from_union([from_bool, from_none], obj.get("enableGitHubTelemetryForwarding")) token = from_union([from_str, from_none], obj.get("token")) - return _ConnectRequest(enable_github_telemetry_forwarding, token) + return _ConnectRequest(token) def to_dict(self) -> dict: result: dict = {} - if self.enable_github_telemetry_forwarding is not None: - result["enableGitHubTelemetryForwarding"] = from_union([from_bool, from_none], self.enable_github_telemetry_forwarding) if self.token is not None: result["token"] = from_union([from_str, from_none], self.token) return result