From d2f5697d702a90191dc8a6e153ed9cc000d832d6 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 29 Jun 2026 14:01:22 -0700 Subject: [PATCH 01/26] codegen: add notification flag to RpcMethod metadata Adds an optional otification marker to the shared RpcMethod interface so the per-language generators can emit notification-style dispatch (no JSON-RPC reply) for void clientGlobal methods such as gitHubTelemetry.event. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/codegen/utils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/codegen/utils.ts b/scripts/codegen/utils.ts index c63f9732c4..9ab335b05f 100644 --- a/scripts/codegen/utils.ts +++ b/scripts/codegen/utils.ts @@ -383,6 +383,7 @@ export interface RpcMethod { stability?: string; visibility?: string; deprecated?: boolean; + notification?: boolean; } export function getRpcSchemaTypeName(schema: JSONSchema7 | null | undefined, fallback: string): string { From 8561b0658e6cd9c2d349b078065675a52cdb3288 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 29 Jun 2026 14:01:31 -0700 Subject: [PATCH 02/26] nodejs: add GitHub telemetry redirection support Regenerates the TypeScript RPC types for the experimental gitHubTelemetry.event clientGlobal notification and wires an onGitHubTelemetry callback on the client. Registering a handler opts created/resumed sessions into telemetry redirection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 35 ++++++--- nodejs/src/generated/rpc.ts | 136 ++++++++++++++++++++++++++++++++++ nodejs/src/index.ts | 3 + nodejs/src/types.ts | 18 +++++ nodejs/test/client.test.ts | 131 ++++++++++++++++++++++++++++++++ scripts/codegen/typescript.ts | 19 ++++- 6 files changed, 329 insertions(+), 13 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 53686a6ca3..761211423f 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -33,7 +33,7 @@ import { registerClientGlobalApiHandlers, registerClientSessionApiHandlers, } from "./generated/rpc.js"; -import type { OpenCanvasInstance, SessionUpdateOptionsParams } from "./generated/rpc.js"; +import type { GitHubTelemetryNotification, OpenCanvasInstance, SessionUpdateOptionsParams } from "./generated/rpc.js"; import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; import { CopilotSession } from "./session.js"; import { createSessionFsAdapter, type SessionFsProvider } from "./sessionFsProvider.js"; @@ -514,7 +514,8 @@ export class CopilotClient { /** Connection-level session filesystem config, set via constructor option. */ private sessionFsConfig: SessionFsConfig | null = null; private requestHandler: CopilotRequestHandler | null = null; - private llmInferenceHandlers: import("./generated/rpc.js").ClientGlobalApiHandlers = {}; + private onGitHubTelemetry?: (notification: GitHubTelemetryNotification) => void; + private clientGlobalHandlers: import("./generated/rpc.js").ClientGlobalApiHandlers = {}; /** * Typed server-scoped RPC methods. @@ -634,7 +635,8 @@ export class CopilotClient { this.onGetTraceContext = options.onGetTraceContext; this.sessionFsConfig = options.sessionFs ?? null; this.requestHandler = options.requestHandler ?? null; - this.setupLlmInference(); + this.onGitHubTelemetry = options.onGitHubTelemetry; + this.setupClientGlobalHandlers(); const effectiveEnv = options.env ?? process.env; this.resolvedEnv = effectiveEnv; @@ -751,19 +753,26 @@ export class CopilotClient { session.clientSessionApis.sessionFs = createSessionFsAdapter(provider); } - private setupLlmInference(): void { - if (!this.requestHandler) { - return; - } - this.llmInferenceHandlers = { - llmInference: createCopilotRequestAdapter(this.requestHandler, () => { + private setupClientGlobalHandlers(): void { + const handlers: import("./generated/rpc.js").ClientGlobalApiHandlers = {}; + if (this.requestHandler) { + handlers.llmInference = createCopilotRequestAdapter(this.requestHandler, () => { if (!this.connection) { return undefined; } this._rpc ??= createServerRpc(this.connection); return this._rpc; - }), - }; + }); + } + if (this.onGitHubTelemetry) { + const onGitHubTelemetry = this.onGitHubTelemetry; + handlers.gitHubTelemetry = { + event: async (notification) => { + onGitHubTelemetry(notification); + }, + }; + } + this.clientGlobalHandlers = handlers; } /** @@ -1422,6 +1431,7 @@ export class CopilotClient { workingDirectory: config.workingDirectory, streaming: config.streaming, includeSubAgentStreamingEvents: config.includeSubAgentStreamingEvents ?? true, + enableGitHubTelemetryRedirection: this.onGitHubTelemetry != null, mcpServers: toWireMcpServers(config.mcpServers), mcpOAuthTokenStorage: config.mcpOAuthTokenStorage, envValueMode: "direct", @@ -1628,6 +1638,7 @@ export class CopilotClient { enableSkills: config.enableSkills, streaming: config.streaming, includeSubAgentStreamingEvents: config.includeSubAgentStreamingEvents ?? true, + enableGitHubTelemetryRedirection: this.onGitHubTelemetry != null, mcpServers: toWireMcpServers(config.mcpServers), mcpOAuthTokenStorage: config.mcpOAuthTokenStorage, envValueMode: "direct", @@ -2545,7 +2556,7 @@ export class CopilotClient { // Register client *global* API handlers (e.g. LLM inference) on the // same connection. These methods carry no implicit sessionId dispatch // — the runtime calls into a single handler for the whole connection. - registerClientGlobalApiHandlers(this.connection, this.llmInferenceHandlers); + registerClientGlobalApiHandlers(this.connection, this.clientGlobalHandlers); this.connection.onClose(() => { this.state = "disconnected"; diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 38f77412d9..8c056de8d3 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -13729,6 +13729,125 @@ export interface WorkspacesSaveLargePasteResult { sizeBytes: number; } | null; } +/** + * Client environment metadata describing the process that produced a telemetry event. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "GitHubTelemetryClientInfo". + */ +/** @experimental */ +export interface GitHubTelemetryClientInfo { + /** + * Copilot CLI version string. + */ + cli_version: string; + /** + * Operating system platform (e.g. darwin, linux, win32). + */ + os_platform: string; + /** + * Operating system version string. + */ + os_version: string; + /** + * Operating system architecture (e.g. arm64, x64). + */ + os_arch: string; + /** + * Node.js runtime version string. + */ + node_version: string; + /** + * Copilot subscription plan, when known. + */ + copilot_plan?: string; + /** + * Type of client. + */ + client_type?: string; + /** + * Name of the client application. + */ + client_name?: string; + /** + * Whether the user is a GitHub/Microsoft staff member. + */ + is_staff?: boolean; + /** + * Stable machine identifier for the device. + */ + dev_device_id?: string; +} +/** + * A single telemetry event in the runtime's native GitHub-shaped telemetry format, forwarded verbatim to opted-in hosts. The `restricted` flag on the enclosing GitHubTelemetryNotification distinguishes standard from restricted events; the payload shape is identical for both. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "GitHubTelemetryEvent". + */ +/** @experimental */ +export interface GitHubTelemetryEvent { + /** + * Event type/kind (e.g. get_completion_with_tools_turn, tool_call_executed). + */ + kind: string; + /** + * Timestamp when the event was created (ISO 8601 format). + */ + created_at?: string; + /** + * Reference to the model call that produced this event. + */ + model_call_id?: string; + /** + * String-valued properties as a map from key to value. + */ + properties: { + [k: string]: string | undefined; + }; + /** + * Numeric metrics as a map from key to value. + */ + metrics: { + [k: string]: number | undefined; + }; + /** + * Experiment assignment context. + */ + exp_assignment_context?: string; + /** + * Feature flags enabled for this session, as a map from flag to value. + */ + features?: { + [k: string]: string | undefined; + }; + /** + * Session identifier the event belongs to. + */ + session_id?: string; + /** + * Copilot tracking ID for user-level attribution. + */ + copilot_tracking_id?: string; + client?: GitHubTelemetryClientInfo; +} +/** + * Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry redirection for the session. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "GitHubTelemetryNotification". + */ +/** @experimental */ +export interface GitHubTelemetryNotification { + /** + * Session the telemetry event belongs to. + */ + sessionId: string; + /** + * Whether this is a restricted telemetry event (cli.restricted_telemetry). Hosts must route restricted events to first-party Microsoft stores only. + */ + restricted: boolean; + event: GitHubTelemetryEvent; +} /** * Standard MCP CallToolResult * @@ -16155,9 +16274,21 @@ export interface LlmInferenceHandler { httpRequestChunk(params: LlmInferenceHttpRequestChunkRequest): Promise; } +/** Handler for `gitHubTelemetry` client global API methods. */ +/** @experimental */ +export interface GitHubTelemetryHandler { + /** + * Forwards a single GitHub telemetry event to a host connection that opted into telemetry redirection for the session. + * + * @param params Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry redirection for the session. + */ + event(params: GitHubTelemetryNotification): Promise; +} + /** All client global API handler groups. */ export interface ClientGlobalApiHandlers { llmInference?: LlmInferenceHandler; + gitHubTelemetry?: GitHubTelemetryHandler; } /** @@ -16181,4 +16312,9 @@ export function registerClientGlobalApiHandlers( if (!handler) throw new Error("No llmInference client-global handler registered"); return handler.httpRequestChunk(params); }); + connection.onNotification("gitHubTelemetry.event", async (params: GitHubTelemetryNotification) => { + const handler = handlers.gitHubTelemetry; + if (!handler) return; + await handler.event(params); + }); } diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index eebf9add5e..e05b33c158 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -76,6 +76,9 @@ export type { ForegroundSessionInfo, GetAuthStatusResponse, GetStatusResponse, + GitHubTelemetryNotification, + GitHubTelemetryEvent, + GitHubTelemetryClientInfo, InfiniteSessionConfig, LargeToolOutputConfig, MemoryConfiguration, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index e354bd8218..a050c3abd6 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -16,12 +16,18 @@ import type { } from "./generated/session-events.js"; import type { CopilotSession } from "./session.js"; import type { + GitHubTelemetryNotification, ModelBillingTokenPrices, OpenCanvasInstance, RemoteSessionMode, } from "./generated/rpc.js"; import type { ToolSet } from "./toolSet.js"; export type { RemoteSessionMode } from "./generated/rpc.js"; +export type { + GitHubTelemetryNotification, + GitHubTelemetryEvent, + GitHubTelemetryClientInfo, +} from "./generated/rpc.js"; export type { ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, @@ -338,6 +344,18 @@ export interface CopilotClientOptions { */ requestHandler?: CopilotRequestHandler; + /** + * Experimental. Receives GitHub telemetry events the runtime forwards to + * this connection. When set, the client opts each session it creates or + * resumes into telemetry redirection and dispatches each + * `gitHubTelemetry.event` notification to this connection-global handler; + * each {@link GitHubTelemetryNotification} carries its originating + * `sessionId`. + * + * @experimental + */ + onGitHubTelemetry?: (notification: GitHubTelemetryNotification) => void; + /** * Server-wide idle timeout for sessions in seconds. * Sessions without activity for this duration are automatically cleaned up. diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 96d7da30cf..cd07f902ca 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -7,6 +7,7 @@ import { CopilotClient, createCanvas, RuntimeConnection, + type GitHubTelemetryNotification, type ModelInfo, } from "../src/index.js"; import { CopilotSession } from "../src/session.js"; @@ -188,6 +189,136 @@ describe("CopilotClient", () => { expect(resumePayload.contextTier).toBe("default"); }); + it("opts into GitHub telemetry redirection when onGitHubTelemetry is provided", async () => { + const client = new CopilotClient({ onGitHubTelemetry: () => {} }); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId }; + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + await client.resumeSession(session.sessionId, { onPermissionRequest: approveAll }); + + const createPayload = spy.mock.calls.find( + ([method]) => method === "session.create" + )![1] as any; + const resumePayload = spy.mock.calls.find( + ([method]) => method === "session.resume" + )![1] as any; + expect(createPayload.enableGitHubTelemetryRedirection).toBe(true); + expect(resumePayload.enableGitHubTelemetryRedirection).toBe(true); + }); + + it("does not opt into GitHub telemetry redirection without a handler", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + + await client.createSession({ onPermissionRequest: approveAll }); + + const createPayload = spy.mock.calls.find( + ([method]) => method === "session.create" + )![1] as any; + expect(createPayload.enableGitHubTelemetryRedirection).toBe(false); + }); + + it("dispatches a real gitHubTelemetry.event wire notification to the handler", async () => { + const { createMessageConnection, StreamMessageReader, StreamMessageWriter } = await import( + "vscode-jsonrpc/node.js" + ); + const { registerClientGlobalApiHandlers } = await import("../src/generated/rpc.js"); + + const clientToServer = new PassThrough(); + const serverToClient = new PassThrough(); + + const clientConn = createMessageConnection( + new StreamMessageReader(serverToClient), + new StreamMessageWriter(clientToServer) + ); + const serverConn = createMessageConnection( + new StreamMessageReader(clientToServer), + new StreamMessageWriter(serverToClient) + ); + onTestFinished(() => { + clientConn.dispose(); + serverConn.dispose(); + }); + + const received: GitHubTelemetryNotification[] = []; + let resolveReceived: () => void; + const got = new Promise((resolve) => { + resolveReceived = resolve; + }); + + registerClientGlobalApiHandlers(clientConn, { + gitHubTelemetry: { + event: async (notification) => { + received.push(notification); + resolveReceived(); + }, + }, + }); + + clientConn.listen(); + serverConn.listen(); + + const notification: GitHubTelemetryNotification = { + sessionId: "session-1", + restricted: false, + event: { + kind: "tool_call_executed", + properties: { tool: "shell" }, + metrics: { duration_ms: 42 }, + }, + }; + + // Send as a real JSON-RPC notification (no id). A regression that wires + // this method up as a request handler would never fire and this await + // would hang. + await serverConn.sendNotification("gitHubTelemetry.event", notification); + await got; + + expect(received).toEqual([notification]); + }); + + it("registers no gitHubTelemetry handler when onGitHubTelemetry is omitted", () => { + const client = new CopilotClient(); + onTestFinished(() => client.forceStop()); + + const handlers = (client as any).clientGlobalHandlers; + expect(handlers.gitHubTelemetry).toBeUndefined(); + }); + + it("forwards gitHubTelemetry events to the onGitHubTelemetry handler", () => { + const received: GitHubTelemetryNotification[] = []; + const client = new CopilotClient({ onGitHubTelemetry: (n) => received.push(n) }); + onTestFinished(() => client.forceStop()); + + const handlers = (client as any).clientGlobalHandlers; + expect(handlers.gitHubTelemetry).toBeDefined(); + + const notification: GitHubTelemetryNotification = { + sessionId: "session-1", + restricted: false, + event: { kind: "tool_call_executed", properties: {}, metrics: {} }, + }; + handlers.gitHubTelemetry.event(notification); + expect(received).toEqual([notification]); + }); + it("forwards expAssignments in session.create and session.resume", async () => { const client = new CopilotClient(); await client.start(); diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index 1303a4979c..497c909ea5 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -1011,7 +1011,24 @@ function emitClientGlobalApiRegistration(clientSchema: Record): const pType = paramsTypeName(method); const hasParams = hasSchemaPayload(getMethodParamsSchema(method)); - if (hasParams) { + if (method.notification) { + // Notification methods carry no response; the server dispatches + // them via `sendNotification`, which only fires `onNotification` + // handlers (an `onRequest` handler would never be invoked). + if (hasParams) { + lines.push(` connection.onNotification("${method.rpcMethod}", async (params: ${pType}) => {`); + lines.push(` const handler = handlers.${groupName};`); + lines.push(` if (!handler) return;`); + lines.push(` await handler.${name}(params);`); + lines.push(` });`); + } else { + lines.push(` connection.onNotification("${method.rpcMethod}", async () => {`); + lines.push(` const handler = handlers.${groupName};`); + lines.push(` if (!handler) return;`); + lines.push(` await handler.${name}();`); + lines.push(` });`); + } + } else if (hasParams) { lines.push(` connection.onRequest("${method.rpcMethod}", async (params: ${pType}) => {`); lines.push(` const handler = handlers.${groupName};`); lines.push(` if (!handler) throw new Error("No ${groupName} client-global handler registered");`); From fc5638a6a2cdfa000f4ebf652815a8db3b6a89f0 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 29 Jun 2026 14:01:38 -0700 Subject: [PATCH 03/26] dotnet: add GitHub telemetry redirection support Regenerates the C# RPC types for the experimental gitHubTelemetry.event clientGlobal notification and adds an OnGitHubTelemetry callback. Registering a handler opts created/resumed sessions into telemetry redirection. The option is marked [Experimental] and [EditorBrowsable(Never)] to keep it unadvertised. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 42 ++- dotnet/src/Generated/Rpc.cs | 128 +++++++ dotnet/src/Types.cs | 9 + dotnet/test/Unit/GitHubTelemetryTests.cs | 430 +++++++++++++++++++++++ 4 files changed, 601 insertions(+), 8 deletions(-) create mode 100644 dotnet/test/Unit/GitHubTelemetryTests.cs diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index a67eb96817..aaff9005fa 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging.Abstractions; using System.Collections.Concurrent; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Net.Sockets; using System.Runtime.ExceptionServices; @@ -1033,7 +1034,8 @@ public async Task CreateSessionAsync(SessionConfig config, Cance Providers: config.Providers, Models: config.Models, ToolFilterPrecedence: toolFilter.ToolFilterPrecedence, - ExpAssignments: config.ExpAssignments); + ExpAssignments: config.ExpAssignments, + EnableGitHubTelemetryRedirection: _options.OnGitHubTelemetry != null ? true : null); var rpcTimestamp = Stopwatch.GetTimestamp(); @@ -1235,7 +1237,8 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes Providers: config.Providers, Models: config.Models, ToolFilterPrecedence: toolFilter.ToolFilterPrecedence, - ExpAssignments: config.ExpAssignments); + ExpAssignments: config.ExpAssignments, + EnableGitHubTelemetryRedirection: _options.OnGitHubTelemetry != null ? true : null); var rpcTimestamp = Stopwatch.GetTimestamp(); var response = await InvokeRpcAsync( @@ -1708,21 +1711,24 @@ await Rpc.SessionFs.SetProviderAsync( } /// - /// Builds the client-global RPC handler bag at construction time. Currently - /// only the LLM inference provider adapter is registered; returns null when no + /// Builds the client-global RPC handler bag at construction time. Registers + /// the LLM inference provider adapter and/or the GitHub telemetry adapter + /// depending on which options are configured; returns null when no /// client-global API is configured so the registration is skipped entirely. /// private ClientGlobalApiHandlers? BuildClientGlobalApis() { var handler = _options.RequestHandler; - if (handler is null) + var onGitHubTelemetry = _options.OnGitHubTelemetry; + if (handler is null && onGitHubTelemetry is null) { return null; } return new ClientGlobalApiHandlers { - LlmInference = new LlmInferenceAdapter(handler, () => _serverRpc), + LlmInference = handler is null ? null : new LlmInferenceAdapter(handler, () => _serverRpc), + GitHubTelemetry = onGitHubTelemetry is null ? null : new GitHubTelemetryAdapter(onGitHubTelemetry), }; } @@ -2476,7 +2482,8 @@ internal record CreateSessionRequest( IList? Providers = null, IList? Models = null, OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null, - [property: JsonPropertyName("expAssignments")] JsonElement? ExpAssignments = null); + [property: JsonPropertyName("expAssignments")] JsonElement? ExpAssignments = null, + bool? EnableGitHubTelemetryRedirection = null); #pragma warning restore GHCP001 internal record ToolDefinition( @@ -2572,7 +2579,8 @@ internal record ResumeSessionRequest( IList? Providers = null, IList? Models = null, OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null, - [property: JsonPropertyName("expAssignments")] JsonElement? ExpAssignments = null); + [property: JsonPropertyName("expAssignments")] JsonElement? ExpAssignments = null, + bool? EnableGitHubTelemetryRedirection = null); #pragma warning restore GHCP001 internal record ResumeSessionResponse( @@ -2690,3 +2698,21 @@ public sealed class ToolResultAIContent(ToolResultObject toolResult) : AIContent /// public ToolResultObject Result => toolResult; } + +/// +/// Bridges the generated client-global handler to +/// the public OnGitHubTelemetry callback, forwarding the generated +/// payload unchanged. +/// +[Experimental(Diagnostics.Experimental)] +internal sealed class GitHubTelemetryAdapter(Action callback) : Rpc.IGitHubTelemetryHandler +{ + private readonly Action _callback = callback ?? throw new ArgumentNullException(nameof(callback)); + + public Task EventAsync(Rpc.GitHubTelemetryNotification request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + _callback(request); + return Task.CompletedTask; + } +} diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index 3a9fcf9cde..559a1f11f4 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -10891,6 +10891,113 @@ public sealed class LlmInferenceHttpRequestChunkRequest public string RequestId { get; set; } = string.Empty; } +/// Client environment metadata describing the process that produced a telemetry event. +[Experimental(Diagnostics.Experimental)] +public sealed class GitHubTelemetryClientInfo +{ + /// Copilot CLI version string. + [JsonPropertyName("cli_version")] + public string CliVersion { get; set; } = string.Empty; + + /// Name of the client application. + [JsonPropertyName("client_name")] + public string? ClientName { get; set; } + + /// Type of client. + [JsonPropertyName("client_type")] + public string? ClientType { get; set; } + + /// Copilot subscription plan, when known. + [JsonPropertyName("copilot_plan")] + public string? CopilotPlan { get; set; } + + /// Stable machine identifier for the device. + [JsonPropertyName("dev_device_id")] + public string? DevDeviceId { get; set; } + + /// Whether the user is a GitHub/Microsoft staff member. + [JsonPropertyName("is_staff")] + public bool? IsStaff { get; set; } + + /// Node.js runtime version string. + [JsonPropertyName("node_version")] + public string NodeVersion { get; set; } = string.Empty; + + /// Operating system architecture (e.g. arm64, x64). + [JsonPropertyName("os_arch")] + public string OsArch { get; set; } = string.Empty; + + /// Operating system platform (e.g. darwin, linux, win32). + [JsonPropertyName("os_platform")] + public string OsPlatform { get; set; } = string.Empty; + + /// Operating system version string. + [JsonPropertyName("os_version")] + public string OsVersion { get; set; } = string.Empty; +} + +/// A single telemetry event in the runtime's native GitHub-shaped telemetry format, forwarded verbatim to opted-in hosts. The `restricted` flag on the enclosing GitHubTelemetryNotification distinguishes standard from restricted events; the payload shape is identical for both. +[Experimental(Diagnostics.Experimental)] +public sealed class GitHubTelemetryEvent +{ + /// Client environment metadata. + [JsonPropertyName("client")] + public GitHubTelemetryClientInfo? Client { get; set; } + + /// Copilot tracking ID for user-level attribution. + [JsonPropertyName("copilot_tracking_id")] + public string? CopilotTrackingId { get; set; } + + /// Timestamp when the event was created (ISO 8601 format). + [JsonPropertyName("created_at")] + public string? CreatedAt { get; set; } + + /// Experiment assignment context. + [JsonPropertyName("exp_assignment_context")] + public string? ExpAssignmentContext { get; set; } + + /// Feature flags enabled for this session, as a map from flag to value. + [JsonPropertyName("features")] + public IDictionary? Features { get; set; } + + /// Event type/kind (e.g. get_completion_with_tools_turn, tool_call_executed). + [JsonPropertyName("kind")] + public string Kind { get; set; } = string.Empty; + + /// Numeric metrics as a map from key to value. + [JsonPropertyName("metrics")] + public IDictionary Metrics { get => field ??= new Dictionary(); set; } + + /// Reference to the model call that produced this event. + [JsonPropertyName("model_call_id")] + public string? ModelCallId { get; set; } + + /// String-valued properties as a map from key to value. + [JsonPropertyName("properties")] + public IDictionary Properties { get => field ??= new Dictionary(); set; } + + /// Session identifier the event belongs to. + [JsonPropertyName("session_id")] + public string? SessionId { get; set; } +} + +/// Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry redirection for the session. +[Experimental(Diagnostics.Experimental)] +public sealed class GitHubTelemetryNotification +{ + /// The telemetry event, in the runtime's native GitHub-shaped telemetry format. + [JsonPropertyName("event")] + public GitHubTelemetryEvent Event { get => field ??= new(); set; } + + /// Whether this is a restricted telemetry event (cli.restricted_telemetry). Hosts must route restricted events to first-party Microsoft stores only. + [JsonPropertyName("restricted")] + public bool Restricted { get; set; } + + /// Session the telemetry event belongs to. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + /// Model capability category for grouping in the model picker. [JsonConverter(typeof(Converter))] [DebuggerDisplay("{Value,nq}")] @@ -20914,11 +21021,24 @@ public interface ILlmInferenceHandler Task HttpRequestChunkAsync(LlmInferenceHttpRequestChunkRequest request, CancellationToken cancellationToken = default); } +/// Handles `gitHubTelemetry` client global API methods. +[Experimental(Diagnostics.Experimental)] +public interface IGitHubTelemetryHandler +{ + /// Forwards a single GitHub telemetry event to a host connection that opted into telemetry redirection for the session. + /// Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry redirection for the session. + /// The to monitor for cancellation requests. The default is . + Task EventAsync(GitHubTelemetryNotification request, CancellationToken cancellationToken = default); +} + /// Provides all client global API handler groups for a connection. public sealed class ClientGlobalApiHandlers { /// Optional handler for LlmInference client global API methods. public ILlmInferenceHandler? LlmInference { get; set; } + + /// Optional handler for GitHubTelemetry client global API methods. + public IGitHubTelemetryHandler? GitHubTelemetry { get; set; } } /// Registers client global API handlers on a JSON-RPC connection. @@ -20942,6 +21062,11 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH var handler = handlers.LlmInference ?? throw new InvalidOperationException("No llmInference client-global handler registered"); return await handler.HttpRequestChunkAsync(request, cancellationToken); }), singleObjectParam: true); + rpc.SetLocalRpcMethod("gitHubTelemetry.event", (Func)(async (request, cancellationToken) => + { + var handler = handlers.GitHubTelemetry ?? throw new InvalidOperationException("No gitHubTelemetry client-global handler registered"); + await handler.EventAsync(request, cancellationToken); + }), singleObjectParam: true); } } @@ -21313,6 +21438,9 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(FolderTrustAddParams))] [JsonSerializable(typeof(FolderTrustCheckParams))] [JsonSerializable(typeof(FolderTrustCheckResult))] +[JsonSerializable(typeof(GitHubTelemetryClientInfo))] +[JsonSerializable(typeof(GitHubTelemetryEvent))] +[JsonSerializable(typeof(GitHubTelemetryNotification))] [JsonSerializable(typeof(HandlePendingToolCallRequest))] [JsonSerializable(typeof(HandlePendingToolCallResult))] [JsonSerializable(typeof(HistoryAbortManualCompactionResult))] diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 5ae9657813..aa69f2a069 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -281,6 +281,7 @@ private CopilotClientOptions(CopilotClientOptions? other) OnListModels = other.OnListModels; SessionFs = other.SessionFs; RequestHandler = other.RequestHandler; + OnGitHubTelemetry = other.OnGitHubTelemetry; SessionIdleTimeoutSeconds = other.SessionIdleTimeoutSeconds; EnableRemoteSessions = other.EnableRemoteSessions; Mode = other.Mode; @@ -378,6 +379,14 @@ private CopilotClientOptions(CopilotClientOptions? other) [Experimental(Diagnostics.Experimental)] public CopilotRequestHandler? RequestHandler { get; set; } + /// + /// Experimental. Receives GitHub telemetry events the runtime forwards to this + /// connection; setting a handler opts created/resumed sessions into redirection. + /// + [Experimental(Diagnostics.Experimental)] + [EditorBrowsable(EditorBrowsableState.Never)] + public Action? OnGitHubTelemetry { get; set; } + /// /// OpenTelemetry configuration for the runtime. /// When set to a non- instance, the runtime is started with OpenTelemetry instrumentation enabled. diff --git a/dotnet/test/Unit/GitHubTelemetryTests.cs b/dotnet/test/Unit/GitHubTelemetryTests.cs new file mode 100644 index 0000000000..465791f649 --- /dev/null +++ b/dotnet/test/Unit/GitHubTelemetryTests.cs @@ -0,0 +1,430 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +#if NET8_0_OR_GREATER +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using Xunit; + +using GitHub.Copilot.Rpc; + +namespace GitHub.Copilot.Test.Unit; + +#pragma warning disable GHCP001 // GitHub telemetry redirection is experimental. + +public sealed class GitHubTelemetryTests +{ + [Fact] + public async Task CreateSession_Opts_Into_Redirection_When_Handler_Provided() + { + await using var server = await FakeTelemetryServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions + { + Connection = RuntimeConnection.ForUri(server.Url), + OnGitHubTelemetry = _ => { }, + }); + await client.StartAsync(); + + await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); + + var createParams = server.LastCreateParams ?? throw new InvalidOperationException("session.create was not captured."); + Assert.True(createParams.TryGetProperty("enableGitHubTelemetryRedirection", out var flag)); + Assert.True(flag.GetBoolean()); + } + + [Fact] + public async Task ResumeSession_Opts_Into_Redirection_When_Handler_Provided() + { + await using var server = await FakeTelemetryServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions + { + Connection = RuntimeConnection.ForUri(server.Url), + OnGitHubTelemetry = _ => { }, + }); + await client.StartAsync(); + + await client.ResumeSessionAsync("session-1", new ResumeSessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); + + var resumeParams = server.LastResumeParams ?? throw new InvalidOperationException("session.resume was not captured."); + Assert.True(resumeParams.TryGetProperty("enableGitHubTelemetryRedirection", out var flag)); + Assert.True(flag.GetBoolean()); + } + + [Fact] + public async Task CreateSession_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(); + + await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); + + var createParams = server.LastCreateParams ?? throw new InvalidOperationException("session.create was not captured."); + var optedIn = createParams.TryGetProperty("enableGitHubTelemetryRedirection", out var flag) + && flag.ValueKind == JsonValueKind.True; + Assert.False(optedIn); + } + + [Fact] + public async Task GitHubTelemetry_Event_Is_Forwarded_To_OnGitHubTelemetry() + { + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var server = await FakeTelemetryServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions + { + Connection = RuntimeConnection.ForUri(server.Url), + OnGitHubTelemetry = notification => received.TrySetResult(notification), + }); + await client.StartAsync(); + + await server.SendGitHubTelemetryEventAsync(new Dictionary + { + ["sessionId"] = "session-1", + ["restricted"] = false, + ["event"] = new Dictionary + { + ["kind"] = "tool_call_executed", + ["properties"] = new Dictionary { ["tool"] = "shell" }, + ["metrics"] = new Dictionary { ["duration_ms"] = 42 }, + ["session_id"] = "session-1", + }, + }); + + var notification = await received.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal("session-1", notification.SessionId); + Assert.False(notification.Restricted); + Assert.Equal("tool_call_executed", notification.Event.Kind); + Assert.Equal("shell", notification.Event.Properties["tool"]); + Assert.Equal(42, notification.Event.Metrics["duration_ms"]); + Assert.Equal("session-1", notification.Event.SessionId); + } + + [Fact] + public async Task GitHubTelemetry_Event_Maps_Restricted_And_ClientInfo() + { + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var server = await FakeTelemetryServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions + { + Connection = RuntimeConnection.ForUri(server.Url), + OnGitHubTelemetry = notification => received.TrySetResult(notification), + }); + await client.StartAsync(); + + await server.SendGitHubTelemetryEventAsync(new Dictionary + { + ["sessionId"] = "session-2", + ["restricted"] = true, + ["event"] = new Dictionary + { + ["kind"] = "model_call", + ["properties"] = new Dictionary { ["model"] = "gpt-5" }, + ["metrics"] = new Dictionary { ["tokens"] = 128 }, + ["session_id"] = "session-2", + ["client"] = new Dictionary + { + ["cli_version"] = "1.2.3", + ["os_platform"] = "win32", + ["os_arch"] = "x64", + ["node_version"] = "20.0.0", + ["is_staff"] = false, + }, + }, + }); + + var notification = await received.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.True(notification.Restricted); + + var clientInfo = notification.Event.Client; + Assert.NotNull(clientInfo); + Assert.Equal("1.2.3", clientInfo!.CliVersion); + Assert.Equal("win32", clientInfo.OsPlatform); + Assert.Equal("x64", clientInfo.OsArch); + Assert.Equal("20.0.0", clientInfo.NodeVersion); + Assert.Equal(false, clientInfo.IsStaff); + } + + private sealed class FakeTelemetryServer : IAsyncDisposable + { + private readonly TcpListener _listener; + private readonly CancellationTokenSource _cts = new(); + private readonly SemaphoreSlim _writeLock = new(1, 1); + private readonly TaskCompletionSource _connected = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Task _serverTask; + + private FakeTelemetryServer(TcpListener listener) + { + _listener = listener; + _serverTask = RunAsync(); + } + + public string Url + { + get + { + var endpoint = (IPEndPoint)_listener.LocalEndpoint; + return $"http://127.0.0.1:{endpoint.Port}"; + } + } + + public JsonElement? LastCreateParams { get; private set; } + + public JsonElement? LastResumeParams { get; private set; } + + public static Task StartAsync() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return Task.FromResult(new FakeTelemetryServer(listener)); + } + + public async Task SendGitHubTelemetryEventAsync(Dictionary notificationParams) + { + var stream = await _connected.Task.WaitAsync(_cts.Token); + + // Send a genuine JSON-RPC notification (no "id"), exactly as the runtime + // does via sendNotification. This exercises the real notification dispatch + // path rather than masking it behind a request that carries an id. + await WriteMessageAsync(stream, new Dictionary + { + ["jsonrpc"] = "2.0", + ["method"] = "gitHubTelemetry.event", + ["params"] = notificationParams, + }, _cts.Token); + } + + public async ValueTask DisposeAsync() + { + _cts.Cancel(); + _listener.Stop(); + + try + { + await _serverTask; + } + catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or IOException or SocketException) + { + } + + _cts.Dispose(); + _writeLock.Dispose(); + } + + private async Task RunAsync() + { + using var tcpClient = await _listener.AcceptTcpClientAsync(_cts.Token); + using var stream = tcpClient.GetStream(); + _connected.TrySetResult(stream); + + while (!_cts.Token.IsCancellationRequested) + { + using var message = await ReadMessageAsync(stream, _cts.Token); + if (message is null) + { + return; + } + + // Inbound messages without a "method" are responses to our own + // server-initiated requests (e.g. session.* the SDK answers); the + // SDK never replies to the gitHubTelemetry.event notification. + if (!message.RootElement.TryGetProperty("method", out _)) + { + continue; + } + + await HandleRequestAsync(stream, message.RootElement, _cts.Token); + } + } + + private async Task HandleRequestAsync(Stream stream, JsonElement request, CancellationToken cancellationToken) + { + if (!request.TryGetProperty("id", out var idElement)) + { + return; + } + + var id = idElement.Clone(); + var method = request.GetProperty("method").GetString(); + + object? result = method switch + { + "connect" => new Dictionary + { + ["ok"] = true, + ["protocolVersion"] = 3, + ["version"] = "test", + }, + "session.create" => CaptureCreate(request), + "session.resume" => CaptureResume(request), + "session.send" => new Dictionary { ["messageId"] = "message-1" }, + "session.destroy" => new Dictionary(), + "runtime.shutdown" => new Dictionary(), + _ => throw new InvalidOperationException($"Unexpected RPC method '{method}'."), + }; + + await WriteMessageAsync(stream, new Dictionary + { + ["jsonrpc"] = "2.0", + ["id"] = id, + ["result"] = result, + }, cancellationToken); + } + + private Dictionary CaptureCreate(JsonElement request) + { + LastCreateParams = request.TryGetProperty("params", out var p) ? p.Clone() : null; + return SessionResult(LastCreateParams); + } + + private Dictionary CaptureResume(JsonElement request) + { + LastResumeParams = request.TryGetProperty("params", out var p) ? p.Clone() : null; + return SessionResult(LastResumeParams); + } + + private static Dictionary SessionResult(JsonElement? paramsElement) + { + string sessionId = "session-1"; + if (paramsElement is { ValueKind: JsonValueKind.Object } p + && p.TryGetProperty("sessionId", out var sidProp) + && sidProp.ValueKind == JsonValueKind.String + && sidProp.GetString() is string sid + && !string.IsNullOrEmpty(sid)) + { + sessionId = sid; + } + + return new Dictionary + { + ["sessionId"] = sessionId, + ["workspacePath"] = null, + ["capabilities"] = null, + }; + } + + private async Task WriteMessageAsync(Stream stream, object payload, CancellationToken cancellationToken) + { + using var bodyStream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(bodyStream)) + { + WriteJsonValue(writer, payload); + } + + var body = bodyStream.ToArray(); + var header = Encoding.ASCII.GetBytes($"Content-Length: {body.Length}\r\n\r\n"); + + await _writeLock.WaitAsync(cancellationToken); + try + { + await stream.WriteAsync(header, cancellationToken); + await stream.WriteAsync(body, cancellationToken); + await stream.FlushAsync(cancellationToken); + } + finally + { + _writeLock.Release(); + } + } + + private static void WriteJsonValue(Utf8JsonWriter writer, object? value) + { + switch (value) + { + case null: + writer.WriteNullValue(); + break; + case string stringValue: + writer.WriteStringValue(stringValue); + break; + case bool boolValue: + writer.WriteBooleanValue(boolValue); + break; + case int intValue: + writer.WriteNumberValue(intValue); + break; + case long longValue: + writer.WriteNumberValue(longValue); + break; + case JsonElement jsonElement: + jsonElement.WriteTo(writer); + break; + case Dictionary dictionary: + writer.WriteStartObject(); + foreach (var (propertyName, propertyValue) in dictionary) + { + writer.WritePropertyName(propertyName); + WriteJsonValue(writer, propertyValue); + } + writer.WriteEndObject(); + break; + default: + throw new InvalidOperationException($"Unexpected JSON value type '{value.GetType().Name}'."); + } + } + + private static async Task ReadMessageAsync(Stream stream, CancellationToken cancellationToken) + { + var headerBytes = new List(); + while (true) + { + var value = await ReadByteAsync(stream, cancellationToken); + if (value < 0) + { + return null; + } + + headerBytes.Add((byte)value); + var count = headerBytes.Count; + if (count >= 4 && + headerBytes[count - 4] == '\r' && + headerBytes[count - 3] == '\n' && + headerBytes[count - 2] == '\r' && + headerBytes[count - 1] == '\n') + { + break; + } + } + + var header = Encoding.ASCII.GetString([.. headerBytes]); + var contentLength = header + .Split(["\r\n"], StringSplitOptions.RemoveEmptyEntries) + .Select(line => line.Split(':', 2)) + .Where(parts => parts.Length == 2 && parts[0].Equals("Content-Length", StringComparison.OrdinalIgnoreCase)) + .Select(parts => int.Parse(parts[1].Trim(), System.Globalization.CultureInfo.InvariantCulture)) + .Single(); + + var body = new byte[contentLength]; + var offset = 0; + while (offset < body.Length) + { + var read = await stream.ReadAsync(body.AsMemory(offset, body.Length - offset), cancellationToken); + if (read == 0) + { + return null; + } + + offset += read; + } + + return JsonDocument.Parse(body); + } + + private static async Task ReadByteAsync(Stream stream, CancellationToken cancellationToken) + { + var buffer = new byte[1]; + var read = await stream.ReadAsync(buffer, cancellationToken); + return read == 0 ? -1 : buffer[0]; + } + } +} + +#pragma warning restore GHCP001 +#endif From ca4c483494e099b9e525f8aa3b5c58aa6223ea0b Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 29 Jun 2026 14:01:45 -0700 Subject: [PATCH 04/26] python: add GitHub telemetry redirection support Regenerates the Python RPC types for the experimental gitHubTelemetry.event clientGlobal notification and adds an on_github_telemetry callback. Registering a handler opts created/resumed sessions into telemetry redirection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/copilot/_jsonrpc.py | 44 ++++++- python/copilot/client.py | 62 ++++++++-- python/copilot/generated/rpc.py | 208 +++++++++++++++++++++++++++++++- python/test_client.py | 180 +++++++++++++++++++++++++++ scripts/codegen/python.ts | 14 +++ 5 files changed, 496 insertions(+), 12 deletions(-) diff --git a/python/copilot/_jsonrpc.py b/python/copilot/_jsonrpc.py index a58908d08d..5e799149e0 100644 --- a/python/copilot/_jsonrpc.py +++ b/python/copilot/_jsonrpc.py @@ -80,6 +80,7 @@ def __init__(self, process): self.pending_requests: dict[str, asyncio.Future] = {} self._pending_inline_callbacks: dict[str, Callable[[Any], None]] = {} self.notification_handler: Callable[[str, dict], None] | None = None + self.notification_method_handlers: dict[str, Callable[[dict], Any]] = {} self.request_handlers: dict[str, RequestHandler] = {} self._running = False self._read_thread: threading.Thread | None = None @@ -232,6 +233,21 @@ def set_notification_handler(self, handler: Callable[[str, dict], None]): """Set the handler for incoming notifications from the server.""" self.notification_handler = handler + def set_notification_method_handler( + self, method: str, handler: Callable[[dict], Any] | None + ): + """Register a handler for a specific server-to-client notification method. + + Notifications carry no ``id`` and expect no response, so they are + dispatched separately from request handlers. A registered method + handler takes precedence over the generic notification handler. The + handler may be a coroutine function; its result is awaited. + """ + if handler is None: + self.notification_method_handlers.pop(method, None) + else: + self.notification_method_handlers[method] = handler + def set_request_handler(self, method: str, handler: RequestHandler): if handler is None: self.request_handlers.pop(method, None) @@ -397,9 +413,14 @@ def _handle_message(self, message: dict): # Check if it's a notification from the server if "method" in message and "id" not in message: + method = message["method"] + params = message.get("params", {}) + handler = self.notification_method_handlers.get(method) + if handler is not None and self._loop: + # Method-specific notification handler takes precedence. + self._loop.call_soon_threadsafe(self._dispatch_notification, handler, params) + return if self.notification_handler and self._loop: - method = message["method"] - params = message.get("params", {}) # Schedule notification handler on the event loop for thread safety self._loop.call_soon_threadsafe(self.notification_handler, method, params) return @@ -427,6 +448,25 @@ def _handle_request(self, message: dict): self._loop, ) + def _dispatch_notification(self, handler: Callable[[dict], Any], params: dict): + """Invoke a method-specific notification handler. Runs on the event loop; + coroutine results are scheduled and any error is logged (notifications + carry no response, so failures never propagate to the server).""" + try: + outcome = handler(params) + except Exception: # pylint: disable=broad-except + logger.warning("Notification handler raised", exc_info=True) + return + if inspect.isawaitable(outcome): + + async def _await_outcome(): + try: + await outcome + except Exception: # pylint: disable=broad-except + logger.warning("Notification handler raised", exc_info=True) + + asyncio.ensure_future(_await_outcome()) + async def _dispatch_request(self, message: dict, handler: RequestHandler): try: params = message.get("params", {}) diff --git a/python/copilot/client.py b/python/copilot/client.py index c7d11d12b1..0560741d18 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -64,6 +64,7 @@ from .generated.rpc import ( ClientGlobalApiHandlers, ClientSessionApiHandlers, + GitHubTelemetryNotification, ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, # noqa: F401 OpenCanvasInstance, @@ -389,6 +390,20 @@ class UriRuntimeConnection(RuntimeConnection): """Shared secret to authenticate the connection.""" +class _GitHubTelemetryAdapter: + """Adapts a user-provided ``on_github_telemetry`` callback to the generated + ``GitHubTelemetryHandler`` protocol. + """ + + def __init__( + self, callback: Callable[[GitHubTelemetryNotification], None] + ) -> None: + self._callback = callback + + async def event(self, params: GitHubTelemetryNotification) -> None: + self._callback(params) + + @dataclass class _CopilotClientOptions: """Internal configuration carrier used by :class:`CopilotClient`. @@ -410,6 +425,7 @@ class _CopilotClientOptions: session_idle_timeout_seconds: int | None = None enable_remote_sessions: bool = False on_list_models: Callable[[], list[ModelInfo] | Awaitable[list[ModelInfo]]] | None = None + on_github_telemetry: Callable[[GitHubTelemetryNotification], None] | None = None mode: CopilotClientMode = "copilot-cli" @@ -1099,6 +1115,7 @@ def __init__( session_idle_timeout_seconds: int | None = None, enable_remote_sessions: bool = False, on_list_models: Callable[[], list[ModelInfo] | Awaitable[list[ModelInfo]]] | None = None, + on_github_telemetry: Callable[[GitHubTelemetryNotification], None] | None = None, mode: CopilotClientMode = "copilot-cli", ): """ @@ -1143,6 +1160,10 @@ def __init__( on_list_models: Custom handler for :meth:`list_models`. When provided, the handler is called instead of querying the runtime server. + on_github_telemetry: Internal. Callback invoked when the runtime + forwards a GitHub telemetry event for a session. Registering a + handler opts every session opened by this client into telemetry + redirection. Example: >>> # Default — spawns runtime using stdio with the bundled binary @@ -1173,6 +1194,7 @@ def __init__( session_idle_timeout_seconds=session_idle_timeout_seconds, enable_remote_sessions=enable_remote_sessions, on_list_models=on_list_models, + on_github_telemetry=on_github_telemetry, mode=mode, ) connection = ( @@ -1188,6 +1210,7 @@ def __init__( self._options: _CopilotClientOptions = options self._connection: RuntimeConnection = connection self._on_list_models = options.on_list_models + self._on_github_telemetry = options.on_github_telemetry # Resolve connection-mode-specific state. self._actual_host: str = "localhost" @@ -1980,6 +2003,11 @@ async def create_session( else True ) + # Opt this connection into gitHubTelemetry.event notifications when a + # telemetry handler was registered on the client. + if self._on_github_telemetry is not None: + payload["enableGitHubTelemetryRedirection"] = True + # Add provider configuration if provided if provider: payload["provider"] = self._convert_provider_to_wire_format(provider) @@ -2568,6 +2596,11 @@ async def resume_session( else True ) + # Opt this connection into gitHubTelemetry.event notifications when a + # telemetry handler was registered on the client. + if self._on_github_telemetry is not None: + payload["enableGitHubTelemetryRedirection"] = True + # Enable permission request callback if handler provided payload["requestPermission"] = bool(on_permission_request) @@ -3632,7 +3665,7 @@ def handle_notification(method: str, params: dict): "systemMessage.transform", self._handle_system_message_transform ) register_client_session_api_handlers(self._client, self._get_client_session_handlers) - self._register_llm_inference_handlers() + self._register_client_global_handlers() # Start listening for messages loop = asyncio.get_running_loop() @@ -3752,7 +3785,7 @@ def handle_notification(method: str, params: dict): "systemMessage.transform", self._handle_system_message_transform ) register_client_session_api_handlers(self._client, self._get_client_session_handlers) - self._register_llm_inference_handlers() + self._register_client_global_handlers() # Start listening for messages loop = asyncio.get_running_loop() @@ -3825,15 +3858,26 @@ async def _set_session_fs_provider(self) -> None: await self._client.request("sessionFs.setProvider", params) - def _register_llm_inference_handlers(self) -> None: - if self._request_handler is None or not self._client: + def _register_client_global_handlers(self) -> None: + if not self._client: + return + llm_inference_adapter = None + if self._request_handler is not None: + llm_inference_adapter = create_copilot_request_adapter( + self._request_handler, + lambda: self._rpc.llm_inference if self._rpc is not None else None, + ) + github_telemetry_adapter = None + if self._on_github_telemetry is not None: + github_telemetry_adapter = _GitHubTelemetryAdapter(self._on_github_telemetry) + if llm_inference_adapter is None and github_telemetry_adapter is None: return - adapter = create_copilot_request_adapter( - self._request_handler, - lambda: self._rpc.llm_inference if self._rpc is not None else None, - ) register_client_global_api_handlers( - self._client, ClientGlobalApiHandlers(llm_inference=adapter) + self._client, + ClientGlobalApiHandlers( + llm_inference=llm_inference_adapter, + git_hub_telemetry=github_telemetry_adapter, + ), ) async def _set_llm_inference_provider(self) -> None: diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index b38c12ff39..f953d578db 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -1778,6 +1778,77 @@ def to_dict(self) -> dict: class GhCLIAuthInfoType(Enum): GH_CLI = "gh-cli" +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class GitHubTelemetryClientInfo: + """Client environment metadata describing the process that produced a telemetry event. + + Client environment metadata. + """ + cli_version: str + """Copilot CLI version string.""" + + node_version: str + """Node.js runtime version string.""" + + os_arch: str + """Operating system architecture (e.g. arm64, x64).""" + + os_platform: str + """Operating system platform (e.g. darwin, linux, win32).""" + + os_version: str + """Operating system version string.""" + + client_name: str | None = None + """Name of the client application.""" + + client_type: str | None = None + """Type of client.""" + + copilot_plan: str | None = None + """Copilot subscription plan, when known.""" + + dev_device_id: str | None = None + """Stable machine identifier for the device.""" + + is_staff: bool | None = None + """Whether the user is a GitHub/Microsoft staff member.""" + + @staticmethod + def from_dict(obj: Any) -> 'GitHubTelemetryClientInfo': + assert isinstance(obj, dict) + cli_version = from_str(obj.get("cli_version")) + node_version = from_str(obj.get("node_version")) + os_arch = from_str(obj.get("os_arch")) + os_platform = from_str(obj.get("os_platform")) + os_version = from_str(obj.get("os_version")) + client_name = from_union([from_str, from_none], obj.get("client_name")) + client_type = from_union([from_str, from_none], obj.get("client_type")) + copilot_plan = from_union([from_str, from_none], obj.get("copilot_plan")) + dev_device_id = from_union([from_str, from_none], obj.get("dev_device_id")) + is_staff = from_union([from_bool, from_none], obj.get("is_staff")) + return GitHubTelemetryClientInfo(cli_version, node_version, os_arch, os_platform, os_version, client_name, client_type, copilot_plan, dev_device_id, is_staff) + + def to_dict(self) -> dict: + result: dict = {} + result["cli_version"] = from_str(self.cli_version) + result["node_version"] = from_str(self.node_version) + result["os_arch"] = from_str(self.os_arch) + result["os_platform"] = from_str(self.os_platform) + result["os_version"] = from_str(self.os_version) + if self.client_name is not None: + result["client_name"] = from_union([from_str, from_none], self.client_name) + if self.client_type is not None: + result["client_type"] = from_union([from_str, from_none], self.client_type) + if self.copilot_plan is not None: + result["copilot_plan"] = from_union([from_str, from_none], self.copilot_plan) + if self.dev_device_id is not None: + result["dev_device_id"] = from_union([from_str, from_none], self.dev_device_id) + if self.is_staff is not None: + result["is_staff"] = from_union([from_bool, from_none], self.is_staff) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class HandlePendingToolCallResult: @@ -20281,6 +20352,114 @@ def to_dict(self) -> dict: result["namespacedName"] = from_union([from_str, from_none], self.namespaced_name) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class GitHubTelemetryEvent: + """A single telemetry event in the runtime's native GitHub-shaped telemetry format, + forwarded verbatim to opted-in hosts. The `restricted` flag on the enclosing + GitHubTelemetryNotification distinguishes standard from restricted events; the payload + shape is identical for both. + + The telemetry event, in the runtime's native GitHub-shaped telemetry format. + """ + kind: str + """Event type/kind (e.g. get_completion_with_tools_turn, tool_call_executed).""" + + metrics: dict[str, float] + """Numeric metrics as a map from key to value.""" + + properties: dict[str, str] + """String-valued properties as a map from key to value.""" + + client: GitHubTelemetryClientInfo | None = None + """Client environment metadata.""" + + copilot_tracking_id: str | None = None + """Copilot tracking ID for user-level attribution.""" + + created_at: str | None = None + """Timestamp when the event was created (ISO 8601 format).""" + + exp_assignment_context: str | None = None + """Experiment assignment context.""" + + features: dict[str, str] | None = None + """Feature flags enabled for this session, as a map from flag to value.""" + + model_call_id: str | None = None + """Reference to the model call that produced this event.""" + + session_id: str | None = None + """Session identifier the event belongs to.""" + + @staticmethod + def from_dict(obj: Any) -> 'GitHubTelemetryEvent': + assert isinstance(obj, dict) + kind = from_str(obj.get("kind")) + metrics = from_dict(from_float, obj.get("metrics")) + properties = from_dict(from_str, obj.get("properties")) + client = from_union([GitHubTelemetryClientInfo.from_dict, from_none], obj.get("client")) + copilot_tracking_id = from_union([from_str, from_none], obj.get("copilot_tracking_id")) + created_at = from_union([from_str, from_none], obj.get("created_at")) + exp_assignment_context = from_union([from_str, from_none], obj.get("exp_assignment_context")) + features = from_union([lambda x: from_dict(from_str, x), from_none], obj.get("features")) + model_call_id = from_union([from_str, from_none], obj.get("model_call_id")) + session_id = from_union([from_str, from_none], obj.get("session_id")) + return GitHubTelemetryEvent(kind, metrics, properties, client, copilot_tracking_id, created_at, exp_assignment_context, features, model_call_id, session_id) + + def to_dict(self) -> dict: + result: dict = {} + result["kind"] = from_str(self.kind) + result["metrics"] = from_dict(to_float, self.metrics) + result["properties"] = from_dict(from_str, self.properties) + if self.client is not None: + result["client"] = from_union([lambda x: to_class(GitHubTelemetryClientInfo, x), from_none], self.client) + if self.copilot_tracking_id is not None: + result["copilot_tracking_id"] = from_union([from_str, from_none], self.copilot_tracking_id) + if self.created_at is not None: + result["created_at"] = from_union([from_str, from_none], self.created_at) + if self.exp_assignment_context is not None: + result["exp_assignment_context"] = from_union([from_str, from_none], self.exp_assignment_context) + if self.features is not None: + result["features"] = from_union([lambda x: from_dict(from_str, x), from_none], self.features) + if self.model_call_id is not None: + result["model_call_id"] = from_union([from_str, from_none], self.model_call_id) + if self.session_id is not None: + result["session_id"] = from_union([from_str, from_none], self.session_id) + return result + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class GitHubTelemetryNotification: + """Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the + runtime forwards to a host connection that opted into telemetry redirection for the + session. + """ + event: GitHubTelemetryEvent + """The telemetry event, in the runtime's native GitHub-shaped telemetry format.""" + + restricted: bool + """Whether this is a restricted telemetry event (cli.restricted_telemetry). Hosts must route + restricted events to first-party Microsoft stores only. + """ + session_id: str + """Session the telemetry event belongs to.""" + + @staticmethod + def from_dict(obj: Any) -> 'GitHubTelemetryNotification': + assert isinstance(obj, dict) + event = GitHubTelemetryEvent.from_dict(obj.get("event")) + restricted = from_bool(obj.get("restricted")) + session_id = from_str(obj.get("sessionId")) + return GitHubTelemetryNotification(event, restricted, session_id) + + def to_dict(self) -> dict: + result: dict = {} + result["event"] = to_class(GitHubTelemetryEvent, self.event) + result["restricted"] = from_bool(self.restricted) + result["sessionId"] = from_str(self.session_id) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class MCPExecuteSamplingParams: @@ -21070,6 +21249,9 @@ class RPC: folder_trust_check_params: FolderTrustCheckParams folder_trust_check_result: FolderTrustCheckResult gh_cli_auth_info: GhCLIAuthInfo + git_hub_telemetry_client_info: GitHubTelemetryClientInfo + git_hub_telemetry_event: GitHubTelemetryEvent + git_hub_telemetry_notification: GitHubTelemetryNotification handle_pending_tool_call_request: HandlePendingToolCallRequest handle_pending_tool_call_result: HandlePendingToolCallResult history_abort_manual_compaction_result: HistoryAbortManualCompactionResult @@ -21842,6 +22024,9 @@ def from_dict(obj: Any) -> 'RPC': folder_trust_check_params = FolderTrustCheckParams.from_dict(obj.get("FolderTrustCheckParams")) folder_trust_check_result = FolderTrustCheckResult.from_dict(obj.get("FolderTrustCheckResult")) gh_cli_auth_info = GhCLIAuthInfo.from_dict(obj.get("GhCliAuthInfo")) + git_hub_telemetry_client_info = GitHubTelemetryClientInfo.from_dict(obj.get("GitHubTelemetryClientInfo")) + git_hub_telemetry_event = GitHubTelemetryEvent.from_dict(obj.get("GitHubTelemetryEvent")) + git_hub_telemetry_notification = GitHubTelemetryNotification.from_dict(obj.get("GitHubTelemetryNotification")) handle_pending_tool_call_request = HandlePendingToolCallRequest.from_dict(obj.get("HandlePendingToolCallRequest")) handle_pending_tool_call_result = HandlePendingToolCallResult.from_dict(obj.get("HandlePendingToolCallResult")) history_abort_manual_compaction_result = HistoryAbortManualCompactionResult.from_dict(obj.get("HistoryAbortManualCompactionResult")) @@ -22481,7 +22666,7 @@ def from_dict(obj: Any) -> 'RPC': subagent_settings = from_union([SubagentSettings.from_dict, from_none], obj.get("SubagentSettings")) task_progress = from_union([TaskProgress.from_dict, from_none], obj.get("TaskProgress")) workspace_summary = from_union([WorkspaceSummary.from_dict, from_none], obj.get("WorkspaceSummary")) - return RPC(abort_request, abort_result, account_all_users, account_get_all_users_result, account_get_current_auth_result, account_get_quota_request, account_get_quota_result, account_login_request, account_login_result, account_logout_request, account_logout_result, account_quota_snapshot, agent_discovery_path, agent_discovery_path_list, agent_discovery_path_scope, agent_get_current_result, agent_info, agent_info_source, agent_list, agent_registry_live_target_entry, agent_registry_live_target_entry_attention_kind, agent_registry_live_target_entry_kind, agent_registry_live_target_entry_last_terminal_event, agent_registry_live_target_entry_status, agent_registry_log_capture, agent_registry_log_capture_open_error_reason, agent_registry_spawn_error, agent_registry_spawn_permission_mode, agent_registry_spawn_registry_timeout, agent_registry_spawn_request, agent_registry_spawn_result, agent_registry_spawn_spawned, agent_registry_spawn_validation_error, agent_registry_spawn_validation_error_field, agent_registry_spawn_validation_error_reason, agent_reload_result, agents_discover_request, agent_select_request, agent_select_result, agents_get_discovery_paths_request, allow_all_permission_set_result, allow_all_permission_state, api_key_auth_info, auth_info, auth_info_type, cancel_user_requested_shell_command_result, canvas_action, canvas_action_invoke_request, canvas_action_invoke_result, canvas_close_request, canvas_host_context, canvas_host_context_capabilities, canvas_json_schema, canvas_list, canvas_list_open_result, canvas_open_request, canvas_provider_close_request, canvas_provider_invoke_action_request, canvas_provider_open_request, canvas_provider_open_result, canvas_session_context, capi_session_options, command_list, commands_handle_pending_command_request, commands_handle_pending_command_result, commands_invoke_request, commands_list_request, commands_respond_to_queued_command_request, commands_respond_to_queued_command_result, configure_session_extensions_params, connected_remote_session_metadata, connected_remote_session_metadata_kind, connected_remote_session_metadata_repository, connect_remote_session_params, connect_request, connect_result, content_filter_mode, copilot_api_token_auth_info, copilot_user_response, copilot_user_response_endpoints, copilot_user_response_quota_snapshots, copilot_user_response_quota_snapshots_chat, copilot_user_response_quota_snapshots_completions, copilot_user_response_quota_snapshots_premium_interactions, current_model, current_tool_metadata, discovered_canvas, discovered_mcp_server, discovered_mcp_server_type, enqueue_command_params, enqueue_command_result, env_auth_info, event_log_read_request, event_log_release_interest_result, event_log_tail_result, event_log_types, events_agent_scope, events_cursor_status, events_read_result, execute_command_params, execute_command_result, extension, extension_context_push_input, extension_list, extensions_disable_request, extensions_enable_request, extension_source, extension_status, external_tool_result, external_tool_text_result_for_llm, external_tool_text_result_for_llm_binary_results_for_llm, external_tool_text_result_for_llm_binary_results_for_llm_type, external_tool_text_result_for_llm_content, external_tool_text_result_for_llm_content_audio, external_tool_text_result_for_llm_content_image, external_tool_text_result_for_llm_content_resource, external_tool_text_result_for_llm_content_resource_details, external_tool_text_result_for_llm_content_resource_link, external_tool_text_result_for_llm_content_resource_link_icon, external_tool_text_result_for_llm_content_resource_link_icon_theme, external_tool_text_result_for_llm_content_terminal, external_tool_text_result_for_llm_content_text, filter_mapping, fleet_start_request, fleet_start_result, folder_trust_add_params, folder_trust_check_params, folder_trust_check_result, gh_cli_auth_info, handle_pending_tool_call_request, handle_pending_tool_call_result, history_abort_manual_compaction_result, history_cancel_background_compaction_result, history_compact_context_window, history_compact_request, history_compact_result, history_summarize_for_handoff_result, history_truncate_request, history_truncate_result, hmac_auth_info, installed_plugin, installed_plugin_info, installed_plugin_source, installed_plugin_source_git_hub, installed_plugin_source_local, installed_plugin_source_url, instruction_discovery_path, instruction_discovery_path_kind, instruction_discovery_path_list, instruction_discovery_path_location, instructions_discover_request, instructions_get_discovery_paths_request, instructions_get_sources_result, instruction_source, instruction_source_location, instruction_source_type, llm_inference_headers, llm_inference_http_request_chunk_request, llm_inference_http_request_chunk_result, llm_inference_http_request_start_request, llm_inference_http_request_start_result, llm_inference_http_request_start_transport, llm_inference_http_response_chunk_error, llm_inference_http_response_chunk_request, llm_inference_http_response_chunk_result, llm_inference_http_response_start_request, llm_inference_http_response_start_result, llm_inference_set_provider_result, local_session_metadata_value, log_request, log_result, lsp_initialize_request, marketplace_add_result, marketplace_browse_result, marketplace_info, marketplace_list_result, marketplace_plugin_info, marketplace_refresh_entry, marketplace_refresh_result, marketplace_remove_result, mcp_allowed_server, mcp_apps_call_tool_request, mcp_apps_diagnose_capability, mcp_apps_diagnose_request, mcp_apps_diagnose_result, mcp_apps_diagnose_server, mcp_apps_host_context, mcp_apps_host_context_details, mcp_apps_host_context_details_available_display_mode, mcp_apps_host_context_details_display_mode, mcp_apps_host_context_details_platform, mcp_apps_host_context_details_theme, mcp_apps_list_tools_request, mcp_apps_list_tools_result, mcp_apps_read_resource_request, mcp_apps_read_resource_result, mcp_apps_resource_content, mcp_apps_set_host_context_details, mcp_apps_set_host_context_details_available_display_mode, mcp_apps_set_host_context_details_display_mode, mcp_apps_set_host_context_details_platform, mcp_apps_set_host_context_details_theme, mcp_apps_set_host_context_request, mcp_cancel_sampling_execution_params, mcp_cancel_sampling_execution_result, mcp_config_add_request, mcp_config_disable_request, mcp_config_enable_request, mcp_config_list, mcp_config_remove_request, mcp_config_update_request, mcp_configure_git_hub_request, mcp_configure_git_hub_result, mcp_disable_request, mcp_discover_request, mcp_discover_result, mcp_enable_request, mcp_execute_sampling_params, mcp_execute_sampling_request, mcp_execute_sampling_result, mcp_filtered_server, mcp_host_state, mcp_is_server_running_request, mcp_is_server_running_result, mcp_list_tools_request, mcp_list_tools_result, mcp_oauth_handle_pending_request, mcp_oauth_handle_pending_result, mcp_oauth_login_grant_type, mcp_oauth_login_request, mcp_oauth_login_result, mcp_oauth_pending_request_response, mcp_oauth_respond_request, mcp_oauth_respond_result, mcp_register_external_client_request, mcp_reload_with_config_request, mcp_remove_git_hub_result, mcp_restart_server_request, mcp_sampling_execution_action, mcp_sampling_execution_result, mcp_server, mcp_server_auth_config, mcp_server_auth_config_redirect_port, mcp_server_config, mcp_server_config_defer_tools, mcp_server_config_http, mcp_server_config_http_oauth_grant_type, mcp_server_config_http_type, mcp_server_config_stdio, mcp_server_failure_info, mcp_server_list, mcp_server_needs_auth_info, mcp_set_env_value_mode_details, mcp_set_env_value_mode_params, mcp_set_env_value_mode_result, mcp_start_server_request, mcp_start_servers_result, mcp_stop_server_request, mcp_tools, mcp_unregister_external_client_request, memory_configuration, metadata_context_info_request, metadata_context_info_result, metadata_is_processing_result, metadata_recompute_context_tokens_request, metadata_recompute_context_tokens_result, metadata_record_context_change_request, metadata_record_context_change_result, metadata_set_working_directory_request, metadata_set_working_directory_result, metadata_snapshot_current_mode, metadata_snapshot_remote_metadata, metadata_snapshot_remote_metadata_repository, metadata_snapshot_remote_metadata_task_type, model, model_billing, model_billing_token_prices, model_billing_token_prices_long_context, model_capabilities, model_capabilities_limits, model_capabilities_limits_vision, model_capabilities_override, model_capabilities_override_limits, model_capabilities_override_limits_vision, model_capabilities_override_supports, model_capabilities_supports, model_list, model_list_request, model_picker_category, model_picker_price_category, model_policy, model_policy_state, model_set_reasoning_effort_request, model_set_reasoning_effort_result, models_list_request, model_switch_to_request, model_switch_to_result, mode_set_request, named_provider_config, name_get_result, name_set_auto_request, name_set_auto_result, name_set_request, open_canvas_instance, options_update_additional_content_exclusion_policy, options_update_additional_content_exclusion_policy_rule, options_update_additional_content_exclusion_policy_rule_source, options_update_additional_content_exclusion_policy_scope, options_update_context_tier, options_update_env_value_mode, options_update_reasoning_summary, options_update_tool_filter_precedence, pending_permission_request, pending_permission_request_list, permission_decision, permission_decision_approved, permission_decision_approved_for_location, permission_decision_approved_for_session, permission_decision_approve_for_location, permission_decision_approve_for_location_approval, permission_decision_approve_for_location_approval_commands, permission_decision_approve_for_location_approval_custom_tool, permission_decision_approve_for_location_approval_extension_management, permission_decision_approve_for_location_approval_extension_permission_access, permission_decision_approve_for_location_approval_mcp, permission_decision_approve_for_location_approval_mcp_sampling, permission_decision_approve_for_location_approval_memory, permission_decision_approve_for_location_approval_read, permission_decision_approve_for_location_approval_write, permission_decision_approve_for_session, permission_decision_approve_for_session_approval, permission_decision_approve_for_session_approval_commands, permission_decision_approve_for_session_approval_custom_tool, permission_decision_approve_for_session_approval_extension_management, permission_decision_approve_for_session_approval_extension_permission_access, permission_decision_approve_for_session_approval_mcp, permission_decision_approve_for_session_approval_mcp_sampling, permission_decision_approve_for_session_approval_memory, permission_decision_approve_for_session_approval_read, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_cancelled, permission_decision_denied_by_content_exclusion_policy, permission_decision_denied_by_permission_request_hook, permission_decision_denied_by_rules, permission_decision_denied_interactively_by_user, permission_decision_denied_no_approval_rule_and_could_not_request_from_user, permission_decision_reject, permission_decision_request, permission_decision_user_not_available, permission_location_add_tool_approval_params, permission_location_apply_params, permission_location_apply_result, permission_location_resolve_params, permission_location_resolve_result, permission_location_type, permission_paths_add_params, permission_paths_allowed_check_params, permission_paths_allowed_check_result, permission_paths_config, permission_paths_list, permission_paths_update_primary_params, permission_paths_workspace_check_params, permission_paths_workspace_check_result, permission_prompt_shown_notification, permission_request_result, permission_rules_set, permissions_configure_additional_content_exclusion_policy, permissions_configure_additional_content_exclusion_policy_rule, permissions_configure_additional_content_exclusion_policy_rule_source, permissions_configure_additional_content_exclusion_policy_scope, permissions_configure_params, permissions_configure_result, permissions_folder_trust_add_trusted_result, permissions_get_allow_all_request, permissions_locations_add_tool_approval_details, permissions_locations_add_tool_approval_details_commands, permissions_locations_add_tool_approval_details_custom_tool, permissions_locations_add_tool_approval_details_extension_management, permissions_locations_add_tool_approval_details_extension_permission_access, permissions_locations_add_tool_approval_details_mcp, permissions_locations_add_tool_approval_details_mcp_sampling, permissions_locations_add_tool_approval_details_memory, permissions_locations_add_tool_approval_details_read, permissions_locations_add_tool_approval_details_write, permissions_locations_add_tool_approval_result, permissions_modify_rules_params, permissions_modify_rules_result, permissions_modify_rules_scope, permissions_notify_prompt_shown_result, permissions_paths_add_result, permissions_paths_list_request, permissions_paths_update_primary_result, permissions_pending_requests_request, permissions_reset_session_approvals_request, permissions_reset_session_approvals_result, permissions_set_allow_all_request, permissions_set_allow_all_source, permissions_set_approve_all_request, permissions_set_approve_all_result, permissions_set_approve_all_source, permissions_set_required_request, permissions_set_required_result, permissions_urls_set_unrestricted_mode_result, permission_urls_config, permission_urls_set_unrestricted_mode_params, ping_request, ping_result, plan_read_result, plan_read_sql_todos_result, plan_read_sql_todos_with_dependencies_result, plan_sql_todo_dependency, plan_sql_todos_row, plan_update_request, plugin, plugin_install_result, plugin_list, plugin_list_result, plugins_disable_request, plugins_enable_request, plugins_install_request, plugins_marketplaces_add_request, plugins_marketplaces_browse_request, plugins_marketplaces_refresh_request, plugins_marketplaces_remove_request, plugins_reload_request, plugins_uninstall_request, plugins_update_request, plugin_update_all_entry, plugin_update_all_result, plugin_update_result, poll_spawned_sessions_result, provider_add_request, provider_add_result, provider_config, provider_config_azure, provider_config_transport, provider_config_type, provider_config_wire_api, provider_endpoint, provider_endpoint_transport, provider_endpoint_type, provider_endpoint_wire_api, provider_get_endpoint_request, provider_model_config, provider_session_token, provider_token_acquire_request, provider_token_acquire_result, push_attachment, push_attachment_blob, push_attachment_directory, push_attachment_file, push_attachment_file_line_range, push_attachment_git_hub_reference, push_attachment_git_hub_reference_type, push_attachment_selection, push_attachment_selection_details, push_attachment_selection_details_end, push_attachment_selection_details_start, queued_command_handled, queued_command_not_handled, queued_command_result, queue_pending_items, queue_pending_items_kind, queue_pending_items_result, queue_remove_most_recent_result, register_event_interest_params, register_event_interest_result, register_extension_tools_params, register_extension_tools_result, release_event_interest_params, remote_control_config, remote_control_config_existing_mc_session, remote_control_status, remote_control_status_active, remote_control_status_connecting, remote_control_status_error, remote_control_status_off, remote_control_status_result, remote_control_stop_result, remote_control_transfer_result, remote_enable_request, remote_enable_result, remote_notify_steerable_changed_request, remote_notify_steerable_changed_result, remote_session_connection_result, remote_session_metadata_repository, remote_session_metadata_task_type, remote_session_metadata_value, remote_session_mode, remote_session_repository, sandbox_config, sandbox_config_user_policy, sandbox_config_user_policy_experimental, sandbox_config_user_policy_experimental_seatbelt, sandbox_config_user_policy_filesystem, sandbox_config_user_policy_network, sandbox_config_user_policy_seatbelt, schedule_entry, schedule_list, schedule_stop_request, schedule_stop_result, secrets_add_filter_values_request, secrets_add_filter_values_result, send_agent_mode, send_attachments_to_message_params, send_mode, send_request, send_result, server_agent_list, server_instruction_source_list, server_skill, server_skill_list, session_activity, session_auth_status, session_bulk_delete_result, session_capability, session_context, session_context_host_type, session_enrich_metadata_result, session_fs_append_file_request, session_fs_error, session_fs_error_code, session_fs_exists_request, session_fs_exists_result, session_fs_mkdir_request, session_fs_readdir_request, session_fs_readdir_result, session_fs_readdir_with_types_entry, session_fs_readdir_with_types_entry_type, session_fs_readdir_with_types_request, session_fs_readdir_with_types_result, session_fs_read_file_request, session_fs_read_file_result, session_fs_rename_request, session_fs_rm_request, session_fs_set_provider_capabilities, session_fs_set_provider_conventions, session_fs_set_provider_request, session_fs_set_provider_result, session_fs_sqlite_exists_request, session_fs_sqlite_exists_result, session_fs_sqlite_query_request, session_fs_sqlite_query_result, session_fs_sqlite_query_type, session_fs_stat_request, session_fs_stat_result, session_fs_write_file_request, session_installed_plugin, session_installed_plugin_source, session_installed_plugin_source_git_hub, session_installed_plugin_source_local, session_installed_plugin_source_url, session_list, session_list_entry, session_list_filter, session_load_deferred_repo_hooks_result, session_log_level, session_mcp_apps_call_tool_result, session_metadata_snapshot, session_mode, session_model_list, session_open_options, session_open_options_additional_content_exclusion_policy, session_open_options_additional_content_exclusion_policy_rule, session_open_options_additional_content_exclusion_policy_rule_source, session_open_options_additional_content_exclusion_policy_scope, session_open_options_env_value_mode, session_open_options_reasoning_summary, session_open_params, session_open_result, session_prune_result, sessions_bulk_delete_request, sessions_check_in_use_request, sessions_check_in_use_result, sessions_close_request, sessions_close_result, sessions_enrich_metadata_request, session_set_credentials_params, session_set_credentials_result, sessions_find_by_prefix_request, sessions_find_by_prefix_result, sessions_find_by_task_id_request, sessions_find_by_task_id_result, sessions_fork_request, sessions_fork_result, sessions_get_board_entry_count_request, sessions_get_board_entry_count_result, sessions_get_event_file_path_request, sessions_get_event_file_path_result, sessions_get_last_for_context_request, sessions_get_last_for_context_result, sessions_get_persisted_remote_steerable_request, sessions_get_persisted_remote_steerable_result, session_sizes, sessions_list_request, sessions_load_deferred_repo_hooks_request, sessions_open_attach, sessions_open_cloud, sessions_open_create, sessions_open_handoff, sessions_open_handoff_task_type, sessions_open_progress, sessions_open_progress_status, sessions_open_progress_step, sessions_open_remote, sessions_open_resume, sessions_open_resume_last, sessions_open_status, session_source, sessions_poll_spawned_sessions_event, sessions_poll_spawned_sessions_request, sessions_prune_old_request, sessions_register_extension_tools_on_session_options, sessions_release_lock_request, sessions_release_lock_result, sessions_reload_plugin_hooks_request, sessions_reload_plugin_hooks_result, sessions_save_request, sessions_save_result, sessions_set_additional_plugins_request, sessions_set_additional_plugins_result, sessions_set_remote_control_steering_request, sessions_start_remote_control_request, sessions_stop_remote_control_request, sessions_transfer_remote_control_request, session_telemetry_engagement, session_update_options_params, session_update_options_result, session_working_directory_context, session_working_directory_context_host_type, shell_cancel_user_requested_request, shell_exec_request, shell_exec_result, shell_execute_user_requested_request, shell_kill_request, shell_kill_result, shell_kill_signal, shutdown_request, skill, skill_discovery_path, skill_discovery_path_list, skill_discovery_scope, skill_list, skills_config_set_disabled_skills_request, skills_disable_request, skills_discover_request, skills_enable_request, skills_get_discovery_paths_request, skills_get_invoked_result, skills_invoked_skill, skills_load_diagnostics, slash_command_agent_prompt_result, slash_command_completed_result, slash_command_info, slash_command_input, slash_command_input_completion, slash_command_invocation_result, slash_command_kind, slash_command_select_subcommand_option, slash_command_select_subcommand_result, slash_command_text_result, subagent_settings_entry, subagent_settings_entry_context_tier, task_agent_info, task_agent_progress, task_execution_mode, task_info, task_list, task_progress_line, tasks_cancel_request, tasks_cancel_result, tasks_get_current_promotable_result, tasks_get_progress_request, tasks_get_progress_result, task_shell_info, task_shell_info_attachment_mode, task_shell_progress, tasks_promote_current_to_background_result, tasks_promote_to_background_request, tasks_promote_to_background_result, tasks_refresh_result, tasks_remove_request, tasks_remove_result, tasks_send_message_request, tasks_send_message_result, tasks_start_agent_request, tasks_start_agent_result, task_status, tasks_wait_for_pending_result, telemetry_set_feature_overrides_request, token_auth_info, tool, tool_list, tools_get_current_metadata_result, tools_initialize_and_validate_result, tools_list_request, tools_update_subagent_settings_result, ui_auto_mode_switch_response, ui_elicitation_array_any_of_field, ui_elicitation_array_any_of_field_items, ui_elicitation_array_any_of_field_items_any_of, ui_elicitation_array_enum_field, ui_elicitation_array_enum_field_items, ui_elicitation_field_value, ui_elicitation_request, ui_elicitation_response, ui_elicitation_response_action, ui_elicitation_response_content, ui_elicitation_result, ui_elicitation_schema, ui_elicitation_schema_property, ui_elicitation_schema_property_boolean, ui_elicitation_schema_property_number, ui_elicitation_schema_property_number_type, ui_elicitation_schema_property_string, ui_elicitation_schema_property_string_format, ui_elicitation_string_enum_field, ui_elicitation_string_one_of_field, ui_elicitation_string_one_of_field_one_of, ui_ephemeral_query_request, ui_ephemeral_query_result, ui_exit_plan_mode_action, ui_exit_plan_mode_response, ui_handle_pending_auto_mode_switch_request, ui_handle_pending_elicitation_request, ui_handle_pending_exit_plan_mode_request, ui_handle_pending_result, ui_handle_pending_sampling_request, ui_handle_pending_sampling_response, ui_handle_pending_user_input_request, ui_register_direct_auto_mode_switch_handler_result, ui_unregister_direct_auto_mode_switch_handler_request, ui_unregister_direct_auto_mode_switch_handler_result, ui_user_input_response, update_subagent_settings_request, usage_get_metrics_result, usage_metrics_code_changes, usage_metrics_model_metric, usage_metrics_model_metric_requests, usage_metrics_model_metric_token_detail, usage_metrics_model_metric_usage, usage_metrics_token_detail, user_auth_info, user_requested_shell_command_result, workspace_diff_file_change, workspace_diff_file_change_type, workspace_diff_mode, workspace_diff_result, workspaces_checkpoints, workspaces_create_file_request, workspaces_diff_request, workspaces_get_workspace_result, workspaces_list_checkpoints_result, workspaces_list_files_result, workspaces_read_checkpoint_request, workspaces_read_checkpoint_result, workspaces_read_file_request, workspaces_read_file_result, workspaces_save_large_paste_request, workspaces_save_large_paste_result, workspace_summary_host_type, workspaces_workspace_details_host_type, session_context_info, subagent_settings, task_progress, workspace_summary) + return RPC(abort_request, abort_result, account_all_users, account_get_all_users_result, account_get_current_auth_result, account_get_quota_request, account_get_quota_result, account_login_request, account_login_result, account_logout_request, account_logout_result, account_quota_snapshot, agent_discovery_path, agent_discovery_path_list, agent_discovery_path_scope, agent_get_current_result, agent_info, agent_info_source, agent_list, agent_registry_live_target_entry, agent_registry_live_target_entry_attention_kind, agent_registry_live_target_entry_kind, agent_registry_live_target_entry_last_terminal_event, agent_registry_live_target_entry_status, agent_registry_log_capture, agent_registry_log_capture_open_error_reason, agent_registry_spawn_error, agent_registry_spawn_permission_mode, agent_registry_spawn_registry_timeout, agent_registry_spawn_request, agent_registry_spawn_result, agent_registry_spawn_spawned, agent_registry_spawn_validation_error, agent_registry_spawn_validation_error_field, agent_registry_spawn_validation_error_reason, agent_reload_result, agents_discover_request, agent_select_request, agent_select_result, agents_get_discovery_paths_request, allow_all_permission_set_result, allow_all_permission_state, api_key_auth_info, auth_info, auth_info_type, cancel_user_requested_shell_command_result, canvas_action, canvas_action_invoke_request, canvas_action_invoke_result, canvas_close_request, canvas_host_context, canvas_host_context_capabilities, canvas_json_schema, canvas_list, canvas_list_open_result, canvas_open_request, canvas_provider_close_request, canvas_provider_invoke_action_request, canvas_provider_open_request, canvas_provider_open_result, canvas_session_context, capi_session_options, command_list, commands_handle_pending_command_request, commands_handle_pending_command_result, commands_invoke_request, commands_list_request, commands_respond_to_queued_command_request, commands_respond_to_queued_command_result, configure_session_extensions_params, connected_remote_session_metadata, connected_remote_session_metadata_kind, connected_remote_session_metadata_repository, connect_remote_session_params, connect_request, connect_result, content_filter_mode, copilot_api_token_auth_info, copilot_user_response, copilot_user_response_endpoints, copilot_user_response_quota_snapshots, copilot_user_response_quota_snapshots_chat, copilot_user_response_quota_snapshots_completions, copilot_user_response_quota_snapshots_premium_interactions, current_model, current_tool_metadata, discovered_canvas, discovered_mcp_server, discovered_mcp_server_type, enqueue_command_params, enqueue_command_result, env_auth_info, event_log_read_request, event_log_release_interest_result, event_log_tail_result, event_log_types, events_agent_scope, events_cursor_status, events_read_result, execute_command_params, execute_command_result, extension, extension_context_push_input, extension_list, extensions_disable_request, extensions_enable_request, extension_source, extension_status, external_tool_result, external_tool_text_result_for_llm, external_tool_text_result_for_llm_binary_results_for_llm, external_tool_text_result_for_llm_binary_results_for_llm_type, external_tool_text_result_for_llm_content, external_tool_text_result_for_llm_content_audio, external_tool_text_result_for_llm_content_image, external_tool_text_result_for_llm_content_resource, external_tool_text_result_for_llm_content_resource_details, external_tool_text_result_for_llm_content_resource_link, external_tool_text_result_for_llm_content_resource_link_icon, external_tool_text_result_for_llm_content_resource_link_icon_theme, external_tool_text_result_for_llm_content_terminal, external_tool_text_result_for_llm_content_text, filter_mapping, fleet_start_request, fleet_start_result, folder_trust_add_params, folder_trust_check_params, folder_trust_check_result, gh_cli_auth_info, git_hub_telemetry_client_info, git_hub_telemetry_event, git_hub_telemetry_notification, handle_pending_tool_call_request, handle_pending_tool_call_result, history_abort_manual_compaction_result, history_cancel_background_compaction_result, history_compact_context_window, history_compact_request, history_compact_result, history_summarize_for_handoff_result, history_truncate_request, history_truncate_result, hmac_auth_info, installed_plugin, installed_plugin_info, installed_plugin_source, installed_plugin_source_git_hub, installed_plugin_source_local, installed_plugin_source_url, instruction_discovery_path, instruction_discovery_path_kind, instruction_discovery_path_list, instruction_discovery_path_location, instructions_discover_request, instructions_get_discovery_paths_request, instructions_get_sources_result, instruction_source, instruction_source_location, instruction_source_type, llm_inference_headers, llm_inference_http_request_chunk_request, llm_inference_http_request_chunk_result, llm_inference_http_request_start_request, llm_inference_http_request_start_result, llm_inference_http_request_start_transport, llm_inference_http_response_chunk_error, llm_inference_http_response_chunk_request, llm_inference_http_response_chunk_result, llm_inference_http_response_start_request, llm_inference_http_response_start_result, llm_inference_set_provider_result, local_session_metadata_value, log_request, log_result, lsp_initialize_request, marketplace_add_result, marketplace_browse_result, marketplace_info, marketplace_list_result, marketplace_plugin_info, marketplace_refresh_entry, marketplace_refresh_result, marketplace_remove_result, mcp_allowed_server, mcp_apps_call_tool_request, mcp_apps_diagnose_capability, mcp_apps_diagnose_request, mcp_apps_diagnose_result, mcp_apps_diagnose_server, mcp_apps_host_context, mcp_apps_host_context_details, mcp_apps_host_context_details_available_display_mode, mcp_apps_host_context_details_display_mode, mcp_apps_host_context_details_platform, mcp_apps_host_context_details_theme, mcp_apps_list_tools_request, mcp_apps_list_tools_result, mcp_apps_read_resource_request, mcp_apps_read_resource_result, mcp_apps_resource_content, mcp_apps_set_host_context_details, mcp_apps_set_host_context_details_available_display_mode, mcp_apps_set_host_context_details_display_mode, mcp_apps_set_host_context_details_platform, mcp_apps_set_host_context_details_theme, mcp_apps_set_host_context_request, mcp_cancel_sampling_execution_params, mcp_cancel_sampling_execution_result, mcp_config_add_request, mcp_config_disable_request, mcp_config_enable_request, mcp_config_list, mcp_config_remove_request, mcp_config_update_request, mcp_configure_git_hub_request, mcp_configure_git_hub_result, mcp_disable_request, mcp_discover_request, mcp_discover_result, mcp_enable_request, mcp_execute_sampling_params, mcp_execute_sampling_request, mcp_execute_sampling_result, mcp_filtered_server, mcp_host_state, mcp_is_server_running_request, mcp_is_server_running_result, mcp_list_tools_request, mcp_list_tools_result, mcp_oauth_handle_pending_request, mcp_oauth_handle_pending_result, mcp_oauth_login_grant_type, mcp_oauth_login_request, mcp_oauth_login_result, mcp_oauth_pending_request_response, mcp_oauth_respond_request, mcp_oauth_respond_result, mcp_register_external_client_request, mcp_reload_with_config_request, mcp_remove_git_hub_result, mcp_restart_server_request, mcp_sampling_execution_action, mcp_sampling_execution_result, mcp_server, mcp_server_auth_config, mcp_server_auth_config_redirect_port, mcp_server_config, mcp_server_config_defer_tools, mcp_server_config_http, mcp_server_config_http_oauth_grant_type, mcp_server_config_http_type, mcp_server_config_stdio, mcp_server_failure_info, mcp_server_list, mcp_server_needs_auth_info, mcp_set_env_value_mode_details, mcp_set_env_value_mode_params, mcp_set_env_value_mode_result, mcp_start_server_request, mcp_start_servers_result, mcp_stop_server_request, mcp_tools, mcp_unregister_external_client_request, memory_configuration, metadata_context_info_request, metadata_context_info_result, metadata_is_processing_result, metadata_recompute_context_tokens_request, metadata_recompute_context_tokens_result, metadata_record_context_change_request, metadata_record_context_change_result, metadata_set_working_directory_request, metadata_set_working_directory_result, metadata_snapshot_current_mode, metadata_snapshot_remote_metadata, metadata_snapshot_remote_metadata_repository, metadata_snapshot_remote_metadata_task_type, model, model_billing, model_billing_token_prices, model_billing_token_prices_long_context, model_capabilities, model_capabilities_limits, model_capabilities_limits_vision, model_capabilities_override, model_capabilities_override_limits, model_capabilities_override_limits_vision, model_capabilities_override_supports, model_capabilities_supports, model_list, model_list_request, model_picker_category, model_picker_price_category, model_policy, model_policy_state, model_set_reasoning_effort_request, model_set_reasoning_effort_result, models_list_request, model_switch_to_request, model_switch_to_result, mode_set_request, named_provider_config, name_get_result, name_set_auto_request, name_set_auto_result, name_set_request, open_canvas_instance, options_update_additional_content_exclusion_policy, options_update_additional_content_exclusion_policy_rule, options_update_additional_content_exclusion_policy_rule_source, options_update_additional_content_exclusion_policy_scope, options_update_context_tier, options_update_env_value_mode, options_update_reasoning_summary, options_update_tool_filter_precedence, pending_permission_request, pending_permission_request_list, permission_decision, permission_decision_approved, permission_decision_approved_for_location, permission_decision_approved_for_session, permission_decision_approve_for_location, permission_decision_approve_for_location_approval, permission_decision_approve_for_location_approval_commands, permission_decision_approve_for_location_approval_custom_tool, permission_decision_approve_for_location_approval_extension_management, permission_decision_approve_for_location_approval_extension_permission_access, permission_decision_approve_for_location_approval_mcp, permission_decision_approve_for_location_approval_mcp_sampling, permission_decision_approve_for_location_approval_memory, permission_decision_approve_for_location_approval_read, permission_decision_approve_for_location_approval_write, permission_decision_approve_for_session, permission_decision_approve_for_session_approval, permission_decision_approve_for_session_approval_commands, permission_decision_approve_for_session_approval_custom_tool, permission_decision_approve_for_session_approval_extension_management, permission_decision_approve_for_session_approval_extension_permission_access, permission_decision_approve_for_session_approval_mcp, permission_decision_approve_for_session_approval_mcp_sampling, permission_decision_approve_for_session_approval_memory, permission_decision_approve_for_session_approval_read, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_cancelled, permission_decision_denied_by_content_exclusion_policy, permission_decision_denied_by_permission_request_hook, permission_decision_denied_by_rules, permission_decision_denied_interactively_by_user, permission_decision_denied_no_approval_rule_and_could_not_request_from_user, permission_decision_reject, permission_decision_request, permission_decision_user_not_available, permission_location_add_tool_approval_params, permission_location_apply_params, permission_location_apply_result, permission_location_resolve_params, permission_location_resolve_result, permission_location_type, permission_paths_add_params, permission_paths_allowed_check_params, permission_paths_allowed_check_result, permission_paths_config, permission_paths_list, permission_paths_update_primary_params, permission_paths_workspace_check_params, permission_paths_workspace_check_result, permission_prompt_shown_notification, permission_request_result, permission_rules_set, permissions_configure_additional_content_exclusion_policy, permissions_configure_additional_content_exclusion_policy_rule, permissions_configure_additional_content_exclusion_policy_rule_source, permissions_configure_additional_content_exclusion_policy_scope, permissions_configure_params, permissions_configure_result, permissions_folder_trust_add_trusted_result, permissions_get_allow_all_request, permissions_locations_add_tool_approval_details, permissions_locations_add_tool_approval_details_commands, permissions_locations_add_tool_approval_details_custom_tool, permissions_locations_add_tool_approval_details_extension_management, permissions_locations_add_tool_approval_details_extension_permission_access, permissions_locations_add_tool_approval_details_mcp, permissions_locations_add_tool_approval_details_mcp_sampling, permissions_locations_add_tool_approval_details_memory, permissions_locations_add_tool_approval_details_read, permissions_locations_add_tool_approval_details_write, permissions_locations_add_tool_approval_result, permissions_modify_rules_params, permissions_modify_rules_result, permissions_modify_rules_scope, permissions_notify_prompt_shown_result, permissions_paths_add_result, permissions_paths_list_request, permissions_paths_update_primary_result, permissions_pending_requests_request, permissions_reset_session_approvals_request, permissions_reset_session_approvals_result, permissions_set_allow_all_request, permissions_set_allow_all_source, permissions_set_approve_all_request, permissions_set_approve_all_result, permissions_set_approve_all_source, permissions_set_required_request, permissions_set_required_result, permissions_urls_set_unrestricted_mode_result, permission_urls_config, permission_urls_set_unrestricted_mode_params, ping_request, ping_result, plan_read_result, plan_read_sql_todos_result, plan_read_sql_todos_with_dependencies_result, plan_sql_todo_dependency, plan_sql_todos_row, plan_update_request, plugin, plugin_install_result, plugin_list, plugin_list_result, plugins_disable_request, plugins_enable_request, plugins_install_request, plugins_marketplaces_add_request, plugins_marketplaces_browse_request, plugins_marketplaces_refresh_request, plugins_marketplaces_remove_request, plugins_reload_request, plugins_uninstall_request, plugins_update_request, plugin_update_all_entry, plugin_update_all_result, plugin_update_result, poll_spawned_sessions_result, provider_add_request, provider_add_result, provider_config, provider_config_azure, provider_config_transport, provider_config_type, provider_config_wire_api, provider_endpoint, provider_endpoint_transport, provider_endpoint_type, provider_endpoint_wire_api, provider_get_endpoint_request, provider_model_config, provider_session_token, provider_token_acquire_request, provider_token_acquire_result, push_attachment, push_attachment_blob, push_attachment_directory, push_attachment_file, push_attachment_file_line_range, push_attachment_git_hub_reference, push_attachment_git_hub_reference_type, push_attachment_selection, push_attachment_selection_details, push_attachment_selection_details_end, push_attachment_selection_details_start, queued_command_handled, queued_command_not_handled, queued_command_result, queue_pending_items, queue_pending_items_kind, queue_pending_items_result, queue_remove_most_recent_result, register_event_interest_params, register_event_interest_result, register_extension_tools_params, register_extension_tools_result, release_event_interest_params, remote_control_config, remote_control_config_existing_mc_session, remote_control_status, remote_control_status_active, remote_control_status_connecting, remote_control_status_error, remote_control_status_off, remote_control_status_result, remote_control_stop_result, remote_control_transfer_result, remote_enable_request, remote_enable_result, remote_notify_steerable_changed_request, remote_notify_steerable_changed_result, remote_session_connection_result, remote_session_metadata_repository, remote_session_metadata_task_type, remote_session_metadata_value, remote_session_mode, remote_session_repository, sandbox_config, sandbox_config_user_policy, sandbox_config_user_policy_experimental, sandbox_config_user_policy_experimental_seatbelt, sandbox_config_user_policy_filesystem, sandbox_config_user_policy_network, sandbox_config_user_policy_seatbelt, schedule_entry, schedule_list, schedule_stop_request, schedule_stop_result, secrets_add_filter_values_request, secrets_add_filter_values_result, send_agent_mode, send_attachments_to_message_params, send_mode, send_request, send_result, server_agent_list, server_instruction_source_list, server_skill, server_skill_list, session_activity, session_auth_status, session_bulk_delete_result, session_capability, session_context, session_context_host_type, session_enrich_metadata_result, session_fs_append_file_request, session_fs_error, session_fs_error_code, session_fs_exists_request, session_fs_exists_result, session_fs_mkdir_request, session_fs_readdir_request, session_fs_readdir_result, session_fs_readdir_with_types_entry, session_fs_readdir_with_types_entry_type, session_fs_readdir_with_types_request, session_fs_readdir_with_types_result, session_fs_read_file_request, session_fs_read_file_result, session_fs_rename_request, session_fs_rm_request, session_fs_set_provider_capabilities, session_fs_set_provider_conventions, session_fs_set_provider_request, session_fs_set_provider_result, session_fs_sqlite_exists_request, session_fs_sqlite_exists_result, session_fs_sqlite_query_request, session_fs_sqlite_query_result, session_fs_sqlite_query_type, session_fs_stat_request, session_fs_stat_result, session_fs_write_file_request, session_installed_plugin, session_installed_plugin_source, session_installed_plugin_source_git_hub, session_installed_plugin_source_local, session_installed_plugin_source_url, session_list, session_list_entry, session_list_filter, session_load_deferred_repo_hooks_result, session_log_level, session_mcp_apps_call_tool_result, session_metadata_snapshot, session_mode, session_model_list, session_open_options, session_open_options_additional_content_exclusion_policy, session_open_options_additional_content_exclusion_policy_rule, session_open_options_additional_content_exclusion_policy_rule_source, session_open_options_additional_content_exclusion_policy_scope, session_open_options_env_value_mode, session_open_options_reasoning_summary, session_open_params, session_open_result, session_prune_result, sessions_bulk_delete_request, sessions_check_in_use_request, sessions_check_in_use_result, sessions_close_request, sessions_close_result, sessions_enrich_metadata_request, session_set_credentials_params, session_set_credentials_result, sessions_find_by_prefix_request, sessions_find_by_prefix_result, sessions_find_by_task_id_request, sessions_find_by_task_id_result, sessions_fork_request, sessions_fork_result, sessions_get_board_entry_count_request, sessions_get_board_entry_count_result, sessions_get_event_file_path_request, sessions_get_event_file_path_result, sessions_get_last_for_context_request, sessions_get_last_for_context_result, sessions_get_persisted_remote_steerable_request, sessions_get_persisted_remote_steerable_result, session_sizes, sessions_list_request, sessions_load_deferred_repo_hooks_request, sessions_open_attach, sessions_open_cloud, sessions_open_create, sessions_open_handoff, sessions_open_handoff_task_type, sessions_open_progress, sessions_open_progress_status, sessions_open_progress_step, sessions_open_remote, sessions_open_resume, sessions_open_resume_last, sessions_open_status, session_source, sessions_poll_spawned_sessions_event, sessions_poll_spawned_sessions_request, sessions_prune_old_request, sessions_register_extension_tools_on_session_options, sessions_release_lock_request, sessions_release_lock_result, sessions_reload_plugin_hooks_request, sessions_reload_plugin_hooks_result, sessions_save_request, sessions_save_result, sessions_set_additional_plugins_request, sessions_set_additional_plugins_result, sessions_set_remote_control_steering_request, sessions_start_remote_control_request, sessions_stop_remote_control_request, sessions_transfer_remote_control_request, session_telemetry_engagement, session_update_options_params, session_update_options_result, session_working_directory_context, session_working_directory_context_host_type, shell_cancel_user_requested_request, shell_exec_request, shell_exec_result, shell_execute_user_requested_request, shell_kill_request, shell_kill_result, shell_kill_signal, shutdown_request, skill, skill_discovery_path, skill_discovery_path_list, skill_discovery_scope, skill_list, skills_config_set_disabled_skills_request, skills_disable_request, skills_discover_request, skills_enable_request, skills_get_discovery_paths_request, skills_get_invoked_result, skills_invoked_skill, skills_load_diagnostics, slash_command_agent_prompt_result, slash_command_completed_result, slash_command_info, slash_command_input, slash_command_input_completion, slash_command_invocation_result, slash_command_kind, slash_command_select_subcommand_option, slash_command_select_subcommand_result, slash_command_text_result, subagent_settings_entry, subagent_settings_entry_context_tier, task_agent_info, task_agent_progress, task_execution_mode, task_info, task_list, task_progress_line, tasks_cancel_request, tasks_cancel_result, tasks_get_current_promotable_result, tasks_get_progress_request, tasks_get_progress_result, task_shell_info, task_shell_info_attachment_mode, task_shell_progress, tasks_promote_current_to_background_result, tasks_promote_to_background_request, tasks_promote_to_background_result, tasks_refresh_result, tasks_remove_request, tasks_remove_result, tasks_send_message_request, tasks_send_message_result, tasks_start_agent_request, tasks_start_agent_result, task_status, tasks_wait_for_pending_result, telemetry_set_feature_overrides_request, token_auth_info, tool, tool_list, tools_get_current_metadata_result, tools_initialize_and_validate_result, tools_list_request, tools_update_subagent_settings_result, ui_auto_mode_switch_response, ui_elicitation_array_any_of_field, ui_elicitation_array_any_of_field_items, ui_elicitation_array_any_of_field_items_any_of, ui_elicitation_array_enum_field, ui_elicitation_array_enum_field_items, ui_elicitation_field_value, ui_elicitation_request, ui_elicitation_response, ui_elicitation_response_action, ui_elicitation_response_content, ui_elicitation_result, ui_elicitation_schema, ui_elicitation_schema_property, ui_elicitation_schema_property_boolean, ui_elicitation_schema_property_number, ui_elicitation_schema_property_number_type, ui_elicitation_schema_property_string, ui_elicitation_schema_property_string_format, ui_elicitation_string_enum_field, ui_elicitation_string_one_of_field, ui_elicitation_string_one_of_field_one_of, ui_ephemeral_query_request, ui_ephemeral_query_result, ui_exit_plan_mode_action, ui_exit_plan_mode_response, ui_handle_pending_auto_mode_switch_request, ui_handle_pending_elicitation_request, ui_handle_pending_exit_plan_mode_request, ui_handle_pending_result, ui_handle_pending_sampling_request, ui_handle_pending_sampling_response, ui_handle_pending_user_input_request, ui_register_direct_auto_mode_switch_handler_result, ui_unregister_direct_auto_mode_switch_handler_request, ui_unregister_direct_auto_mode_switch_handler_result, ui_user_input_response, update_subagent_settings_request, usage_get_metrics_result, usage_metrics_code_changes, usage_metrics_model_metric, usage_metrics_model_metric_requests, usage_metrics_model_metric_token_detail, usage_metrics_model_metric_usage, usage_metrics_token_detail, user_auth_info, user_requested_shell_command_result, workspace_diff_file_change, workspace_diff_file_change_type, workspace_diff_mode, workspace_diff_result, workspaces_checkpoints, workspaces_create_file_request, workspaces_diff_request, workspaces_get_workspace_result, workspaces_list_checkpoints_result, workspaces_list_files_result, workspaces_read_checkpoint_request, workspaces_read_checkpoint_result, workspaces_read_file_request, workspaces_read_file_result, workspaces_save_large_paste_request, workspaces_save_large_paste_result, workspace_summary_host_type, workspaces_workspace_details_host_type, session_context_info, subagent_settings, task_progress, workspace_summary) def to_dict(self) -> dict: result: dict = {} @@ -22614,6 +22799,9 @@ def to_dict(self) -> dict: result["FolderTrustCheckParams"] = to_class(FolderTrustCheckParams, self.folder_trust_check_params) result["FolderTrustCheckResult"] = to_class(FolderTrustCheckResult, self.folder_trust_check_result) result["GhCliAuthInfo"] = to_class(GhCLIAuthInfo, self.gh_cli_auth_info) + result["GitHubTelemetryClientInfo"] = to_class(GitHubTelemetryClientInfo, self.git_hub_telemetry_client_info) + result["GitHubTelemetryEvent"] = to_class(GitHubTelemetryEvent, self.git_hub_telemetry_event) + result["GitHubTelemetryNotification"] = to_class(GitHubTelemetryNotification, self.git_hub_telemetry_notification) result["HandlePendingToolCallRequest"] = to_class(HandlePendingToolCallRequest, self.handle_pending_tool_call_request) result["HandlePendingToolCallResult"] = to_class(HandlePendingToolCallResult, self.handle_pending_tool_call_result) result["HistoryAbortManualCompactionResult"] = to_class(HistoryAbortManualCompactionResult, self.history_abort_manual_compaction_result) @@ -25451,9 +25639,16 @@ async def http_request_chunk(self, params: LlmInferenceHTTPRequestChunkRequest) "Delivers a body byte range (or a cancellation signal) for a request previously announced via httpRequestStart, correlated by requestId. The runtime fires at least one chunk per request — when there is no body, a single chunk with empty data and end=true. Mid-stream the runtime may send a chunk with cancel=true to abort the request; the SDK then stops issuing httpResponseChunk frames and may emit a terminal httpResponseChunk with error set.\n\nArgs:\n params: A request body chunk or cancellation signal.\n\nReturns:\n Acknowledgement. The SDK is free to ignore the ack and treat chunk delivery as fire-and-forget." pass +# Experimental: this API group is experimental and may change or be removed. +class GitHubTelemetryHandler(Protocol): + async def event(self, params: GitHubTelemetryNotification) -> None: + "Forwards a single GitHub telemetry event to a host connection that opted into telemetry redirection for the session.\n\nArgs:\n params: Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry redirection for the session." + pass + @dataclass class ClientGlobalApiHandlers: llm_inference: LlmInferenceHandler | None = None + git_hub_telemetry: GitHubTelemetryHandler | None = None def register_client_global_api_handlers( client: "JsonRpcClient", @@ -25479,6 +25674,13 @@ async def handle_llm_inference_http_request_chunk(params: dict) -> dict | None: result = await handler.http_request_chunk(request) return result.to_dict() client.set_request_handler("llmInference.httpRequestChunk", handle_llm_inference_http_request_chunk) + async def handle_git_hub_telemetry_event(params: dict) -> None: + request = GitHubTelemetryNotification.from_dict(params) + handler = handlers.git_hub_telemetry + if handler is None: return None + await handler.event(request) + return None + client.set_notification_method_handler("gitHubTelemetry.event", handle_git_hub_telemetry_event) __all__ = [ "APIKeyAuthInfo", @@ -25637,6 +25839,10 @@ async def handle_llm_inference_http_request_chunk(params: dict) -> dict | None: "FolderTrustCheckResult", "GhCLIAuthInfo", "GhCLIAuthInfoType", + "GitHubTelemetryClientInfo", + "GitHubTelemetryEvent", + "GitHubTelemetryHandler", + "GitHubTelemetryNotification", "HMACAuthInfo", "HMACAuthInfoType", "HandlePendingToolCallRequest", diff --git a/python/test_client.py b/python/test_client.py index f3f46c4d8b..4241a77aa0 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -1921,3 +1921,183 @@ def on_failure(input_data, invocation): }, ) assert result == {"additionalContext": "sync-ok"} + + +class TestGitHubTelemetry: + """Unit tests for the experimental gitHubTelemetry.event consumer surface.""" + + @pytest.mark.asyncio + async def test_create_session_enables_redirection_when_handler_registered(self): + client = CopilotClient( + connection=RuntimeConnection.for_stdio(path=CLI_PATH), + on_github_telemetry=lambda _notification: None, + ) + await client.start() + + try: + captured = {} + original_request = client._client.request + + async def mock_request(method, params, **kwargs): + captured[method] = params + return await original_request(method, params, **kwargs) + + client._client.request = mock_request + await client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + assert captured["session.create"]["enableGitHubTelemetryRedirection"] is True + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_create_session_omits_redirection_without_handler(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + + try: + captured = {} + original_request = client._client.request + + async def mock_request(method, params, **kwargs): + captured[method] = params + return await original_request(method, params, **kwargs) + + client._client.request = mock_request + await client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + assert "enableGitHubTelemetryRedirection" not in captured["session.create"] + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_resume_session_enables_redirection_when_handler_registered(self): + client = CopilotClient( + connection=RuntimeConnection.for_stdio(path=CLI_PATH), + on_github_telemetry=lambda _notification: None, + ) + await client.start() + + try: + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all + ) + + captured = {} + original_request = client._client.request + + async def mock_request(method, params, **kwargs): + captured[method] = params + if method == "session.resume": + return {"sessionId": session.session_id} + return await original_request(method, params, **kwargs) + + client._client.request = mock_request + await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + ) + assert captured["session.resume"]["enableGitHubTelemetryRedirection"] is True + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_resume_session_omits_redirection_without_handler(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + + try: + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all + ) + + captured = {} + original_request = client._client.request + + async def mock_request(method, params, **kwargs): + captured[method] = params + if method == "session.resume": + return {"sessionId": session.session_id} + return await original_request(method, params, **kwargs) + + client._client.request = mock_request + await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + ) + assert "enableGitHubTelemetryRedirection" not in captured["session.resume"] + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_event_routes_to_handler_via_notification_transport(self): + import asyncio + + from copilot.generated.rpc import GitHubTelemetryNotification + + received: list = [] + done = asyncio.Event() + + def on_telemetry(notification): + received.append(notification) + done.set() + + client = CopilotClient( + connection=RuntimeConnection.for_stdio(path=CLI_PATH), + on_github_telemetry=on_telemetry, + ) + await client.start() + + try: + # The method must be wired as a notification handler, NOT a request + # handler: the runtime forwards telemetry via send_notification (an + # id-less message), which never reaches the request-handler table. + assert "gitHubTelemetry.event" in client._client.notification_method_handlers + assert "gitHubTelemetry.event" not in client._client.request_handlers + + # Drive a real JSON-RPC notification (no "id") through the transport's + # message dispatch — the exact path the runtime uses. + client._client._handle_message( + { + "jsonrpc": "2.0", + "method": "gitHubTelemetry.event", + "params": { + "sessionId": "sess-telemetry", + "restricted": True, + "event": { + "kind": "tool_call_executed", + "metrics": {"duration_ms": 12.5}, + "properties": {"tool": "shell"}, + "session_id": "sess-telemetry", + }, + }, + } + ) + + await asyncio.wait_for(done.wait(), timeout=5) + + assert len(received) == 1 + notification = received[0] + assert isinstance(notification, GitHubTelemetryNotification) + assert notification.session_id == "sess-telemetry" + assert notification.restricted is True + assert notification.event.kind == "tool_call_executed" + assert notification.event.metrics["duration_ms"] == 12.5 + assert notification.event.properties["tool"] == "shell" + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_event_handler_not_registered_without_option(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + + try: + assert ( + "gitHubTelemetry.event" + not in client._client.notification_method_handlers + ) + assert "gitHubTelemetry.event" not in client._client.request_handlers + finally: + await client.force_stop() diff --git a/scripts/codegen/python.ts b/scripts/codegen/python.ts index e0c4e21412..181f8abd57 100644 --- a/scripts/codegen/python.ts +++ b/scripts/codegen/python.ts @@ -3791,6 +3791,20 @@ function emitClientGlobalRegistrationMethod( const handlerField = toSnakeCase(groupName); const handlerMethod = clientSessionHandlerMethodName(method.rpcMethod); + if (method.notification) { + // Notification methods carry no response and are dispatched via the + // notification path (an `id`-less message never reaches a request + // handler), so register on the method-specific notification registry. + lines.push(` async def ${handlerVariableName}(params: dict) -> None:`); + lines.push(` request = ${paramsType}.from_dict(params)`); + lines.push(` handler = handlers.${handlerField}`); + lines.push(` if handler is None: return None`); + lines.push(` await handler.${handlerMethod}(request)`); + lines.push(` return None`); + lines.push(` client.set_notification_method_handler("${method.rpcMethod}", ${handlerVariableName})`); + return; + } + lines.push(` async def ${handlerVariableName}(params: dict) -> dict | None:`); lines.push(` request = ${paramsType}.from_dict(params)`); lines.push(` handler = handlers.${handlerField}`); From 276aa3307bff542c06a89af3f4315b5f60cd256c Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 29 Jun 2026 14:01:52 -0700 Subject: [PATCH 05/26] go: add GitHub telemetry redirection support Regenerates the Go RPC types for the experimental gitHubTelemetry.event clientGlobal notification and adds an OnGitHubTelemetry callback. Registering a handler opts created/resumed sessions into telemetry redirection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go/client.go | 40 ++++++-- go/client_test.go | 231 ++++++++++++++++++++++++++++++++++++++++++ go/rpc/zrpc.go | 100 +++++++++++++++++- go/types.go | 7 ++ scripts/codegen/go.ts | 44 ++++++++ 5 files changed, 413 insertions(+), 9 deletions(-) diff --git a/go/client.go b/go/client.go index 970f046425..891cb11e73 100644 --- a/go/client.go +++ b/go/client.go @@ -757,6 +757,9 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses } else { req.IncludeSubAgentStreamingEvents = Bool(true) } + if c.options.OnGitHubTelemetry != nil { + req.EnableGitHubTelemetryRedirection = Bool(true) + } if config.OnUserInputRequest != nil { req.RequestUserInput = Bool(true) } @@ -1023,6 +1026,9 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, } else { req.IncludeSubAgentStreamingEvents = Bool(true) } + if c.options.OnGitHubTelemetry != nil { + req.EnableGitHubTelemetryRedirection = Bool(true) + } if config.OnUserInputRequest != nil { req.RequestUserInput = Bool(true) } @@ -2029,17 +2035,35 @@ func (c *Client) setupNotificationHandler() { } return session.clientSessionAPIs }) - if c.options.RequestHandler != nil { - adapter := newCopilotRequestAdapter(c.options.RequestHandler, func() *rpc.ServerLlmInferenceAPI { - if c.RPC == nil { - return nil - } - return c.RPC.LlmInference - }) - rpc.RegisterClientGlobalAPIHandlers(c.client, &rpc.ClientGlobalAPIHandlers{LlmInference: adapter}) + if c.options.RequestHandler != nil || c.options.OnGitHubTelemetry != nil { + handlers := &rpc.ClientGlobalAPIHandlers{} + if c.options.RequestHandler != nil { + handlers.LlmInference = newCopilotRequestAdapter(c.options.RequestHandler, func() *rpc.ServerLlmInferenceAPI { + if c.RPC == nil { + return nil + } + return c.RPC.LlmInference + }) + } + if c.options.OnGitHubTelemetry != nil { + handlers.GitHubTelemetry = &gitHubTelemetryAdapter{callback: c.options.OnGitHubTelemetry} + } + rpc.RegisterClientGlobalAPIHandlers(c.client, handlers) } } +// gitHubTelemetryAdapter adapts the OnGitHubTelemetry option to the generated +// rpc.GitHubTelemetryHandler interface. +type gitHubTelemetryAdapter struct { + callback func(notification *rpc.GitHubTelemetryNotification) +} + +func (a *gitHubTelemetryAdapter) Event(request *rpc.GitHubTelemetryNotification) error { + defer func() { recover() }() // Ignore handler panics + a.callback(request) + return nil +} + func (c *Client) handleSessionEvent(req sessionEventRequest) { if req.SessionID == "" { return diff --git a/go/client_test.go b/go/client_test.go index d59c71c6f9..f39f99ece6 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -3,6 +3,7 @@ package copilot import ( "context" "encoding/json" + "fmt" "net" "os" "os/exec" @@ -13,6 +14,7 @@ import ( "strings" "sync" "testing" + "time" "github.com/github/copilot-sdk/go/internal/jsonrpc2" "github.com/github/copilot-sdk/go/internal/truncbuffer" @@ -1973,6 +1975,235 @@ func TestResumeSessionRequest_IncludeSubAgentStreamingEvents(t *testing.T) { }) } +func TestCreateSessionRequest_EnableGitHubTelemetryRedirection(t *testing.T) { + t.Run("forwards explicit true", func(t *testing.T) { + req := createSessionRequest{ + EnableGitHubTelemetryRedirection: Bool(true), + } + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if m["enableGitHubTelemetryRedirection"] != true { + t.Errorf("Expected enableGitHubTelemetryRedirection to be true, got %v", m["enableGitHubTelemetryRedirection"]) + } + }) + + t.Run("omits when not set", func(t *testing.T) { + req := createSessionRequest{} + data, _ := json.Marshal(req) + var m map[string]any + json.Unmarshal(data, &m) + if _, ok := m["enableGitHubTelemetryRedirection"]; ok { + t.Error("Expected enableGitHubTelemetryRedirection to be omitted when not set") + } + }) +} + +func TestResumeSessionRequest_EnableGitHubTelemetryRedirection(t *testing.T) { + t.Run("forwards explicit true", func(t *testing.T) { + req := resumeSessionRequest{ + SessionID: "s1", + EnableGitHubTelemetryRedirection: Bool(true), + } + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if m["enableGitHubTelemetryRedirection"] != true { + t.Errorf("Expected enableGitHubTelemetryRedirection to be true, got %v", m["enableGitHubTelemetryRedirection"]) + } + }) + + t.Run("omits when not set", func(t *testing.T) { + req := resumeSessionRequest{SessionID: "s1"} + data, _ := json.Marshal(req) + var m map[string]any + json.Unmarshal(data, &m) + if _, ok := m["enableGitHubTelemetryRedirection"]; ok { + t.Error("Expected enableGitHubTelemetryRedirection to be omitted when not set") + } + }) +} + +func TestClient_ForwardsGitHubTelemetryRedirectionToSessionRequests(t *testing.T) { + rpcClient, server, _ := newRuntimeShutdownRpcPair(t) + t.Cleanup(server.Stop) + client := &Client{ + client: rpcClient, + RPC: rpc.NewServerRPC(rpcClient), + sessions: make(map[string]*Session), + options: ClientOptions{OnGitHubTelemetry: func(*rpc.GitHubTelemetryNotification) {}}, + } + + createParams := make(chan json.RawMessage, 1) + server.SetRequestHandler("session.create", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + createParams <- append(json.RawMessage(nil), params...) + sessionID := sessionIDFromParams(t, params) + return []byte(`{"sessionId":"` + sessionID + `","workspacePath":"/workspace"}`), nil + }) + + if _, err := client.CreateSession(t.Context(), &SessionConfig{}); err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + assertRedirectionFlagTrue(t, <-createParams) + + resumeParams := make(chan json.RawMessage, 1) + server.SetRequestHandler("session.resume", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + resumeParams <- append(json.RawMessage(nil), params...) + return []byte(`{"sessionId":"resumed","workspacePath":"/workspace"}`), nil + }) + + if _, err := client.ResumeSessionWithOptions(t.Context(), "resumed", &ResumeSessionConfig{}); err != nil { + t.Fatalf("ResumeSessionWithOptions failed: %v", err) + } + assertRedirectionFlagTrue(t, <-resumeParams) +} + +func assertRedirectionFlagTrue(t *testing.T, params json.RawMessage) { + t.Helper() + var decoded map[string]any + if err := json.Unmarshal(params, &decoded); err != nil { + t.Fatalf("failed to unmarshal request params: %v", err) + } + if decoded["enableGitHubTelemetryRedirection"] != true { + t.Fatalf("expected enableGitHubTelemetryRedirection=true, got %v", decoded["enableGitHubTelemetryRedirection"]) + } +} + +func TestClient_OmitsGitHubTelemetryRedirectionWhenNoHandler(t *testing.T) { + rpcClient, server, _ := newRuntimeShutdownRpcPair(t) + t.Cleanup(server.Stop) + client := &Client{ + client: rpcClient, + RPC: rpc.NewServerRPC(rpcClient), + sessions: make(map[string]*Session), + options: ClientOptions{}, + } + + createParams := make(chan json.RawMessage, 1) + server.SetRequestHandler("session.create", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + createParams <- append(json.RawMessage(nil), params...) + sessionID := sessionIDFromParams(t, params) + return []byte(`{"sessionId":"` + sessionID + `","workspacePath":"/workspace"}`), nil + }) + + if _, err := client.CreateSession(t.Context(), &SessionConfig{}); err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + assertRedirectionFlagAbsent(t, <-createParams) + + resumeParams := make(chan json.RawMessage, 1) + server.SetRequestHandler("session.resume", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + resumeParams <- append(json.RawMessage(nil), params...) + return []byte(`{"sessionId":"resumed","workspacePath":"/workspace"}`), nil + }) + + if _, err := client.ResumeSessionWithOptions(t.Context(), "resumed", &ResumeSessionConfig{}); err != nil { + t.Fatalf("ResumeSessionWithOptions failed: %v", err) + } + assertRedirectionFlagAbsent(t, <-resumeParams) +} + +func assertRedirectionFlagAbsent(t *testing.T, params json.RawMessage) { + t.Helper() + var decoded map[string]any + if err := json.Unmarshal(params, &decoded); err != nil { + t.Fatalf("failed to unmarshal request params: %v", err) + } + if _, ok := decoded["enableGitHubTelemetryRedirection"]; ok { + t.Fatalf("expected enableGitHubTelemetryRedirection to be omitted, got %v", decoded["enableGitHubTelemetryRedirection"]) + } +} + +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 + // verify that a real Client wired with OnGitHubTelemetry routes it to the + // callback through the client's own client-global handler registration + // (setupNotificationHandler), rather than registering the adapter by hand. + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + rpcClient := jsonrpc2.NewClient(clientConn, clientConn) + rpcClient.Start() + defer rpcClient.Stop() + + // Drain the client->server direction so net.Pipe writes never block. + go func() { + buf := make([]byte, 4096) + for { + if _, err := serverConn.Read(buf); err != nil { + return + } + } + }() + + received := make(chan *rpc.GitHubTelemetryNotification, 1) + client := &Client{ + client: rpcClient, + RPC: rpc.NewServerRPC(rpcClient), + sessions: make(map[string]*Session), + options: ClientOptions{ + OnGitHubTelemetry: func(n *rpc.GitHubTelemetryNotification) { received <- n }, + }, + } + // setupNotificationHandler is what registers the gitHubTelemetryAdapter when + // OnGitHubTelemetry is set; exercising it here covers the real client wiring. + client.setupNotificationHandler() + + notification := map[string]any{ + "jsonrpc": "2.0", + "method": "gitHubTelemetry.event", + "params": map[string]any{ + "sessionId": "sess-telemetry", + "restricted": true, + "event": map[string]any{ + "kind": "tool_call_executed", + "metrics": map[string]any{"duration_ms": 12.5}, + "properties": map[string]any{"tool": "shell"}, + }, + }, + } + data, err := json.Marshal(notification) + if err != nil { + t.Fatalf("marshal notification: %v", err) + } + go func() { + _, _ = fmt.Fprintf(serverConn, "Content-Length: %d\r\n\r\n%s", len(data), data) + }() + + select { + case n := <-received: + if n.SessionID != "sess-telemetry" { + t.Errorf("session id = %q, want sess-telemetry", n.SessionID) + } + if !n.Restricted { + t.Error("expected restricted to be true") + } + if n.Event.Kind != "tool_call_executed" { + t.Errorf("kind = %q, want tool_call_executed", n.Event.Kind) + } + if n.Event.Metrics["duration_ms"] != 12.5 { + t.Errorf("metrics[duration_ms] = %v, want 12.5", n.Event.Metrics["duration_ms"]) + } + if n.Event.Properties["tool"] != "shell" { + t.Errorf("properties[tool] = %q, want shell", n.Event.Properties["tool"]) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for telemetry notification") + } +} + func TestCreateSessionRequest_EnableOnDemandInstructionDiscovery(t *testing.T) { t.Run("forwards explicit true", func(t *testing.T) { req := createSessionRequest{ diff --git a/go/rpc/zrpc.go b/go/rpc/zrpc.go index 03ec16cea1..cfd6452a6d 100644 --- a/go/rpc/zrpc.go +++ b/go/rpc/zrpc.go @@ -1713,6 +1713,76 @@ type FolderTrustCheckResult struct { Trusted bool `json:"trusted"` } +// Client environment metadata describing the process that produced a telemetry event. +// Experimental: GitHubTelemetryClientInfo is part of an experimental API and may change or +// be removed. +type GitHubTelemetryClientInfo struct { + // Name of the client application. + ClientName *string `json:"client_name,omitempty"` + // Type of client. + ClientType *string `json:"client_type,omitempty"` + // Copilot CLI version string. + CLIVersion string `json:"cli_version"` + // Copilot subscription plan, when known. + CopilotPlan *string `json:"copilot_plan,omitempty"` + // Stable machine identifier for the device. + DevDeviceID *string `json:"dev_device_id,omitempty"` + // Whether the user is a GitHub/Microsoft staff member. + IsStaff *bool `json:"is_staff,omitempty"` + // Node.js runtime version string. + NodeVersion string `json:"node_version"` + // Operating system architecture (e.g. arm64, x64). + OsArch string `json:"os_arch"` + // Operating system platform (e.g. darwin, linux, win32). + OsPlatform string `json:"os_platform"` + // Operating system version string. + OsVersion string `json:"os_version"` +} + +// A single telemetry event in the runtime's native GitHub-shaped telemetry format, +// forwarded verbatim to opted-in hosts. The `restricted` flag on the enclosing +// GitHubTelemetryNotification distinguishes standard from restricted events; the payload +// shape is identical for both. +// Experimental: GitHubTelemetryEvent is part of an experimental API and may change or be +// removed. +type GitHubTelemetryEvent struct { + // Client environment metadata. + Client *GitHubTelemetryClientInfo `json:"client,omitempty"` + // Copilot tracking ID for user-level attribution. + CopilotTrackingID *string `json:"copilot_tracking_id,omitempty"` + // Timestamp when the event was created (ISO 8601 format). + CreatedAt *string `json:"created_at,omitempty"` + // Experiment assignment context. + ExpAssignmentContext *string `json:"exp_assignment_context,omitempty"` + // Feature flags enabled for this session, as a map from flag to value. + Features map[string]string `json:"features,omitzero"` + // Event type/kind (e.g. get_completion_with_tools_turn, tool_call_executed). + Kind string `json:"kind"` + // Numeric metrics as a map from key to value. + Metrics map[string]float64 `json:"metrics"` + // Reference to the model call that produced this event. + ModelCallID *string `json:"model_call_id,omitempty"` + // String-valued properties as a map from key to value. + Properties map[string]string `json:"properties"` + // Session identifier the event belongs to. + SessionID *string `json:"session_id,omitempty"` +} + +// Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the +// runtime forwards to a host connection that opted into telemetry redirection for the +// session. +// Experimental: GitHubTelemetryNotification is part of an experimental API and may change +// or be removed. +type GitHubTelemetryNotification struct { + // The telemetry event, in the runtime's native GitHub-shaped telemetry format. + Event GitHubTelemetryEvent `json:"event"` + // Whether this is a restricted telemetry event (cli.restricted_telemetry). Hosts must route + // restricted events to first-party Microsoft stores only. + Restricted bool `json:"restricted"` + // Session the telemetry event belongs to. + SessionID string `json:"sessionId"` +} + // Pending external tool call request ID, with the tool result or an error describing why it // failed. // Experimental: HandlePendingToolCallRequest is part of an experimental API and may change @@ -17463,6 +17533,20 @@ func RegisterClientSessionAPIHandlers(client *jsonrpc2.Client, getHandlers func( }) } +// Experimental: GitHubTelemetryHandler contains experimental APIs that may change or be +// removed. +type GitHubTelemetryHandler interface { + // Event forwards a single GitHub telemetry event to a host connection that opted into + // telemetry redirection for the session. + // + // RPC method: gitHubTelemetry.event. + // + // Parameters: Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry + // event the runtime forwards to a host connection that opted into telemetry redirection for + // the session. + Event(request *GitHubTelemetryNotification) error +} + // Experimental: LlmInferenceHandler contains experimental APIs that may change or be // removed. type LlmInferenceHandler interface { @@ -17499,7 +17583,8 @@ type LlmInferenceHandler interface { // Unlike client-session handlers these carry no implicit session id dispatch // key; a single set of handlers serves the entire connection. type ClientGlobalAPIHandlers struct { - LlmInference LlmInferenceHandler + GitHubTelemetry GitHubTelemetryHandler + LlmInference LlmInferenceHandler } func clientGlobalHandlerError(err error) *jsonrpc2.Error { @@ -17516,6 +17601,19 @@ func clientGlobalHandlerError(err error) *jsonrpc2.Error { // RegisterClientGlobalAPIHandlers registers handlers for server-to-client client-global API // calls. func RegisterClientGlobalAPIHandlers(client *jsonrpc2.Client, handlers *ClientGlobalAPIHandlers) { + client.SetRequestHandler("gitHubTelemetry.event", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + var request GitHubTelemetryNotification + if err := json.Unmarshal(params, &request); err != nil { + return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("Invalid params: %v", err)} + } + if handlers == nil || handlers.GitHubTelemetry == nil { + return nil, nil + } + if err := handlers.GitHubTelemetry.Event(&request); err != nil { + return nil, clientGlobalHandlerError(err) + } + return nil, nil + }) client.SetRequestHandler("llmInference.httpRequestChunk", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { var request LlmInferenceHTTPRequestChunkRequest if err := json.Unmarshal(params, &request); err != nil { diff --git a/go/types.go b/go/types.go index 8a7df3c46a..2e503af1ee 100644 --- a/go/types.go +++ b/go/types.go @@ -122,6 +122,11 @@ type ClientOptions struct { // this handler instead of issuing the calls itself. Works for both CAPI // and BYOK sessions. RequestHandler *CopilotRequestHandler + // OnGitHubTelemetry registers a connection-level callback (experimental) + // that receives GitHub telemetry events the runtime forwards for sessions + // opened by this client. When non-nil, every session created or resumed by + // this client opts into telemetry redirection (enableGitHubTelemetryRedirection). + OnGitHubTelemetry func(notification *rpc.GitHubTelemetryNotification) // Telemetry configures OpenTelemetry integration for the runtime. // When non-nil, COPILOT_OTEL_ENABLED=true is set and any populated // fields are mapped to the corresponding environment variables. @@ -1977,6 +1982,7 @@ type createSessionRequest struct { WorkingDirectory string `json:"workingDirectory,omitempty"` Streaming *bool `json:"streaming,omitempty"` IncludeSubAgentStreamingEvents *bool `json:"includeSubAgentStreamingEvents,omitempty"` + EnableGitHubTelemetryRedirection *bool `json:"enableGitHubTelemetryRedirection,omitempty"` MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` MCPOAuthTokenStorage string `json:"mcpOAuthTokenStorage,omitempty"` EnvValueMode string `json:"envValueMode,omitempty"` @@ -2072,6 +2078,7 @@ type resumeSessionRequest struct { ContinuePendingWork *bool `json:"continuePendingWork,omitempty"` Streaming *bool `json:"streaming,omitempty"` IncludeSubAgentStreamingEvents *bool `json:"includeSubAgentStreamingEvents,omitempty"` + EnableGitHubTelemetryRedirection *bool `json:"enableGitHubTelemetryRedirection,omitempty"` MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` MCPOAuthTokenStorage string `json:"mcpOAuthTokenStorage,omitempty"` EnvValueMode string `json:"envValueMode,omitempty"` diff --git a/scripts/codegen/go.ts b/scripts/codegen/go.ts index 5403fb4444..fec25953d1 100644 --- a/scripts/codegen/go.ts +++ b/scripts/codegen/go.ts @@ -3846,6 +3846,28 @@ async function generateRpc(schemaPath?: string): Promise { } } + // Client-global methods are intentionally excluded from `allMethods` above + // (which drives server/session/clientSession wrapper synthesis). A void + // client-global result has no named definition in the schema, so its empty + // `*Result` wrapper would otherwise be referenced but never emitted. Emit it + // here, mirroring the void handling applied to the other method groups. + for (const method of collectRpcMethods(schema.clientGlobal || {})) { + const resultSchema = getMethodResultSchema(method); + const resultTypeName = goResultTypeName(method); + if ( + isVoidSchema(resultSchema) && + !method.notification && + !(resultTypeName in allDefinitions) + ) { + allDefinitions[resultTypeName] = { + title: resultTypeName, + type: "object", + properties: {}, + additionalProperties: false, + }; + } + } + const allDefinitionCollections: DefinitionCollections = { definitions: { ...(rpcDefinitions.$defs ?? {}), ...allDefinitions }, $defs: { ...allDefinitions, ...(rpcDefinitions.$defs ?? {}) }, @@ -4384,6 +4406,10 @@ function emitClientGlobalApiRegistration(lines: string[], clientSchema: Record Date: Mon, 29 Jun 2026 14:02:00 -0700 Subject: [PATCH 06/26] rust: add GitHub telemetry redirection support Regenerates the Rust RPC types for the experimental gitHubTelemetry.event clientGlobal notification and adds an on_github_telemetry callback. Registering a handler opts created/resumed sessions into telemetry redirection. The telemetry module is #[doc(hidden)] to keep it unadvertised. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/src/generated/api_types.rs | 108 +++++++++++++++ rust/src/github_telemetry.rs | 28 ++++ rust/src/lib.rs | 73 ++++++++++ rust/src/router.rs | 35 +++++ rust/src/session.rs | 8 +- rust/src/types.rs | 2 + rust/src/wire.rs | 10 ++ rust/tests/session_test.rs | 228 ++++++++++++++++++++++++++++++++ 8 files changed, 490 insertions(+), 2 deletions(-) create mode 100644 rust/src/github_telemetry.rs diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index b1d85c0a58..10c506c152 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -13204,6 +13204,114 @@ pub struct WorkspaceSummary { pub user_named: Option, } +/// Client environment metadata describing the process that produced a telemetry event. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitHubTelemetryClientInfo { + /// Copilot CLI version string. + #[serde(rename = "cli_version")] + pub cli_version: String, + /// Name of the client application. + #[serde(rename = "client_name", skip_serializing_if = "Option::is_none")] + pub client_name: Option, + /// Type of client. + #[serde(rename = "client_type", skip_serializing_if = "Option::is_none")] + pub client_type: Option, + /// Copilot subscription plan, when known. + #[serde(rename = "copilot_plan", skip_serializing_if = "Option::is_none")] + pub copilot_plan: Option, + /// Stable machine identifier for the device. + #[serde(rename = "dev_device_id", skip_serializing_if = "Option::is_none")] + pub dev_device_id: Option, + /// Whether the user is a GitHub/Microsoft staff member. + #[serde(rename = "is_staff", skip_serializing_if = "Option::is_none")] + pub is_staff: Option, + /// Node.js runtime version string. + #[serde(rename = "node_version")] + pub node_version: String, + /// Operating system architecture (e.g. arm64, x64). + #[serde(rename = "os_arch")] + pub os_arch: String, + /// Operating system platform (e.g. darwin, linux, win32). + #[serde(rename = "os_platform")] + pub os_platform: String, + /// Operating system version string. + #[serde(rename = "os_version")] + pub os_version: String, +} + +/// A single telemetry event in the runtime's native GitHub-shaped telemetry format, forwarded verbatim to opted-in hosts. The `restricted` flag on the enclosing GitHubTelemetryNotification distinguishes standard from restricted events; the payload shape is identical for both. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitHubTelemetryEvent { + /// Client environment metadata. + #[serde(skip_serializing_if = "Option::is_none")] + pub client: Option, + /// Copilot tracking ID for user-level attribution. + #[serde( + rename = "copilot_tracking_id", + skip_serializing_if = "Option::is_none" + )] + pub copilot_tracking_id: Option, + /// Timestamp when the event was created (ISO 8601 format). + #[serde(rename = "created_at", skip_serializing_if = "Option::is_none")] + pub created_at: Option, + /// Experiment assignment context. + #[serde( + rename = "exp_assignment_context", + skip_serializing_if = "Option::is_none" + )] + pub exp_assignment_context: Option, + /// Feature flags enabled for this session, as a map from flag to value. + #[serde(skip_serializing_if = "Option::is_none")] + pub features: Option>, + /// Event type/kind (e.g. get_completion_with_tools_turn, tool_call_executed). + pub kind: String, + /// Numeric metrics as a map from key to value. + pub metrics: HashMap, + /// Reference to the model call that produced this event. + #[serde(rename = "model_call_id", skip_serializing_if = "Option::is_none")] + pub model_call_id: Option, + /// String-valued properties as a map from key to value. + pub properties: HashMap, + /// Session identifier the event belongs to. + #[serde(rename = "session_id", skip_serializing_if = "Option::is_none")] + pub session_id: Option, +} + +/// Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry redirection for the session. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitHubTelemetryNotification { + /// The telemetry event, in the runtime's native GitHub-shaped telemetry format. + pub event: GitHubTelemetryEvent, + /// Whether this is a restricted telemetry event (cli.restricted_telemetry). Hosts must route restricted events to first-party Microsoft stores only. + pub restricted: bool, + /// Session the telemetry event belongs to. + pub session_id: SessionId, +} + /// List of Copilot models available to the resolved user, including capabilities and billing metadata. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/rust/src/github_telemetry.rs b/rust/src/github_telemetry.rs new file mode 100644 index 0000000000..509035a224 --- /dev/null +++ b/rust/src/github_telemetry.rs @@ -0,0 +1,28 @@ +//! GitHub telemetry redirection callback surface. +//! +//! The runtime forwards per-session GitHub (hydro) telemetry to opted-in host +//! connections via the `gitHubTelemetry.event` JSON-RPC notification. The +//! payload types (`GitHubTelemetryNotification`, `GitHubTelemetryEvent`, +//! `GitHubTelemetryClientInfo`) are generated from the protocol schema and +//! re-exported here so consumers can register a callback against them via +//! [`ClientOptions::on_github_telemetry`](crate::ClientOptions::on_github_telemetry). +//! +//! Experimental: this surface is part of the GitHub telemetry redirection +//! feature and may change or be removed without notice. + +use std::sync::Arc; + +#[doc(hidden)] +pub use crate::generated::api_types::{ + GitHubTelemetryClientInfo, GitHubTelemetryEvent, GitHubTelemetryNotification, +}; + +/// Callback invoked for each `gitHubTelemetry.event` notification forwarded by +/// the runtime to a connection that opted into telemetry redirection. +/// +/// Set via +/// [`ClientOptions::on_github_telemetry`](crate::ClientOptions::on_github_telemetry). +/// Registering a callback auto-enables telemetry redirection on every session +/// created or resumed by the client. +#[doc(hidden)] +pub type GitHubTelemetryCallback = Arc; diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 22fdc53d78..136def8570 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -15,6 +15,10 @@ pub use errors::*; /// model-layer HTTP and WebSocket traffic the runtime issues for both CAPI and /// BYOK sessions. pub mod copilot_request_handler; +/// GitHub telemetry redirection callback surface (experimental). Public but +/// `#[doc(hidden)]` — re-exports the generated telemetry payload types. +#[doc(hidden)] +pub mod github_telemetry; /// Event handler traits for session lifecycle. pub mod handler; /// Lifecycle hook callbacks (pre/post tool use, prompt submission, session start/end). @@ -257,6 +261,15 @@ pub struct ClientOptions { /// [`CopilotRequestHandler`] /// instead of issuing the calls itself. pub request_handler: Option>, + /// Connection-level GitHub telemetry redirection callback (experimental). + /// + /// When set, every session created or resumed on this client opts into + /// telemetry redirection (`enableGitHubTelemetryRedirection`) and the + /// callback is invoked for each `gitHubTelemetry.event` notification the + /// runtime forwards. `#[doc(hidden)]`, consistent with the experimental + /// telemetry payload types. + #[doc(hidden)] + pub on_github_telemetry: Option, /// Optional [`TraceContextProvider`] used to inject W3C Trace Context /// headers (`traceparent` / `tracestate`) on outbound `session.create`, /// `session.resume`, and `session.send` requests. @@ -336,6 +349,10 @@ impl std::fmt::Debug for ClientOptions { "request_handler", &self.request_handler.as_ref().map(|_| ""), ) + .field( + "on_github_telemetry", + &self.on_github_telemetry.as_ref().map(|_| ""), + ) .field( "on_get_trace_context", &self.on_get_trace_context.as_ref().map(|_| ""), @@ -584,6 +601,7 @@ impl Default for ClientOptions { on_list_models: None, session_fs: None, request_handler: None, + on_github_telemetry: None, on_get_trace_context: None, telemetry: None, base_directory: None, @@ -728,6 +746,20 @@ impl ClientOptions { self } + /// Register a connection-level GitHub telemetry redirection callback + /// (internal/experimental). Registering a callback auto-enables telemetry + /// redirection on every session created or resumed on this client; the + /// callback fires for each forwarded `gitHubTelemetry.event` notification. + /// The callback is wrapped in `Arc` internally. + #[doc(hidden)] + pub fn with_on_github_telemetry(mut self, callback: F) -> Self + where + F: Fn(crate::github_telemetry::GitHubTelemetryNotification) + Send + Sync + 'static, + { + self.on_github_telemetry = Some(Arc::new(callback)); + self + } + /// Set the [`TraceContextProvider`] used to inject W3C Trace Context /// headers on outbound `session.create` / `session.resume` / /// `session.send` requests. The provider is wrapped in `Arc` internally. @@ -853,6 +885,11 @@ struct ClientInner { /// Inbound `llmInference.*` dispatcher, installed when /// [`ClientOptions::request_handler`] is set. llm_inference: OnceLock>, + /// Connection-level GitHub telemetry redirection callback, set from + /// [`ClientOptions::on_github_telemetry`]. Drives the + /// `enableGitHubTelemetryRedirection` wire flag and the + /// `gitHubTelemetry.event` notification dispatch. + on_github_telemetry: Option, on_get_trace_context: Option>, /// Token sent in the `connect` handshake. Auto-generated when the /// SDK spawns its own CLI in TCP mode and no explicit token is set; @@ -1005,6 +1042,7 @@ impl Client { session_fs_config.is_some(), session_fs_sqlite_declared, options.on_get_trace_context, + options.on_github_telemetry, effective_connection_token.clone(), options.mode, )? @@ -1032,6 +1070,7 @@ impl Client { session_fs_config.is_some(), session_fs_sqlite_declared, options.on_get_trace_context, + options.on_github_telemetry, effective_connection_token.clone(), options.mode, )? @@ -1050,6 +1089,7 @@ impl Client { session_fs_config.is_some(), session_fs_sqlite_declared, options.on_get_trace_context, + options.on_github_telemetry, effective_connection_token.clone(), options.mode, )? @@ -1097,6 +1137,7 @@ impl Client { &client.inner.notification_tx, &client.inner.request_rx, Some(dispatcher.clone()), + client.inner.on_github_telemetry.clone(), ); client.rpc().llm_inference().set_provider().await?; debug!( @@ -1129,6 +1170,7 @@ impl Client { false, None, None, + None, ClientMode::default(), ) } @@ -1157,6 +1199,7 @@ impl Client { false, Some(provider), None, + None, ClientMode::default(), ) } @@ -1180,11 +1223,37 @@ impl Client { false, false, None, + None, token, ClientMode::default(), ) } + /// Construct a [`Client`] from raw streams with a preset GitHub telemetry + /// callback, for integration testing telemetry redirection. + #[doc(hidden)] + #[cfg(any(test, feature = "test-support"))] + pub fn from_streams_with_github_telemetry( + reader: impl AsyncRead + Unpin + Send + 'static, + writer: impl AsyncWrite + Unpin + Send + 'static, + cwd: PathBuf, + on_github_telemetry: crate::github_telemetry::GitHubTelemetryCallback, + ) -> Result { + Self::from_transport( + reader, + writer, + None, + cwd, + None, + false, + false, + None, + Some(on_github_telemetry), + None, + ClientMode::default(), + ) + } + /// Public test-only wrapper around the random connection-token /// generator used by [`Client::start`] when the SDK spawns a TCP /// server without an explicit token. Lets integration tests @@ -1205,6 +1274,7 @@ impl Client { session_fs_configured: bool, session_fs_sqlite_declared: bool, on_get_trace_context: Option>, + on_github_telemetry: Option, effective_connection_token: Option, mode: ClientMode, ) -> Result { @@ -1237,6 +1307,7 @@ impl Client { session_fs_configured, session_fs_sqlite_declared, llm_inference: OnceLock::new(), + on_github_telemetry, on_get_trace_context, effective_connection_token, mode, @@ -1646,6 +1717,7 @@ impl Client { &self.inner.notification_tx, &self.inner.request_rx, self.inner.llm_inference.get().cloned(), + self.inner.on_github_telemetry.clone(), ); self.inner.router.register(session_id) } @@ -2732,6 +2804,7 @@ mod tests { session_fs_configured: false, session_fs_sqlite_declared: false, llm_inference: OnceLock::new(), + on_github_telemetry: None, on_get_trace_context: None, effective_connection_token: None, mode: ClientMode::default(), diff --git a/rust/src/router.rs b/rust/src/router.rs index cc621c287c..cbd900d2d6 100644 --- a/rust/src/router.rs +++ b/rust/src/router.rs @@ -86,6 +86,7 @@ impl SessionRouter { notification_tx: &broadcast::Sender, request_rx: &Mutex>>, llm_inference: Option>, + github_telemetry: Option, ) { let mut started = self.started.lock(); if *started { @@ -100,6 +101,40 @@ impl SessionRouter { loop { match notif_rx.recv().await { Ok(notification) => { + // Client-global `gitHubTelemetry.event` notifications carry + // no routable session and are surfaced to the consumer + // callback (if any) registered at client construction. + if notification.method == "gitHubTelemetry.event" { + if let Some(ref callback) = github_telemetry { + let Some(ref params) = notification.params else { + continue; + }; + match serde_json::from_value::< + crate::github_telemetry::GitHubTelemetryNotification, + >(params.clone()) + { + Ok(telemetry) => { + if std::panic::catch_unwind(std::panic::AssertUnwindSafe( + || callback(telemetry), + )) + .is_err() + { + warn!( + "gitHubTelemetry.event callback panicked; \ + continuing notification routing" + ); + } + } + Err(e) => { + warn!( + error = %e, + "failed to deserialize gitHubTelemetry.event notification" + ); + } + } + } + continue; + } if notification.method != "session.event" { continue; } diff --git a/rust/src/session.rs b/rust/src/session.rs index 18b91b4377..08b7215c6e 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -872,7 +872,9 @@ impl Client { let opt_custom_agents_local_only = config.custom_agents_local_only; let opt_coauthor_enabled = config.coauthor_enabled; let opt_manage_schedule_enabled = config.manage_schedule_enabled; - let (wire, mut runtime) = config.into_wire(local_session_id.clone())?; + let (mut wire, mut runtime) = config.into_wire(local_session_id.clone())?; + wire.enable_github_telemetry_redirection = + self.inner.on_github_telemetry.is_some().then_some(true); let permission_handler = crate::permission::resolve_handler( runtime.permission_handler.take(), @@ -1130,7 +1132,9 @@ impl Client { let opt_custom_agents_local_only = config.custom_agents_local_only; let opt_coauthor_enabled = config.coauthor_enabled; let opt_manage_schedule_enabled = config.manage_schedule_enabled; - let (wire, mut runtime) = config.into_wire()?; + let (mut wire, mut runtime) = config.into_wire()?; + wire.enable_github_telemetry_redirection = + self.inner.on_github_telemetry.is_some().then_some(true); let permission_handler = crate::permission::resolve_handler( runtime.permission_handler.take(), diff --git a/rust/src/types.rs b/rust/src/types.rs index 75408db026..9994079ba3 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -2135,6 +2135,7 @@ impl SessionConfig { remote_session: self.remote_session, cloud: self.cloud, include_sub_agent_streaming_events: self.include_sub_agent_streaming_events, + enable_github_telemetry_redirection: None, commands: wire_commands, exp_assignments: self.exp_assignments, }; @@ -3093,6 +3094,7 @@ impl ResumeSessionConfig { github_token: self.github_token, remote_session: self.remote_session, include_sub_agent_streaming_events: self.include_sub_agent_streaming_events, + enable_github_telemetry_redirection: None, commands: wire_commands, exp_assignments: self.exp_assignments, suppress_resume_event: self.suppress_resume_event, diff --git a/rust/src/wire.rs b/rust/src/wire.rs index e6dad66d58..ba5141f682 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -153,6 +153,11 @@ pub(crate) struct SessionCreateWire { pub cloud: Option, #[serde(skip_serializing_if = "Option::is_none")] pub include_sub_agent_streaming_events: Option, + #[serde( + rename = "enableGitHubTelemetryRedirection", + skip_serializing_if = "Option::is_none" + )] + pub enable_github_telemetry_redirection: Option, #[serde(skip_serializing_if = "Option::is_none")] pub commands: Option>, #[serde(skip_serializing_if = "Option::is_none")] @@ -268,6 +273,11 @@ pub(crate) struct SessionResumeWire { pub remote_session: Option, #[serde(skip_serializing_if = "Option::is_none")] pub include_sub_agent_streaming_events: Option, + #[serde( + rename = "enableGitHubTelemetryRedirection", + skip_serializing_if = "Option::is_none" + )] + pub enable_github_telemetry_redirection: Option, #[serde(skip_serializing_if = "Option::is_none")] pub commands: Option>, /// Maps to wire field `disableResume`. diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 98c6248230..ff8f7f3223 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -373,6 +373,234 @@ async fn create_session_sends_canvas_wire_fields() { timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); } +fn make_client_with_telemetry( + callback: github_copilot_sdk::github_telemetry::GitHubTelemetryCallback, +) -> (Client, tokio::io::DuplexStream, tokio::io::DuplexStream) { + let (client_write, server_read) = duplex(8192); + let (server_write, client_read) = duplex(8192); + let client = Client::from_streams_with_github_telemetry( + client_read, + client_write, + std::env::temp_dir(), + callback, + ) + .unwrap(); + (client, server_read, server_write) +} + +#[tokio::test] +async fn create_and_resume_send_github_telemetry_redirection_when_callback_registered() { + use github_copilot_sdk::types::ResumeSessionConfig; + + 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 create_handle = tokio::spawn({ + let client = client.clone(); + async move { + client + .create_session(SessionConfig::default()) + .await + .unwrap() + } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "session.create"); + assert_eq!(request["params"]["enableGitHubTelemetryRedirection"], true); + + let id = request["id"].as_u64().unwrap(); + let session_id = requested_session_id(&request).to_string(); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "sessionId": session_id.clone() }, + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); + + let resume_handle = tokio::spawn({ + let client = client.clone(); + let session_id = session_id.clone(); + async move { + client + .resume_session(ResumeSessionConfig::new(SessionId::from(session_id))) + .await + .unwrap() + } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "session.resume"); + assert_eq!(request["params"]["enableGitHubTelemetryRedirection"], true); + + let id = request["id"].as_u64().unwrap(); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "sessionId": session_id }, + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + + let reload = read_framed(&mut server_read).await; + assert_eq!(reload["method"], "session.skills.reload"); + let id = reload["id"].as_u64().unwrap(); + let response = serde_json::json!({ "jsonrpc": "2.0", "id": id, "result": {} }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + + timeout(TIMEOUT, resume_handle).await.unwrap().unwrap(); +} + +#[tokio::test] +async fn create_session_omits_github_telemetry_redirection_without_callback() { + let (client, mut server_read, mut server_write) = make_client(); + + let create_handle = tokio::spawn({ + let client = client.clone(); + async move { + client + .create_session(SessionConfig::default()) + .await + .unwrap() + } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "session.create"); + assert!( + request["params"] + .get("enableGitHubTelemetryRedirection") + .is_none_or(Value::is_null), + "redirection flag should be omitted when no callback is registered" + ); + + let id = request["id"].as_u64().unwrap(); + let session_id = requested_session_id(&request).to_string(); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "sessionId": session_id }, + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); +} + +#[tokio::test] +async fn resume_session_omits_github_telemetry_redirection_without_callback() { + use github_copilot_sdk::types::ResumeSessionConfig; + + let (client, mut server_read, mut server_write) = make_client(); + + let resume_handle = tokio::spawn({ + let client = client.clone(); + async move { + client + .resume_session(ResumeSessionConfig::new(SessionId::from( + "sess-1".to_string(), + ))) + .await + .unwrap() + } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "session.resume"); + assert!( + request["params"] + .get("enableGitHubTelemetryRedirection") + .is_none_or(Value::is_null), + "redirection 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": { "sessionId": "sess-1" }, + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + + let reload = read_framed(&mut server_read).await; + assert_eq!(reload["method"], "session.skills.reload"); + let id = reload["id"].as_u64().unwrap(); + let response = serde_json::json!({ "jsonrpc": "2.0", "id": id, "result": {} }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + + timeout(TIMEOUT, resume_handle).await.unwrap().unwrap(); +} + +#[tokio::test] +async fn github_telemetry_event_dispatches_to_callback() { + use github_copilot_sdk::github_telemetry::GitHubTelemetryNotification; + + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let callback: github_copilot_sdk::github_telemetry::GitHubTelemetryCallback = + Arc::new(move |notification| { + let _ = tx.send(notification); + }); + let (client, mut server_read, mut server_write) = make_client_with_telemetry(callback); + + let create_handle = tokio::spawn({ + let client = client.clone(); + async move { + client + .create_session(SessionConfig::default()) + .await + .unwrap() + } + }); + + let request = read_framed(&mut server_read).await; + let id = request["id"].as_u64().unwrap(); + let session_id = requested_session_id(&request).to_string(); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "sessionId": session_id.clone() }, + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); + + let notification = serde_json::json!({ + "jsonrpc": "2.0", + "method": "gitHubTelemetry.event", + "params": { + "sessionId": session_id.clone(), + "restricted": false, + "event": { + "kind": "tool_call_executed", + "properties": { "tool": "bash" }, + "metrics": { "duration_ms": 12.0 }, + "session_id": session_id.clone(), + "created_at": "2025-01-01T00:00:00Z" + } + } + }); + write_framed( + &mut server_write, + &serde_json::to_vec(¬ification).unwrap(), + ) + .await; + + let received = timeout(TIMEOUT, rx.recv()).await.unwrap().unwrap(); + assert_eq!(received.session_id, session_id); + assert!(!received.restricted); + assert_eq!(received.event.kind, "tool_call_executed"); + assert_eq!( + received.event.properties.get("tool").map(String::as_str), + Some("bash") + ); + assert_eq!( + received.event.metrics.get("duration_ms").copied(), + Some(12.0) + ); + assert_eq!( + received.event.created_at.as_deref(), + Some("2025-01-01T00:00:00Z") + ); +} + #[tokio::test] async fn provider_canvas_dispatch_routes_direct_canvas_action_requests() { let (session, mut server) = create_session_pair_with_config(|cfg| { From 50012683a937c909caa4c37f0e8c8c4deda1302f Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 29 Jun 2026 14:02:09 -0700 Subject: [PATCH 07/26] java: add GitHub telemetry redirection support Adds the experimental gitHubTelemetry.event notification adapter and an onGitHubTelemetry client option. Registering a handler opts created/resumed sessions into telemetry redirection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../com/github/copilot/CopilotClient.java | 22 ++ .../copilot/GitHubTelemetryAdapter.java | 46 +++ .../copilot/rpc/CopilotClientOptions.java | 39 +++ .../copilot/rpc/CreateSessionRequest.java | 24 ++ .../rpc/GitHubTelemetryClientInfo.java | 143 +++++++++ .../copilot/rpc/GitHubTelemetryEvent.java | 147 +++++++++ .../rpc/GitHubTelemetryNotification.java | 62 ++++ .../copilot/rpc/ResumeSessionRequest.java | 24 ++ .../github/copilot/GitHubTelemetryTest.java | 283 ++++++++++++++++++ 9 files changed, 790 insertions(+) create mode 100644 java/src/main/java/com/github/copilot/GitHubTelemetryAdapter.java create mode 100644 java/src/main/java/com/github/copilot/rpc/GitHubTelemetryClientInfo.java create mode 100644 java/src/main/java/com/github/copilot/rpc/GitHubTelemetryEvent.java create mode 100644 java/src/main/java/com/github/copilot/rpc/GitHubTelemetryNotification.java create mode 100644 java/src/test/java/com/github/copilot/GitHubTelemetryTest.java diff --git a/java/src/main/java/com/github/copilot/CopilotClient.java b/java/src/main/java/com/github/copilot/CopilotClient.java index 1a49941895..1b880a87c7 100644 --- a/java/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/CopilotClient.java @@ -257,6 +257,14 @@ private Connection startCoreBody() { llmAdapter.registerHandlers(rpc); } + // Register the GitHub telemetry redirection handler when configured. + java.util.function.Consumer onGitHubTelemetry = this.options + .getOnGitHubTelemetry(); + if (onGitHubTelemetry != null) { + GitHubTelemetryAdapter telemetryAdapter = new GitHubTelemetryAdapter(onGitHubTelemetry); + telemetryAdapter.registerHandlers(rpc); + } + // Verify protocol version verifyProtocolVersion(connection); LoggingHelpers.logTiming(LOG, Level.FINE, @@ -578,6 +586,13 @@ public CompletableFuture createSession(SessionConfig config) { request.setSystemMessage(extracted.wireSystemMessage()); } + // Opt this session into GitHub telemetry redirection when a + // connection-level handler is registered (mirrors the runtime's + // hand-written capability flag, not part of the codegen'd contract). + if (options.getOnGitHubTelemetry() != null) { + request.setEnableGitHubTelemetryRedirection(true); + } + // Empty mode: validate availableTools and set toolFilterPrecedence if (options.getMode() == CopilotClientMode.EMPTY) { if (config.getAvailableTools() == null) { @@ -720,6 +735,13 @@ public CompletableFuture resumeSession(String sessionId, ResumeS request.setSystemMessage(extracted.wireSystemMessage()); } + // Opt this session into GitHub telemetry redirection when a + // connection-level handler is registered (mirrors the runtime's + // hand-written capability flag, not part of the codegen'd contract). + if (options.getOnGitHubTelemetry() != null) { + request.setEnableGitHubTelemetryRedirection(true); + } + // Empty mode: validate availableTools and set toolFilterPrecedence for resume // path if (options.getMode() == CopilotClientMode.EMPTY) { diff --git a/java/src/main/java/com/github/copilot/GitHubTelemetryAdapter.java b/java/src/main/java/com/github/copilot/GitHubTelemetryAdapter.java new file mode 100644 index 0000000000..3589eb15da --- /dev/null +++ b/java/src/main/java/com/github/copilot/GitHubTelemetryAdapter.java @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import java.util.function.Consumer; +import java.util.logging.Level; +import java.util.logging.Logger; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.rpc.GitHubTelemetryNotification; + +/** + * Bridges the runtime's {@code gitHubTelemetry.event} client-global + * notification to a consumer's {@code onGitHubTelemetry} callback. The + * notification carries per-session GitHub (hydro) telemetry the runtime + * forwards to connections that opted into telemetry redirection. + */ +final class GitHubTelemetryAdapter { + + private static final Logger LOG = Logger.getLogger(GitHubTelemetryAdapter.class.getName()); + private static final ObjectMapper MAPPER = JsonRpcClient.getObjectMapper(); + + private final Consumer callback; + + GitHubTelemetryAdapter(Consumer callback) { + this.callback = callback; + } + + void registerHandlers(JsonRpcClient rpc) { + rpc.registerMethodHandler("gitHubTelemetry.event", (rpcId, params) -> handleEvent(params)); + } + + private void handleEvent(JsonNode params) { + try { + GitHubTelemetryNotification notification = MAPPER.treeToValue(params, GitHubTelemetryNotification.class); + if (notification != null) { + callback.accept(notification); + } + } catch (Exception e) { + LOG.log(Level.SEVERE, "Error handling gitHubTelemetry.event notification", e); + } + } +} diff --git a/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java b/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java index e9f59aa646..2c2897e733 100644 --- a/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java +++ b/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java @@ -11,10 +11,12 @@ import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; +import java.util.function.Consumer; import java.util.function.Supplier; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.github.copilot.CopilotExperimental; import com.github.copilot.CopilotRequestHandler; import java.util.Optional; import java.util.OptionalInt; @@ -57,6 +59,7 @@ public class CopilotClientOptions { private CopilotClientMode mode = CopilotClientMode.COPILOT_CLI; private Supplier>> onListModels; private CopilotRequestHandler requestHandler; + private Consumer onGitHubTelemetry; private int port; private TelemetryConfig telemetry; private Integer sessionIdleTimeoutSeconds; @@ -484,6 +487,41 @@ public CopilotClientOptions setRequestHandler(CopilotRequestHandler requestHandl return this; } + /** + * Gets the connection-level GitHub telemetry redirection handler. + * + *

+ * Experimental: this option may change or be removed without notice. + * + * @return the telemetry handler, or {@code null} if not set + */ + @JsonIgnore + @CopilotExperimental + public Consumer getOnGitHubTelemetry() { + return onGitHubTelemetry; + } + + /** + * Sets a connection-level handler for GitHub telemetry redirection + * (experimental). + * + *

+ * When provided, the client opts every session it creates or resumes into + * telemetry redirection, and the runtime forwards each per-session telemetry + * event to this handler via the {@code gitHubTelemetry.event} notification. + * + * @param onGitHubTelemetry + * the telemetry handler (must not be {@code null}) + * @return this options instance for method chaining + * @throws IllegalArgumentException + * if {@code onGitHubTelemetry} is {@code null} + */ + @CopilotExperimental + public CopilotClientOptions setOnGitHubTelemetry(Consumer onGitHubTelemetry) { + this.onGitHubTelemetry = Objects.requireNonNull(onGitHubTelemetry, "onGitHubTelemetry must not be null"); + return this; + } + /** * Gets the TCP port for the CLI server. * @@ -720,6 +758,7 @@ public CopilotClientOptions clone() { copy.logLevel = this.logLevel; copy.onListModels = this.onListModels; copy.requestHandler = this.requestHandler; + copy.onGitHubTelemetry = this.onGitHubTelemetry; copy.port = this.port; copy.remote = this.remote; copy.sessionIdleTimeoutSeconds = this.sessionIdleTimeoutSeconds; diff --git a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java index 8fc966c6f1..f498c06c69 100644 --- a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -92,6 +92,9 @@ public final class CreateSessionRequest { @JsonProperty("includeSubAgentStreamingEvents") private Boolean includeSubAgentStreamingEvents; + @JsonProperty("enableGitHubTelemetryRedirection") + private Boolean enableGitHubTelemetryRedirection; + @JsonProperty("mcpServers") private Map mcpServers; @@ -778,6 +781,27 @@ public void clearIncludeSubAgentStreamingEvents() { this.includeSubAgentStreamingEvents = null; } + /** Gets the GitHub telemetry redirection flag. @return the flag */ + public Boolean getEnableGitHubTelemetryRedirection() { + return enableGitHubTelemetryRedirection; + } + + /** + * Sets the GitHub telemetry redirection flag. @param + * enableGitHubTelemetryRedirection the flag + */ + public void setEnableGitHubTelemetryRedirection(boolean enableGitHubTelemetryRedirection) { + this.enableGitHubTelemetryRedirection = enableGitHubTelemetryRedirection; + } + + /** + * Clears the enableGitHubTelemetryRedirection setting, reverting to the default + * behavior. + */ + public void clearEnableGitHubTelemetryRedirection() { + this.enableGitHubTelemetryRedirection = null; + } + /** Gets the commands wire definitions. @return the commands */ public List getCommands() { return commands == null ? null : Collections.unmodifiableList(commands); diff --git a/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryClientInfo.java b/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryClientInfo.java new file mode 100644 index 0000000000..abf1600d2c --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryClientInfo.java @@ -0,0 +1,143 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import com.github.copilot.CopilotExperimental; + +/** + * Client environment metadata describing the process that produced a telemetry + * event. + * + *

+ * Internal/experimental: this type is part of the GitHub telemetry redirection + * surface and may change or be removed without notice. + * + * @since 1.0.0 + */ +@CopilotExperimental +public class GitHubTelemetryClientInfo { + + @JsonProperty("cli_version") + private String cliVersion = ""; + + @JsonProperty("client_name") + private String clientName; + + @JsonProperty("client_type") + private String clientType; + + @JsonProperty("copilot_plan") + private String copilotPlan; + + @JsonProperty("dev_device_id") + private String devDeviceId; + + @JsonProperty("is_staff") + private Boolean isStaff; + + @JsonProperty("node_version") + private String nodeVersion = ""; + + @JsonProperty("os_arch") + private String osArch = ""; + + @JsonProperty("os_platform") + private String osPlatform = ""; + + @JsonProperty("os_version") + private String osVersion = ""; + + /** + * Gets the Copilot CLI version string. + * + * @return the CLI version + */ + public String getCliVersion() { + return cliVersion; + } + + /** + * Gets the name of the client application. + * + * @return the client name, or {@code null} if unknown + */ + public String getClientName() { + return clientName; + } + + /** + * Gets the type of client. + * + * @return the client type, or {@code null} if unknown + */ + public String getClientType() { + return clientType; + } + + /** + * Gets the Copilot subscription plan, when known. + * + * @return the Copilot plan, or {@code null} if unknown + */ + public String getCopilotPlan() { + return copilotPlan; + } + + /** + * Gets the stable machine identifier for the device. + * + * @return the device identifier, or {@code null} if unknown + */ + public String getDevDeviceId() { + return devDeviceId; + } + + /** + * Gets whether the user is a GitHub/Microsoft staff member. + * + * @return the staff flag, or {@code null} if unknown + */ + public Boolean getIsStaff() { + return isStaff; + } + + /** + * Gets the Node.js runtime version string. + * + * @return the Node.js version + */ + public String getNodeVersion() { + return nodeVersion; + } + + /** + * Gets the operating system architecture (e.g. arm64, x64). + * + * @return the OS architecture + */ + public String getOsArch() { + return osArch; + } + + /** + * Gets the operating system platform (e.g. darwin, linux, win32). + * + * @return the OS platform + */ + public String getOsPlatform() { + return osPlatform; + } + + /** + * Gets the operating system version string. + * + * @return the OS version + */ + public String getOsVersion() { + return osVersion; + } +} diff --git a/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryEvent.java b/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryEvent.java new file mode 100644 index 0000000000..f4e353e373 --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryEvent.java @@ -0,0 +1,147 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import java.util.Collections; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import com.github.copilot.CopilotExperimental; + +/** + * A single telemetry event in the runtime's native GitHub-shaped telemetry + * format, forwarded verbatim to opted-in hosts. + * + *

+ * Internal/experimental: this type is part of the GitHub telemetry redirection + * surface and may change or be removed without notice. + * + * @since 1.0.0 + */ +@CopilotExperimental +public class GitHubTelemetryEvent { + + @JsonProperty("client") + private GitHubTelemetryClientInfo client; + + @JsonProperty("copilot_tracking_id") + private String copilotTrackingId; + + @JsonProperty("created_at") + private String createdAt; + + @JsonProperty("exp_assignment_context") + private String expAssignmentContext; + + @JsonProperty("features") + private Map features; + + @JsonProperty("kind") + private String kind = ""; + + @JsonProperty("metrics") + private Map metrics = Collections.emptyMap(); + + @JsonProperty("model_call_id") + private String modelCallId; + + @JsonProperty("properties") + private Map properties = Collections.emptyMap(); + + @JsonProperty("session_id") + private String sessionId; + + /** + * Gets the client environment metadata. + * + * @return the client info, or {@code null} if absent + */ + public GitHubTelemetryClientInfo getClient() { + return client; + } + + /** + * Gets the Copilot tracking ID for user-level attribution. + * + * @return the tracking ID, or {@code null} if absent + */ + public String getCopilotTrackingId() { + return copilotTrackingId; + } + + /** + * Gets the timestamp when the event was created (ISO 8601 format). + * + * @return the creation timestamp, or {@code null} if absent + */ + public String getCreatedAt() { + return createdAt; + } + + /** + * Gets the experiment assignment context. + * + * @return the assignment context, or {@code null} if absent + */ + public String getExpAssignmentContext() { + return expAssignmentContext; + } + + /** + * Gets the feature flags enabled for this session, as a map from flag to value. + * + * @return the features map, or {@code null} if absent + */ + public Map getFeatures() { + return features; + } + + /** + * Gets the event type/kind (e.g. get_completion_with_tools_turn, + * tool_call_executed). + * + * @return the event kind + */ + public String getKind() { + return kind; + } + + /** + * Gets the numeric metrics as a map from key to value. + * + * @return the metrics map + */ + public Map getMetrics() { + return metrics; + } + + /** + * Gets the reference to the model call that produced this event. + * + * @return the model call ID, or {@code null} if absent + */ + public String getModelCallId() { + return modelCallId; + } + + /** + * Gets the string-valued properties as a map from key to value. + * + * @return the properties map + */ + public Map getProperties() { + return properties; + } + + /** + * Gets the session identifier the event belongs to. + * + * @return the session ID, or {@code null} if absent + */ + public String getSessionId() { + return sessionId; + } +} diff --git a/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryNotification.java b/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryNotification.java new file mode 100644 index 0000000000..637c84b4f6 --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryNotification.java @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import com.github.copilot.CopilotExperimental; + +/** + * Payload for a {@code gitHubTelemetry.event} notification: a single GitHub + * telemetry event the runtime forwards to a host connection that opted into + * telemetry redirection for the session. + * + *

+ * Internal/experimental: this type is part of the GitHub telemetry redirection + * surface and may change or be removed without notice. + * + * @since 1.0.0 + */ +@CopilotExperimental +public class GitHubTelemetryNotification { + + @JsonProperty("event") + private GitHubTelemetryEvent event = new GitHubTelemetryEvent(); + + @JsonProperty("restricted") + private boolean restricted; + + @JsonProperty("sessionId") + private String sessionId = ""; + + /** + * Gets the telemetry event, in the runtime's native GitHub-shaped telemetry + * format. + * + * @return the telemetry event + */ + public GitHubTelemetryEvent getEvent() { + return event; + } + + /** + * Gets whether this is a restricted telemetry event (cli.restricted_telemetry). + * Hosts must route restricted events to first-party Microsoft stores only. + * + * @return {@code true} if the event is restricted + */ + public boolean isRestricted() { + return restricted; + } + + /** + * Gets the session the telemetry event belongs to. + * + * @return the session ID + */ + public String getSessionId() { + return sessionId; + } +} diff --git a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java index 2b25875d7f..3f54cd7da3 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -135,6 +135,9 @@ public final class ResumeSessionRequest { @JsonProperty("includeSubAgentStreamingEvents") private Boolean includeSubAgentStreamingEvents; + @JsonProperty("enableGitHubTelemetryRedirection") + private Boolean enableGitHubTelemetryRedirection; + @JsonProperty("mcpServers") private Map mcpServers; @@ -663,6 +666,27 @@ public void clearIncludeSubAgentStreamingEvents() { this.includeSubAgentStreamingEvents = null; } + /** Gets the GitHub telemetry redirection flag. @return the flag */ + public Boolean getEnableGitHubTelemetryRedirection() { + return enableGitHubTelemetryRedirection; + } + + /** + * Sets the GitHub telemetry redirection flag. @param + * enableGitHubTelemetryRedirection the flag + */ + public void setEnableGitHubTelemetryRedirection(boolean enableGitHubTelemetryRedirection) { + this.enableGitHubTelemetryRedirection = enableGitHubTelemetryRedirection; + } + + /** + * Clears the enableGitHubTelemetryRedirection setting, reverting to the default + * behavior. + */ + public void clearEnableGitHubTelemetryRedirection() { + this.enableGitHubTelemetryRedirection = null; + } + /** Gets MCP servers. @return the servers map */ public Map getMcpServers() { return mcpServers == null ? null : Collections.unmodifiableMap(mcpServers); diff --git a/java/src/test/java/com/github/copilot/GitHubTelemetryTest.java b/java/src/test/java/com/github/copilot/GitHubTelemetryTest.java new file mode 100644 index 0000000000..d894ba90ce --- /dev/null +++ b/java/src/test/java/com/github/copilot/GitHubTelemetryTest.java @@ -0,0 +1,283 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.github.copilot.rpc.CopilotClientOptions; +import com.github.copilot.rpc.GitHubTelemetryNotification; +import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.ResumeSessionConfig; +import com.github.copilot.rpc.SessionConfig; + +/** + * Exercises the hand-written GitHub telemetry redirection surface: the + * {@code gitHubTelemetry.event} notification adapter, the + * {@code enableGitHubTelemetryRedirection} capability flag on the create/resume + * requests, and the {@code onGitHubTelemetry} client option. + */ +@AllowCopilotExperimental +class GitHubTelemetryTest { + + private record SocketPair(JsonRpcClient client, Socket serverSide, + ServerSocket serverSocket) implements AutoCloseable { + + @Override + public void close() throws Exception { + client.close(); + serverSide.close(); + serverSocket.close(); + } + } + + private SocketPair createSocketPair() throws Exception { + var serverSocket = new ServerSocket(0); + var clientSocket = new Socket("localhost", serverSocket.getLocalPort()); + var serverSide = serverSocket.accept(); + var client = JsonRpcClient.fromSocket(clientSocket); + return new SocketPair(client, serverSide, serverSocket); + } + + private void writeRpcMessage(OutputStream out, String json) throws IOException { + byte[] content = json.getBytes(StandardCharsets.UTF_8); + String header = "Content-Length: " + content.length + "\r\n\r\n"; + out.write(header.getBytes(StandardCharsets.UTF_8)); + out.write(content); + out.flush(); + } + + @Test + void adapterDispatchesNotificationToHandlerWithTypedPayload() throws Exception { + try (var pair = createSocketPair()) { + var received = new CompletableFuture(); + Consumer handler = received::complete; + new GitHubTelemetryAdapter(handler).registerHandlers(pair.client()); + + String notification = """ + { + "jsonrpc": "2.0", + "method": "gitHubTelemetry.event", + "params": { + "sessionId": "sess-123", + "restricted": true, + "event": { + "kind": "tool_call_executed", + "created_at": "2024-01-01T00:00:00Z", + "model_call_id": "call-9", + "properties": { "tool": "shell" }, + "metrics": { "duration_ms": 42.5 }, + "exp_assignment_context": "ctx", + "features": { "flag_a": "on" }, + "session_id": "sess-123", + "copilot_tracking_id": "track-1", + "client": { + "cli_version": "1.2.3", + "os_platform": "win32", + "os_version": "10", + "os_arch": "x64", + "node_version": "20.0.0", + "is_staff": false + } + } + } + } + """; + writeRpcMessage(pair.serverSide().getOutputStream(), notification); + + GitHubTelemetryNotification result = received.get(5, TimeUnit.SECONDS); + assertEquals("sess-123", result.getSessionId()); + assertTrue(result.isRestricted()); + + var event = result.getEvent(); + assertNotNull(event); + assertEquals("tool_call_executed", event.getKind()); + assertEquals("2024-01-01T00:00:00Z", event.getCreatedAt()); + assertEquals("call-9", event.getModelCallId()); + assertEquals("shell", event.getProperties().get("tool")); + assertEquals(42.5, event.getMetrics().get("duration_ms")); + assertEquals("ctx", event.getExpAssignmentContext()); + assertEquals("on", event.getFeatures().get("flag_a")); + assertEquals("sess-123", event.getSessionId()); + assertEquals("track-1", event.getCopilotTrackingId()); + + var client = event.getClient(); + assertNotNull(client); + assertEquals("1.2.3", client.getCliVersion()); + assertEquals("win32", client.getOsPlatform()); + assertEquals("x64", client.getOsArch()); + assertEquals("20.0.0", client.getNodeVersion()); + assertEquals(Boolean.FALSE, client.getIsStaff()); + } + } + + @Test + void clientOptsSessionsIntoRedirectionAndReceivesEvents() throws Exception { + var received = new CompletableFuture(); + Consumer handler = received::complete; + + try (var server = new FakeRuntimeServer(); + var client = new CopilotClient( + new CopilotClientOptions().setCliUrl(server.url()).setOnGitHubTelemetry(handler))) { + + client.start().get(15, TimeUnit.SECONDS); + + // Creating a session must opt it into telemetry redirection. + client.createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(15, + TimeUnit.SECONDS); + JsonNode createParams = server.awaitCreate(); + assertTrue(createParams.path("enableGitHubTelemetryRedirection").asBoolean(), + "create request should carry enableGitHubTelemetryRedirection=true"); + + // The adapter registered on connect should forward server-pushed events. + server.sendTelemetry(Map.of("sessionId", "sess-xyz", "restricted", false, "event", + Map.of("kind", "session_started", "session_id", "sess-xyz"))); + GitHubTelemetryNotification event = received.get(5, TimeUnit.SECONDS); + assertEquals("sess-xyz", event.getSessionId()); + assertFalse(event.isRestricted()); + assertEquals("session_started", event.getEvent().getKind()); + + // Resuming a session must opt it in as well. + client.resumeSession("resume-1", + new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) + .get(15, TimeUnit.SECONDS); + JsonNode resumeParams = server.awaitResume(); + assertTrue(resumeParams.path("enableGitHubTelemetryRedirection").asBoolean(), + "resume request should carry enableGitHubTelemetryRedirection=true"); + } + } + + @Test + void clientOmitsRedirectionWhenNoHandler() throws Exception { + try (var server = new FakeRuntimeServer(); + var client = new CopilotClient(new CopilotClientOptions().setCliUrl(server.url()))) { + + client.start().get(15, TimeUnit.SECONDS); + + client.createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(15, + TimeUnit.SECONDS); + JsonNode createParams = server.awaitCreate(); + assertFalse(createParams.has("enableGitHubTelemetryRedirection"), + "create request should omit the flag when no handler is registered"); + + client.resumeSession("resume-1", + new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) + .get(15, TimeUnit.SECONDS); + JsonNode resumeParams = server.awaitResume(); + assertFalse(resumeParams.has("enableGitHubTelemetryRedirection"), + "resume request should omit the flag when no handler is registered"); + } + } + + @Test + void optionsRetainAndCloneTelemetryHandler() { + Consumer handler = n -> { + }; + var options = new CopilotClientOptions().setOnGitHubTelemetry(handler); + assertSame(handler, options.getOnGitHubTelemetry()); + + var copy = options.clone(); + assertSame(handler, copy.getOnGitHubTelemetry()); + } + + /** + * A minimal in-process JSON-RPC runtime that answers the connect/create/resume + * handshake so a real {@link CopilotClient} can be driven over a socket, and + * can push {@code gitHubTelemetry.event} notifications back to the client. + */ + private static final class FakeRuntimeServer implements AutoCloseable { + + private final ServerSocket serverSocket; + private final Thread acceptThread; + private final CompletableFuture ready = new CompletableFuture<>(); + private final CompletableFuture createParams = new CompletableFuture<>(); + private final CompletableFuture resumeParams = new CompletableFuture<>(); + + FakeRuntimeServer() throws IOException { + serverSocket = new ServerSocket(0); + acceptThread = new Thread(this::acceptLoop, "fake-runtime-accept"); + acceptThread.setDaemon(true); + acceptThread.start(); + } + + String url() { + return "127.0.0.1:" + serverSocket.getLocalPort(); + } + + JsonNode awaitCreate() throws Exception { + return createParams.get(15, TimeUnit.SECONDS); + } + + JsonNode awaitResume() throws Exception { + return resumeParams.get(15, TimeUnit.SECONDS); + } + + void sendTelemetry(Object params) throws Exception { + ready.get(15, TimeUnit.SECONDS).notify("gitHubTelemetry.event", params); + } + + 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("session.create", (id, params) -> { + createParams.complete(params); + respond(server, id, Map.of("sessionId", params.path("sessionId").asText("created"), "workspacePath", + "/workspace")); + }); + server.registerMethodHandler("session.resume", (id, params) -> { + resumeParams.complete(params); + respond(server, id, Map.of("sessionId", params.path("sessionId").asText("resume-1"), + "workspacePath", "/workspace")); + }); + server.registerMethodHandler("session.destroy", (id, params) -> respond(server, id, Map.of())); + server.registerMethodHandler("runtime.shutdown", (id, params) -> respond(server, id, Map.of())); + ready.complete(server); + } catch (IOException e) { + ready.completeExceptionally(e); + createParams.completeExceptionally(e); + resumeParams.completeExceptionally(e); + } + } + + private static void respond(JsonRpcClient server, String id, Object result) { + if (id == null) { + return; + } + try { + server.sendResponse(id, result); + } catch (IOException e) { + // Connection torn down (e.g. client closing); ignore. + } + } + + @Override + public void close() throws Exception { + JsonRpcClient server = ready.getNow(null); + if (server != null) { + server.close(); + } + serverSocket.close(); + } + } +} From d1fb55a2f48eea74e5290576c5c8bce58efd89f9 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 29 Jun 2026 15:19:59 -0700 Subject: [PATCH 08/26] Fix telemetry codegen after schema bump Keep experimental GitHub telemetry schema additions available to codegen until they ship in the packaged Copilot schema, and apply formatter output required by CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 6 +- nodejs/test/client.test.ts | 5 +- python/copilot/_jsonrpc.py | 4 +- python/copilot/client.py | 4 +- python/test_client.py | 5 +- rust/src/router.rs | 18 +- scripts/codegen/rust.ts | 10 +- .../api-additions.schema.json | 166 ++++++++++++++++++ scripts/codegen/utils.ts | 57 +++++- 9 files changed, 244 insertions(+), 31 deletions(-) create mode 100644 scripts/codegen/schema-overrides/api-additions.schema.json diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 761211423f..72f6f574ea 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -33,7 +33,11 @@ import { registerClientGlobalApiHandlers, registerClientSessionApiHandlers, } from "./generated/rpc.js"; -import type { GitHubTelemetryNotification, OpenCanvasInstance, SessionUpdateOptionsParams } from "./generated/rpc.js"; +import type { + GitHubTelemetryNotification, + OpenCanvasInstance, + SessionUpdateOptionsParams, +} from "./generated/rpc.js"; import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; import { CopilotSession } from "./session.js"; import { createSessionFsAdapter, type SessionFsProvider } from "./sessionFsProvider.js"; diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index a7c4aea69a..da241a65e7 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -236,9 +236,8 @@ describe("CopilotClient", () => { }); it("dispatches a real gitHubTelemetry.event wire notification to the handler", async () => { - const { createMessageConnection, StreamMessageReader, StreamMessageWriter } = await import( - "vscode-jsonrpc/node.js" - ); + const { createMessageConnection, StreamMessageReader, StreamMessageWriter } = + await import("vscode-jsonrpc/node.js"); const { registerClientGlobalApiHandlers } = await import("../src/generated/rpc.js"); const clientToServer = new PassThrough(); diff --git a/python/copilot/_jsonrpc.py b/python/copilot/_jsonrpc.py index 5e799149e0..58f75b6ed1 100644 --- a/python/copilot/_jsonrpc.py +++ b/python/copilot/_jsonrpc.py @@ -233,9 +233,7 @@ def set_notification_handler(self, handler: Callable[[str, dict], None]): """Set the handler for incoming notifications from the server.""" self.notification_handler = handler - def set_notification_method_handler( - self, method: str, handler: Callable[[dict], Any] | None - ): + def set_notification_method_handler(self, method: str, handler: Callable[[dict], Any] | None): """Register a handler for a specific server-to-client notification method. Notifications carry no ``id`` and expect no response, so they are diff --git a/python/copilot/client.py b/python/copilot/client.py index 0560741d18..d8ea14f5fb 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -395,9 +395,7 @@ class _GitHubTelemetryAdapter: ``GitHubTelemetryHandler`` protocol. """ - def __init__( - self, callback: Callable[[GitHubTelemetryNotification], None] - ) -> None: + def __init__(self, callback: Callable[[GitHubTelemetryNotification], None]) -> None: self._callback = callback async def event(self, params: GitHubTelemetryNotification) -> None: diff --git a/python/test_client.py b/python/test_client.py index 4241a77aa0..251450d50b 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -2094,10 +2094,7 @@ async def test_event_handler_not_registered_without_option(self): await client.start() try: - assert ( - "gitHubTelemetry.event" - not in client._client.notification_method_handlers - ) + assert "gitHubTelemetry.event" not in client._client.notification_method_handlers assert "gitHubTelemetry.event" not in client._client.request_handlers finally: await client.force_stop() diff --git a/rust/src/router.rs b/rust/src/router.rs index cbd900d2d6..adc1923824 100644 --- a/rust/src/router.rs +++ b/rust/src/router.rs @@ -114,17 +114,17 @@ impl SessionRouter { >(params.clone()) { Ok(telemetry) => { - if std::panic::catch_unwind(std::panic::AssertUnwindSafe( - || callback(telemetry), - )) - .is_err() - { - warn!( - "gitHubTelemetry.event callback panicked; \ + if std::panic::catch_unwind(std::panic::AssertUnwindSafe( + || callback(telemetry), + )) + .is_err() + { + warn!( + "gitHubTelemetry.event callback panicked; \ continuing notification routing" - ); + ); + } } - } Err(e) => { warn!( error = %e, diff --git a/scripts/codegen/rust.ts b/scripts/codegen/rust.ts index 3a3ce39a2f..3d6208204e 100644 --- a/scripts/codegen/rust.ts +++ b/scripts/codegen/rust.ts @@ -40,7 +40,7 @@ import { isSchemaExperimental, isSchemaInternal, isVoidSchema, - normalizeSchemaBrandCasing, + loadSchemaJson, fixBrandCasing, parseExternalSchemaRef, postProcessSchema, @@ -2155,12 +2155,8 @@ async function generate(): Promise { schemaArgs.sessionEventsSchemaPath || (await getSessionEventsSchemaPath()); const apiSchemaPath = await getApiSchemaPath(schemaArgs.apiSchemaPath); - const sessionEventsRaw = normalizeSchemaBrandCasing( - JSON.parse(await fs.readFile(sessionEventsSchemaPath, "utf-8")), - ); - const apiRaw = normalizeSchemaBrandCasing( - JSON.parse(await fs.readFile(apiSchemaPath, "utf-8")) as ApiSchema, - ); + const sessionEventsRaw = await loadSchemaJson(sessionEventsSchemaPath); + const apiRaw = await loadSchemaJson(apiSchemaPath); const sessionEventsSchema = propagateInternalVisibility( postProcessSchema( diff --git a/scripts/codegen/schema-overrides/api-additions.schema.json b/scripts/codegen/schema-overrides/api-additions.schema.json new file mode 100644 index 0000000000..b5f2fd70d6 --- /dev/null +++ b/scripts/codegen/schema-overrides/api-additions.schema.json @@ -0,0 +1,166 @@ +{ + "clientGlobal": { + "gitHubTelemetry": { + "event": { + "rpcMethod": "gitHubTelemetry.event", + "description": "Forwards a single GitHub telemetry event to a host connection that opted into telemetry redirection for the session.", + "params": { + "$ref": "#/definitions/GitHubTelemetryNotification", + "description": "Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry redirection for the session." + }, + "result": { + "type": "null" + }, + "notification": true, + "stability": "experimental" + } + } + }, + "definitions": { + "GitHubTelemetryClientInfo": { + "type": "object", + "properties": { + "cli_version": { + "type": "string", + "description": "Copilot CLI version string." + }, + "os_platform": { + "type": "string", + "description": "Operating system platform (e.g. darwin, linux, win32)." + }, + "os_version": { + "type": "string", + "description": "Operating system version string." + }, + "os_arch": { + "type": "string", + "description": "Operating system architecture (e.g. arm64, x64)." + }, + "node_version": { + "type": "string", + "description": "Node.js runtime version string." + }, + "copilot_plan": { + "type": "string", + "description": "Copilot subscription plan, when known." + }, + "client_type": { + "type": "string", + "description": "Type of client." + }, + "client_name": { + "type": "string", + "description": "Name of the client application." + }, + "is_staff": { + "type": "boolean", + "description": "Whether the user is a GitHub/Microsoft staff member." + }, + "dev_device_id": { + "type": "string", + "description": "Stable machine identifier for the device." + } + }, + "required": [ + "cli_version", + "os_platform", + "os_version", + "os_arch", + "node_version" + ], + "additionalProperties": false, + "description": "Client environment metadata describing the process that produced a telemetry event.", + "title": "GitHubTelemetryClientInfo", + "stability": "experimental" + }, + "GitHubTelemetryEvent": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "description": "Event type/kind (e.g. get_completion_with_tools_turn, tool_call_executed)." + }, + "created_at": { + "type": "string", + "description": "Timestamp when the event was created (ISO 8601 format)." + }, + "model_call_id": { + "type": "string", + "description": "Reference to the model call that produced this event." + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "String-valued properties as a map from key to value." + }, + "metrics": { + "type": "object", + "additionalProperties": { + "type": "number" + }, + "description": "Numeric metrics as a map from key to value." + }, + "exp_assignment_context": { + "type": "string", + "description": "Experiment assignment context." + }, + "features": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Feature flags enabled for this session, as a map from flag to value." + }, + "session_id": { + "type": "string", + "description": "Session identifier the event belongs to." + }, + "copilot_tracking_id": { + "type": "string", + "description": "Copilot tracking ID for user-level attribution." + }, + "client": { + "$ref": "#/definitions/GitHubTelemetryClientInfo", + "description": "Client environment metadata." + } + }, + "required": [ + "kind", + "properties", + "metrics" + ], + "additionalProperties": false, + "description": "A single telemetry event in the runtime's native GitHub-shaped telemetry format, forwarded verbatim to opted-in hosts. The `restricted` flag on the enclosing GitHubTelemetryNotification distinguishes standard from restricted events; the payload shape is identical for both.", + "title": "GitHubTelemetryEvent", + "stability": "experimental" + }, + "GitHubTelemetryNotification": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Session the telemetry event belongs to." + }, + "restricted": { + "type": "boolean", + "description": "Whether this is a restricted telemetry event (cli.restricted_telemetry). Hosts must route restricted events to first-party Microsoft stores only." + }, + "event": { + "$ref": "#/definitions/GitHubTelemetryEvent", + "description": "The telemetry event, in the runtime's native GitHub-shaped telemetry format." + } + }, + "required": [ + "sessionId", + "restricted", + "event" + ], + "additionalProperties": false, + "description": "Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry redirection for the session.", + "title": "GitHubTelemetryNotification", + "stability": "experimental" + } + } +} diff --git a/scripts/codegen/utils.ts b/scripts/codegen/utils.ts index 9ab335b05f..37f191edb3 100644 --- a/scripts/codegen/utils.ts +++ b/scripts/codegen/utils.ts @@ -46,6 +46,7 @@ export type SchemaWithSharedDefinitions = T // ── Schema paths ──────────────────────────────────────────────────────────── const SDK_NODE_MODULES = path.join(REPO_ROOT, "nodejs/node_modules"); +const API_SCHEMA_ADDITIONS_PATH = path.join(__dirname, "schema-overrides/api-additions.schema.json"); /** * Resolve a JSON schema shipped by the `@github/copilot` CLI package. @@ -185,7 +186,61 @@ function renameBrandDefinitionKeys(defs: Record): void { /** Load a JSON schema file and normalize GitHub brand casing in titles, refs, and definition keys. */ export async function loadSchemaJson(filePath: string): Promise { const parsed = JSON.parse(await fs.readFile(filePath, "utf-8")) as T; - return normalizeSchemaBrandCasing(parsed); + const normalized = normalizeSchemaBrandCasing(parsed); + return applyApiSchemaAdditions(normalized, filePath); +} + +async function applyApiSchemaAdditions(schema: T, filePath: string): Promise { + if (path.basename(filePath) !== "api.schema.json") return schema; + + let additions: ApiSchema; + try { + additions = normalizeSchemaBrandCasing( + JSON.parse(await fs.readFile(API_SCHEMA_ADDITIONS_PATH, "utf-8")) as ApiSchema + ); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") return schema; + throw err; + } + + const apiSchema = schema as ApiSchema; + mergeSchemaAdditions(apiSchema, "definitions", additions.definitions); + mergeSchemaAdditions(apiSchema, "$defs", additions.$defs); + mergeSchemaAdditions(apiSchema, "server", additions.server); + mergeSchemaAdditions(apiSchema, "session", additions.session); + mergeSchemaAdditions(apiSchema, "clientSession", additions.clientSession); + mergeSchemaAdditions(apiSchema, "clientGlobal", additions.clientGlobal); + return schema; +} + +function mergeSchemaAdditions( + schema: ApiSchema, + key: keyof ApiSchema, + additions: Record | undefined +): void { + if (!additions) return; + mergeMissingEntries((schema[key] ??= {}) as Record, additions); +} + +function mergeMissingEntries(target: Record, additions: Record | undefined): void { + if (!additions) return; + + for (const [key, value] of Object.entries(additions)) { + if (!(key in target)) { + target[key] = value; + continue; + } + + const existing = target[key]; + if (isPlainObject(existing) && isPlainObject(value)) { + mergeMissingEntries(existing, value); + } + } +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } // ── Schema processing ─────────────────────────────────────────────────────── From c810cbd6c94434c019d2a4729bcf821188e88b41 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 30 Jun 2026 12:24:24 -0700 Subject: [PATCH 09/26] Rename telemetry redirection to forwarding across SDKs The runtime renamed the per-session opt-in capability from "redirection" to "forwarding" (wire field enableGitHubTelemetryForwarding) and updated the GitHubTelemetry schema description strings. This mirrors that rename across every SDK plus the codegen schema overlay so the hand-written opt-in flag matches the runtime contract. Also folds in two PR review fixes: - dotnet: document the expected-teardown catch in GitHubTelemetryTests DisposeAsync (was an empty catch). - python: re-export GitHubTelemetryNotification/Event/ClientInfo from copilot/__init__.py for parity with the nodejs public surface. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 8 ++-- dotnet/src/Generated/Rpc.cs | 6 +-- dotnet/src/Types.cs | 2 +- dotnet/test/Unit/GitHubTelemetryTests.cs | 14 +++--- go/client.go | 4 +- go/client_test.go | 48 +++++++++---------- go/rpc/zrpc.go | 6 +-- go/types.go | 6 +-- .../com/github/copilot/CopilotClient.java | 10 ++-- .../copilot/GitHubTelemetryAdapter.java | 2 +- .../copilot/rpc/CopilotClientOptions.java | 6 +-- .../copilot/rpc/CreateSessionRequest.java | 24 +++++----- .../rpc/GitHubTelemetryClientInfo.java | 2 +- .../copilot/rpc/GitHubTelemetryEvent.java | 2 +- .../rpc/GitHubTelemetryNotification.java | 4 +- .../copilot/rpc/ResumeSessionRequest.java | 24 +++++----- .../github/copilot/GitHubTelemetryTest.java | 22 ++++----- nodejs/src/client.ts | 4 +- nodejs/src/generated/rpc.ts | 6 +-- nodejs/src/types.ts | 2 +- nodejs/test/client.test.ts | 10 ++-- python/copilot/__init__.py | 6 +++ python/copilot/client.py | 6 +-- python/copilot/generated/rpc.py | 4 +- python/test_client.py | 16 +++---- rust/src/generated/api_types.rs | 2 +- rust/src/github_telemetry.rs | 8 ++-- rust/src/lib.rs | 16 +++---- rust/src/session.rs | 4 +- rust/src/types.rs | 4 +- rust/src/wire.rs | 8 ++-- rust/tests/session_test.rs | 18 +++---- .../api-additions.schema.json | 6 +-- 33 files changed, 159 insertions(+), 151 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index aaff9005fa..e6c6baad2e 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1035,7 +1035,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance Models: config.Models, ToolFilterPrecedence: toolFilter.ToolFilterPrecedence, ExpAssignments: config.ExpAssignments, - EnableGitHubTelemetryRedirection: _options.OnGitHubTelemetry != null ? true : null); + EnableGitHubTelemetryForwarding: _options.OnGitHubTelemetry != null ? true : null); var rpcTimestamp = Stopwatch.GetTimestamp(); @@ -1238,7 +1238,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes Models: config.Models, ToolFilterPrecedence: toolFilter.ToolFilterPrecedence, ExpAssignments: config.ExpAssignments, - EnableGitHubTelemetryRedirection: _options.OnGitHubTelemetry != null ? true : null); + EnableGitHubTelemetryForwarding: _options.OnGitHubTelemetry != null ? true : null); var rpcTimestamp = Stopwatch.GetTimestamp(); var response = await InvokeRpcAsync( @@ -2483,7 +2483,7 @@ internal record CreateSessionRequest( IList? Models = null, OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null, [property: JsonPropertyName("expAssignments")] JsonElement? ExpAssignments = null, - bool? EnableGitHubTelemetryRedirection = null); + bool? EnableGitHubTelemetryForwarding = null); #pragma warning restore GHCP001 internal record ToolDefinition( @@ -2580,7 +2580,7 @@ internal record ResumeSessionRequest( IList? Models = null, OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null, [property: JsonPropertyName("expAssignments")] JsonElement? ExpAssignments = null, - bool? EnableGitHubTelemetryRedirection = null); + bool? EnableGitHubTelemetryForwarding = null); #pragma warning restore GHCP001 internal record ResumeSessionResponse( diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index 47f62dbf7e..6df2c0ba95 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -11546,7 +11546,7 @@ public sealed class GitHubTelemetryEvent public string? SessionId { get; set; } } -///

Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry redirection for the session. +/// Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry forwarding for the session. [Experimental(Diagnostics.Experimental)] public sealed class GitHubTelemetryNotification { @@ -21768,8 +21768,8 @@ public interface ILlmInferenceHandler [Experimental(Diagnostics.Experimental)] public interface IGitHubTelemetryHandler { - /// Forwards a single GitHub telemetry event to a host connection that opted into telemetry redirection for the session. - /// Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry redirection for the session. + /// Forwards a single GitHub telemetry event to a host connection that opted into telemetry forwarding for the session. + /// Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry forwarding for the session. /// The to monitor for cancellation requests. The default is . Task EventAsync(GitHubTelemetryNotification request, CancellationToken cancellationToken = default); } diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index aa69f2a069..494837f544 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -381,7 +381,7 @@ private CopilotClientOptions(CopilotClientOptions? other) /// /// Experimental. Receives GitHub telemetry events the runtime forwards to this - /// connection; setting a handler opts created/resumed sessions into redirection. + /// connection; setting a handler opts created/resumed sessions into forwarding. /// [Experimental(Diagnostics.Experimental)] [EditorBrowsable(EditorBrowsableState.Never)] diff --git a/dotnet/test/Unit/GitHubTelemetryTests.cs b/dotnet/test/Unit/GitHubTelemetryTests.cs index 465791f649..dbe008ea49 100644 --- a/dotnet/test/Unit/GitHubTelemetryTests.cs +++ b/dotnet/test/Unit/GitHubTelemetryTests.cs @@ -13,12 +13,12 @@ namespace GitHub.Copilot.Test.Unit; -#pragma warning disable GHCP001 // GitHub telemetry redirection is experimental. +#pragma warning disable GHCP001 // GitHub telemetry forwarding is experimental. public sealed class GitHubTelemetryTests { [Fact] - public async Task CreateSession_Opts_Into_Redirection_When_Handler_Provided() + public async Task CreateSession_Opts_Into_Forwarding_When_Handler_Provided() { await using var server = await FakeTelemetryServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions @@ -31,12 +31,12 @@ public async Task CreateSession_Opts_Into_Redirection_When_Handler_Provided() await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); var createParams = server.LastCreateParams ?? throw new InvalidOperationException("session.create was not captured."); - Assert.True(createParams.TryGetProperty("enableGitHubTelemetryRedirection", out var flag)); + Assert.True(createParams.TryGetProperty("enableGitHubTelemetryForwarding", out var flag)); Assert.True(flag.GetBoolean()); } [Fact] - public async Task ResumeSession_Opts_Into_Redirection_When_Handler_Provided() + public async Task ResumeSession_Opts_Into_Forwarding_When_Handler_Provided() { await using var server = await FakeTelemetryServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions @@ -49,7 +49,7 @@ public async Task ResumeSession_Opts_Into_Redirection_When_Handler_Provided() await client.ResumeSessionAsync("session-1", new ResumeSessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); var resumeParams = server.LastResumeParams ?? throw new InvalidOperationException("session.resume was not captured."); - Assert.True(resumeParams.TryGetProperty("enableGitHubTelemetryRedirection", out var flag)); + Assert.True(resumeParams.TryGetProperty("enableGitHubTelemetryForwarding", out var flag)); Assert.True(flag.GetBoolean()); } @@ -66,7 +66,7 @@ public async Task CreateSession_Does_Not_Opt_In_Without_Handler() await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); var createParams = server.LastCreateParams ?? throw new InvalidOperationException("session.create was not captured."); - var optedIn = createParams.TryGetProperty("enableGitHubTelemetryRedirection", out var flag) + var optedIn = createParams.TryGetProperty("enableGitHubTelemetryForwarding", out var flag) && flag.ValueKind == JsonValueKind.True; Assert.False(optedIn); } @@ -212,6 +212,8 @@ public async ValueTask DisposeAsync() } catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or IOException or SocketException) { + // Expected during teardown: the listener/socket is torn down while the + // server loop is still awaiting I/O. Nothing to clean up beyond this. } _cts.Dispose(); diff --git a/go/client.go b/go/client.go index 891cb11e73..3778ac471b 100644 --- a/go/client.go +++ b/go/client.go @@ -758,7 +758,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.IncludeSubAgentStreamingEvents = Bool(true) } if c.options.OnGitHubTelemetry != nil { - req.EnableGitHubTelemetryRedirection = Bool(true) + req.EnableGitHubTelemetryForwarding = Bool(true) } if config.OnUserInputRequest != nil { req.RequestUserInput = Bool(true) @@ -1027,7 +1027,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.IncludeSubAgentStreamingEvents = Bool(true) } if c.options.OnGitHubTelemetry != nil { - req.EnableGitHubTelemetryRedirection = Bool(true) + req.EnableGitHubTelemetryForwarding = Bool(true) } if config.OnUserInputRequest != nil { req.RequestUserInput = Bool(true) diff --git a/go/client_test.go b/go/client_test.go index f39f99ece6..fb875c456e 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -1975,10 +1975,10 @@ func TestResumeSessionRequest_IncludeSubAgentStreamingEvents(t *testing.T) { }) } -func TestCreateSessionRequest_EnableGitHubTelemetryRedirection(t *testing.T) { +func TestCreateSessionRequest_EnableGitHubTelemetryForwarding(t *testing.T) { t.Run("forwards explicit true", func(t *testing.T) { req := createSessionRequest{ - EnableGitHubTelemetryRedirection: Bool(true), + EnableGitHubTelemetryForwarding: Bool(true), } data, err := json.Marshal(req) if err != nil { @@ -1988,8 +1988,8 @@ func TestCreateSessionRequest_EnableGitHubTelemetryRedirection(t *testing.T) { if err := json.Unmarshal(data, &m); err != nil { t.Fatalf("Failed to unmarshal: %v", err) } - if m["enableGitHubTelemetryRedirection"] != true { - t.Errorf("Expected enableGitHubTelemetryRedirection to be true, got %v", m["enableGitHubTelemetryRedirection"]) + if m["enableGitHubTelemetryForwarding"] != true { + t.Errorf("Expected enableGitHubTelemetryForwarding to be true, got %v", m["enableGitHubTelemetryForwarding"]) } }) @@ -1998,17 +1998,17 @@ func TestCreateSessionRequest_EnableGitHubTelemetryRedirection(t *testing.T) { data, _ := json.Marshal(req) var m map[string]any json.Unmarshal(data, &m) - if _, ok := m["enableGitHubTelemetryRedirection"]; ok { - t.Error("Expected enableGitHubTelemetryRedirection to be omitted when not set") + if _, ok := m["enableGitHubTelemetryForwarding"]; ok { + t.Error("Expected enableGitHubTelemetryForwarding to be omitted when not set") } }) } -func TestResumeSessionRequest_EnableGitHubTelemetryRedirection(t *testing.T) { +func TestResumeSessionRequest_EnableGitHubTelemetryForwarding(t *testing.T) { t.Run("forwards explicit true", func(t *testing.T) { req := resumeSessionRequest{ SessionID: "s1", - EnableGitHubTelemetryRedirection: Bool(true), + EnableGitHubTelemetryForwarding: Bool(true), } data, err := json.Marshal(req) if err != nil { @@ -2018,8 +2018,8 @@ func TestResumeSessionRequest_EnableGitHubTelemetryRedirection(t *testing.T) { if err := json.Unmarshal(data, &m); err != nil { t.Fatalf("Failed to unmarshal: %v", err) } - if m["enableGitHubTelemetryRedirection"] != true { - t.Errorf("Expected enableGitHubTelemetryRedirection to be true, got %v", m["enableGitHubTelemetryRedirection"]) + if m["enableGitHubTelemetryForwarding"] != true { + t.Errorf("Expected enableGitHubTelemetryForwarding to be true, got %v", m["enableGitHubTelemetryForwarding"]) } }) @@ -2028,13 +2028,13 @@ func TestResumeSessionRequest_EnableGitHubTelemetryRedirection(t *testing.T) { data, _ := json.Marshal(req) var m map[string]any json.Unmarshal(data, &m) - if _, ok := m["enableGitHubTelemetryRedirection"]; ok { - t.Error("Expected enableGitHubTelemetryRedirection to be omitted when not set") + if _, ok := m["enableGitHubTelemetryForwarding"]; ok { + t.Error("Expected enableGitHubTelemetryForwarding to be omitted when not set") } }) } -func TestClient_ForwardsGitHubTelemetryRedirectionToSessionRequests(t *testing.T) { +func TestClient_ForwardsGitHubTelemetryForwardingToSessionRequests(t *testing.T) { rpcClient, server, _ := newRuntimeShutdownRpcPair(t) t.Cleanup(server.Stop) client := &Client{ @@ -2054,7 +2054,7 @@ func TestClient_ForwardsGitHubTelemetryRedirectionToSessionRequests(t *testing.T if _, err := client.CreateSession(t.Context(), &SessionConfig{}); err != nil { t.Fatalf("CreateSession failed: %v", err) } - assertRedirectionFlagTrue(t, <-createParams) + assertForwardingFlagTrue(t, <-createParams) resumeParams := make(chan json.RawMessage, 1) server.SetRequestHandler("session.resume", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { @@ -2065,21 +2065,21 @@ func TestClient_ForwardsGitHubTelemetryRedirectionToSessionRequests(t *testing.T if _, err := client.ResumeSessionWithOptions(t.Context(), "resumed", &ResumeSessionConfig{}); err != nil { t.Fatalf("ResumeSessionWithOptions failed: %v", err) } - assertRedirectionFlagTrue(t, <-resumeParams) + assertForwardingFlagTrue(t, <-resumeParams) } -func assertRedirectionFlagTrue(t *testing.T, params json.RawMessage) { +func assertForwardingFlagTrue(t *testing.T, params json.RawMessage) { t.Helper() var decoded map[string]any if err := json.Unmarshal(params, &decoded); err != nil { t.Fatalf("failed to unmarshal request params: %v", err) } - if decoded["enableGitHubTelemetryRedirection"] != true { - t.Fatalf("expected enableGitHubTelemetryRedirection=true, got %v", decoded["enableGitHubTelemetryRedirection"]) + if decoded["enableGitHubTelemetryForwarding"] != true { + t.Fatalf("expected enableGitHubTelemetryForwarding=true, got %v", decoded["enableGitHubTelemetryForwarding"]) } } -func TestClient_OmitsGitHubTelemetryRedirectionWhenNoHandler(t *testing.T) { +func TestClient_OmitsGitHubTelemetryForwardingWhenNoHandler(t *testing.T) { rpcClient, server, _ := newRuntimeShutdownRpcPair(t) t.Cleanup(server.Stop) client := &Client{ @@ -2099,7 +2099,7 @@ func TestClient_OmitsGitHubTelemetryRedirectionWhenNoHandler(t *testing.T) { if _, err := client.CreateSession(t.Context(), &SessionConfig{}); err != nil { t.Fatalf("CreateSession failed: %v", err) } - assertRedirectionFlagAbsent(t, <-createParams) + assertForwardingFlagAbsent(t, <-createParams) resumeParams := make(chan json.RawMessage, 1) server.SetRequestHandler("session.resume", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { @@ -2110,17 +2110,17 @@ func TestClient_OmitsGitHubTelemetryRedirectionWhenNoHandler(t *testing.T) { if _, err := client.ResumeSessionWithOptions(t.Context(), "resumed", &ResumeSessionConfig{}); err != nil { t.Fatalf("ResumeSessionWithOptions failed: %v", err) } - assertRedirectionFlagAbsent(t, <-resumeParams) + assertForwardingFlagAbsent(t, <-resumeParams) } -func assertRedirectionFlagAbsent(t *testing.T, params json.RawMessage) { +func assertForwardingFlagAbsent(t *testing.T, params json.RawMessage) { t.Helper() var decoded map[string]any if err := json.Unmarshal(params, &decoded); err != nil { t.Fatalf("failed to unmarshal request params: %v", err) } - if _, ok := decoded["enableGitHubTelemetryRedirection"]; ok { - t.Fatalf("expected enableGitHubTelemetryRedirection to be omitted, got %v", decoded["enableGitHubTelemetryRedirection"]) + if _, ok := decoded["enableGitHubTelemetryForwarding"]; ok { + t.Fatalf("expected enableGitHubTelemetryForwarding to be omitted, got %v", decoded["enableGitHubTelemetryForwarding"]) } } diff --git a/go/rpc/zrpc.go b/go/rpc/zrpc.go index 06f0b41d32..3985eb86dc 100644 --- a/go/rpc/zrpc.go +++ b/go/rpc/zrpc.go @@ -2038,7 +2038,7 @@ type GitHubTelemetryEvent struct { } // Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the -// runtime forwards to a host connection that opted into telemetry redirection for the +// runtime forwards to a host connection that opted into telemetry forwarding for the // session. // Experimental: GitHubTelemetryNotification is part of an experimental API and may change // or be removed. @@ -18499,12 +18499,12 @@ func RegisterClientSessionAPIHandlers(client *jsonrpc2.Client, getHandlers func( // removed. type GitHubTelemetryHandler interface { // Event forwards a single GitHub telemetry event to a host connection that opted into - // telemetry redirection for the session. + // telemetry forwarding for the session. // // RPC method: gitHubTelemetry.event. // // Parameters: Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry - // event the runtime forwards to a host connection that opted into telemetry redirection for + // event the runtime forwards to a host connection that opted into telemetry forwarding for // the session. Event(request *GitHubTelemetryNotification) error } diff --git a/go/types.go b/go/types.go index 2e503af1ee..b509b2f596 100644 --- a/go/types.go +++ b/go/types.go @@ -125,7 +125,7 @@ type ClientOptions struct { // OnGitHubTelemetry registers a connection-level callback (experimental) // that receives GitHub telemetry events the runtime forwards for sessions // opened by this client. When non-nil, every session created or resumed by - // this client opts into telemetry redirection (enableGitHubTelemetryRedirection). + // this client opts into telemetry forwarding (enableGitHubTelemetryForwarding). OnGitHubTelemetry func(notification *rpc.GitHubTelemetryNotification) // Telemetry configures OpenTelemetry integration for the runtime. // When non-nil, COPILOT_OTEL_ENABLED=true is set and any populated @@ -1982,7 +1982,7 @@ type createSessionRequest struct { WorkingDirectory string `json:"workingDirectory,omitempty"` Streaming *bool `json:"streaming,omitempty"` IncludeSubAgentStreamingEvents *bool `json:"includeSubAgentStreamingEvents,omitempty"` - EnableGitHubTelemetryRedirection *bool `json:"enableGitHubTelemetryRedirection,omitempty"` + EnableGitHubTelemetryForwarding *bool `json:"enableGitHubTelemetryForwarding,omitempty"` MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` MCPOAuthTokenStorage string `json:"mcpOAuthTokenStorage,omitempty"` EnvValueMode string `json:"envValueMode,omitempty"` @@ -2078,7 +2078,7 @@ type resumeSessionRequest struct { ContinuePendingWork *bool `json:"continuePendingWork,omitempty"` Streaming *bool `json:"streaming,omitempty"` IncludeSubAgentStreamingEvents *bool `json:"includeSubAgentStreamingEvents,omitempty"` - EnableGitHubTelemetryRedirection *bool `json:"enableGitHubTelemetryRedirection,omitempty"` + EnableGitHubTelemetryForwarding *bool `json:"enableGitHubTelemetryForwarding,omitempty"` MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` MCPOAuthTokenStorage string `json:"mcpOAuthTokenStorage,omitempty"` EnvValueMode string `json:"envValueMode,omitempty"` diff --git a/java/src/main/java/com/github/copilot/CopilotClient.java b/java/src/main/java/com/github/copilot/CopilotClient.java index eb803c3df5..b54b7bae91 100644 --- a/java/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/CopilotClient.java @@ -257,7 +257,7 @@ private Connection startCoreBody() { llmAdapter.registerHandlers(rpc); } - // Register the GitHub telemetry redirection handler when configured. + // Register the GitHub telemetry forwarding handler when configured. java.util.function.Consumer onGitHubTelemetry = this.options .getOnGitHubTelemetry(); if (onGitHubTelemetry != null) { @@ -586,11 +586,11 @@ public CompletableFuture createSession(SessionConfig config) { request.setSystemMessage(extracted.wireSystemMessage()); } - // Opt this session into GitHub telemetry redirection when a + // Opt this session into GitHub telemetry forwarding when a // connection-level handler is registered (mirrors the runtime's // hand-written capability flag, not part of the codegen'd contract). if (options.getOnGitHubTelemetry() != null) { - request.setEnableGitHubTelemetryRedirection(true); + request.setEnableGitHubTelemetryForwarding(true); } // Empty mode: validate availableTools and set toolFilterPrecedence @@ -735,11 +735,11 @@ public CompletableFuture resumeSession(String sessionId, ResumeS request.setSystemMessage(extracted.wireSystemMessage()); } - // Opt this session into GitHub telemetry redirection when a + // Opt this session into GitHub telemetry forwarding when a // connection-level handler is registered (mirrors the runtime's // hand-written capability flag, not part of the codegen'd contract). if (options.getOnGitHubTelemetry() != null) { - request.setEnableGitHubTelemetryRedirection(true); + request.setEnableGitHubTelemetryForwarding(true); } // Empty mode: validate availableTools and set toolFilterPrecedence for resume diff --git a/java/src/main/java/com/github/copilot/GitHubTelemetryAdapter.java b/java/src/main/java/com/github/copilot/GitHubTelemetryAdapter.java index 3589eb15da..7157a882d7 100644 --- a/java/src/main/java/com/github/copilot/GitHubTelemetryAdapter.java +++ b/java/src/main/java/com/github/copilot/GitHubTelemetryAdapter.java @@ -16,7 +16,7 @@ * Bridges the runtime's {@code gitHubTelemetry.event} client-global * notification to a consumer's {@code onGitHubTelemetry} callback. The * notification carries per-session GitHub (hydro) telemetry the runtime - * forwards to connections that opted into telemetry redirection. + * forwards to connections that opted into telemetry forwarding. */ final class GitHubTelemetryAdapter { diff --git a/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java b/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java index 2c2897e733..96cd17f59e 100644 --- a/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java +++ b/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java @@ -488,7 +488,7 @@ public CopilotClientOptions setRequestHandler(CopilotRequestHandler requestHandl } /** - * Gets the connection-level GitHub telemetry redirection handler. + * Gets the connection-level GitHub telemetry forwarding handler. * *

* Experimental: this option may change or be removed without notice. @@ -502,12 +502,12 @@ public Consumer getOnGitHubTelemetry() { } /** - * Sets a connection-level handler for GitHub telemetry redirection + * Sets a connection-level handler for GitHub telemetry forwarding * (experimental). * *

* When provided, the client opts every session it creates or resumes into - * telemetry redirection, and the runtime forwards each per-session telemetry + * telemetry forwarding, and the runtime forwards each per-session telemetry * event to this handler via the {@code gitHubTelemetry.event} notification. * * @param onGitHubTelemetry diff --git a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java index f498c06c69..001b9da6b4 100644 --- a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -92,8 +92,8 @@ public final class CreateSessionRequest { @JsonProperty("includeSubAgentStreamingEvents") private Boolean includeSubAgentStreamingEvents; - @JsonProperty("enableGitHubTelemetryRedirection") - private Boolean enableGitHubTelemetryRedirection; + @JsonProperty("enableGitHubTelemetryForwarding") + private Boolean enableGitHubTelemetryForwarding; @JsonProperty("mcpServers") private Map mcpServers; @@ -781,25 +781,25 @@ public void clearIncludeSubAgentStreamingEvents() { this.includeSubAgentStreamingEvents = null; } - /** Gets the GitHub telemetry redirection flag. @return the flag */ - public Boolean getEnableGitHubTelemetryRedirection() { - return enableGitHubTelemetryRedirection; + /** Gets the GitHub telemetry forwarding flag. @return the flag */ + public Boolean getEnableGitHubTelemetryForwarding() { + return enableGitHubTelemetryForwarding; } /** - * Sets the GitHub telemetry redirection flag. @param - * enableGitHubTelemetryRedirection the flag + * Sets the GitHub telemetry forwarding flag. @param + * enableGitHubTelemetryForwarding the flag */ - public void setEnableGitHubTelemetryRedirection(boolean enableGitHubTelemetryRedirection) { - this.enableGitHubTelemetryRedirection = enableGitHubTelemetryRedirection; + public void setEnableGitHubTelemetryForwarding(boolean enableGitHubTelemetryForwarding) { + this.enableGitHubTelemetryForwarding = enableGitHubTelemetryForwarding; } /** - * Clears the enableGitHubTelemetryRedirection setting, reverting to the default + * Clears the enableGitHubTelemetryForwarding setting, reverting to the default * behavior. */ - public void clearEnableGitHubTelemetryRedirection() { - this.enableGitHubTelemetryRedirection = null; + public void clearEnableGitHubTelemetryForwarding() { + this.enableGitHubTelemetryForwarding = null; } /** Gets the commands wire definitions. @return the commands */ diff --git a/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryClientInfo.java b/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryClientInfo.java index abf1600d2c..7680e9444d 100644 --- a/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryClientInfo.java +++ b/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryClientInfo.java @@ -13,7 +13,7 @@ * event. * *

- * Internal/experimental: this type is part of the GitHub telemetry redirection + * Internal/experimental: this type is part of the GitHub telemetry forwarding * surface and may change or be removed without notice. * * @since 1.0.0 diff --git a/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryEvent.java b/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryEvent.java index f4e353e373..e257733b2c 100644 --- a/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryEvent.java +++ b/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryEvent.java @@ -16,7 +16,7 @@ * format, forwarded verbatim to opted-in hosts. * *

- * Internal/experimental: this type is part of the GitHub telemetry redirection + * Internal/experimental: this type is part of the GitHub telemetry forwarding * surface and may change or be removed without notice. * * @since 1.0.0 diff --git a/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryNotification.java b/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryNotification.java index 637c84b4f6..a06008600c 100644 --- a/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryNotification.java +++ b/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryNotification.java @@ -11,10 +11,10 @@ /** * Payload for a {@code gitHubTelemetry.event} notification: a single GitHub * telemetry event the runtime forwards to a host connection that opted into - * telemetry redirection for the session. + * telemetry forwarding for the session. * *

- * Internal/experimental: this type is part of the GitHub telemetry redirection + * Internal/experimental: this type is part of the GitHub telemetry forwarding * surface and may change or be removed without notice. * * @since 1.0.0 diff --git a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java index 3f54cd7da3..3d5eac58d5 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -135,8 +135,8 @@ public final class ResumeSessionRequest { @JsonProperty("includeSubAgentStreamingEvents") private Boolean includeSubAgentStreamingEvents; - @JsonProperty("enableGitHubTelemetryRedirection") - private Boolean enableGitHubTelemetryRedirection; + @JsonProperty("enableGitHubTelemetryForwarding") + private Boolean enableGitHubTelemetryForwarding; @JsonProperty("mcpServers") private Map mcpServers; @@ -666,25 +666,25 @@ public void clearIncludeSubAgentStreamingEvents() { this.includeSubAgentStreamingEvents = null; } - /** Gets the GitHub telemetry redirection flag. @return the flag */ - public Boolean getEnableGitHubTelemetryRedirection() { - return enableGitHubTelemetryRedirection; + /** Gets the GitHub telemetry forwarding flag. @return the flag */ + public Boolean getEnableGitHubTelemetryForwarding() { + return enableGitHubTelemetryForwarding; } /** - * Sets the GitHub telemetry redirection flag. @param - * enableGitHubTelemetryRedirection the flag + * Sets the GitHub telemetry forwarding flag. @param + * enableGitHubTelemetryForwarding the flag */ - public void setEnableGitHubTelemetryRedirection(boolean enableGitHubTelemetryRedirection) { - this.enableGitHubTelemetryRedirection = enableGitHubTelemetryRedirection; + public void setEnableGitHubTelemetryForwarding(boolean enableGitHubTelemetryForwarding) { + this.enableGitHubTelemetryForwarding = enableGitHubTelemetryForwarding; } /** - * Clears the enableGitHubTelemetryRedirection setting, reverting to the default + * Clears the enableGitHubTelemetryForwarding setting, reverting to the default * behavior. */ - public void clearEnableGitHubTelemetryRedirection() { - this.enableGitHubTelemetryRedirection = null; + public void clearEnableGitHubTelemetryForwarding() { + this.enableGitHubTelemetryForwarding = null; } /** Gets MCP servers. @return the servers map */ diff --git a/java/src/test/java/com/github/copilot/GitHubTelemetryTest.java b/java/src/test/java/com/github/copilot/GitHubTelemetryTest.java index d894ba90ce..cfb443dba6 100644 --- a/java/src/test/java/com/github/copilot/GitHubTelemetryTest.java +++ b/java/src/test/java/com/github/copilot/GitHubTelemetryTest.java @@ -30,9 +30,9 @@ import com.github.copilot.rpc.SessionConfig; /** - * Exercises the hand-written GitHub telemetry redirection surface: the + * Exercises the hand-written GitHub telemetry forwarding surface: the * {@code gitHubTelemetry.event} notification adapter, the - * {@code enableGitHubTelemetryRedirection} capability flag on the create/resume + * {@code enableGitHubTelemetryForwarding} capability flag on the create/resume * requests, and the {@code onGitHubTelemetry} client option. */ @AllowCopilotExperimental @@ -130,7 +130,7 @@ void adapterDispatchesNotificationToHandlerWithTypedPayload() throws Exception { } @Test - void clientOptsSessionsIntoRedirectionAndReceivesEvents() throws Exception { + void clientOptsSessionsIntoForwardingAndReceivesEvents() throws Exception { var received = new CompletableFuture(); Consumer handler = received::complete; @@ -140,12 +140,12 @@ void clientOptsSessionsIntoRedirectionAndReceivesEvents() throws Exception { client.start().get(15, TimeUnit.SECONDS); - // Creating a session must opt it into telemetry redirection. + // Creating a session must opt it into telemetry forwarding. client.createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(15, TimeUnit.SECONDS); JsonNode createParams = server.awaitCreate(); - assertTrue(createParams.path("enableGitHubTelemetryRedirection").asBoolean(), - "create request should carry enableGitHubTelemetryRedirection=true"); + assertTrue(createParams.path("enableGitHubTelemetryForwarding").asBoolean(), + "create request should carry enableGitHubTelemetryForwarding=true"); // The adapter registered on connect should forward server-pushed events. server.sendTelemetry(Map.of("sessionId", "sess-xyz", "restricted", false, "event", @@ -160,13 +160,13 @@ void clientOptsSessionsIntoRedirectionAndReceivesEvents() throws Exception { new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) .get(15, TimeUnit.SECONDS); JsonNode resumeParams = server.awaitResume(); - assertTrue(resumeParams.path("enableGitHubTelemetryRedirection").asBoolean(), - "resume request should carry enableGitHubTelemetryRedirection=true"); + assertTrue(resumeParams.path("enableGitHubTelemetryForwarding").asBoolean(), + "resume request should carry enableGitHubTelemetryForwarding=true"); } } @Test - void clientOmitsRedirectionWhenNoHandler() throws Exception { + void clientOmitsForwardingWhenNoHandler() throws Exception { try (var server = new FakeRuntimeServer(); var client = new CopilotClient(new CopilotClientOptions().setCliUrl(server.url()))) { @@ -175,14 +175,14 @@ void clientOmitsRedirectionWhenNoHandler() throws Exception { client.createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(15, TimeUnit.SECONDS); JsonNode createParams = server.awaitCreate(); - assertFalse(createParams.has("enableGitHubTelemetryRedirection"), + assertFalse(createParams.has("enableGitHubTelemetryForwarding"), "create request should omit the flag when no handler is registered"); client.resumeSession("resume-1", new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) .get(15, TimeUnit.SECONDS); JsonNode resumeParams = server.awaitResume(); - assertFalse(resumeParams.has("enableGitHubTelemetryRedirection"), + assertFalse(resumeParams.has("enableGitHubTelemetryForwarding"), "resume request should omit the flag when no handler is registered"); } } diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 72f6f574ea..b4f8311593 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1435,7 +1435,7 @@ export class CopilotClient { workingDirectory: config.workingDirectory, streaming: config.streaming, includeSubAgentStreamingEvents: config.includeSubAgentStreamingEvents ?? true, - enableGitHubTelemetryRedirection: this.onGitHubTelemetry != null, + enableGitHubTelemetryForwarding: this.onGitHubTelemetry != null, mcpServers: toWireMcpServers(config.mcpServers), mcpOAuthTokenStorage: config.mcpOAuthTokenStorage, envValueMode: "direct", @@ -1642,7 +1642,7 @@ export class CopilotClient { enableSkills: config.enableSkills, streaming: config.streaming, includeSubAgentStreamingEvents: config.includeSubAgentStreamingEvents ?? true, - enableGitHubTelemetryRedirection: this.onGitHubTelemetry != null, + enableGitHubTelemetryForwarding: this.onGitHubTelemetry != null, mcpServers: toWireMcpServers(config.mcpServers), mcpOAuthTokenStorage: config.mcpOAuthTokenStorage, envValueMode: "direct", diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 90f6029c40..4eddb69352 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -14436,7 +14436,7 @@ export interface GitHubTelemetryEvent { client?: GitHubTelemetryClientInfo; } /** - * Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry redirection for the session. + * Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry forwarding for the session. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "GitHubTelemetryNotification". @@ -16944,9 +16944,9 @@ export interface LlmInferenceHandler { /** @experimental */ export interface GitHubTelemetryHandler { /** - * Forwards a single GitHub telemetry event to a host connection that opted into telemetry redirection for the session. + * Forwards a single GitHub telemetry event to a host connection that opted into telemetry forwarding for the session. * - * @param params Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry redirection for the session. + * @param params Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry forwarding for the session. */ event(params: GitHubTelemetryNotification): Promise; } diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index a050c3abd6..0ba6132348 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -347,7 +347,7 @@ export interface CopilotClientOptions { /** * Experimental. Receives GitHub telemetry events the runtime forwards to * this connection. When set, the client opts each session it creates or - * resumes into telemetry redirection and dispatches each + * resumes into telemetry forwarding and dispatches each * `gitHubTelemetry.event` notification to this connection-global handler; * each {@link GitHubTelemetryNotification} carries its originating * `sessionId`. diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index da241a65e7..b4fb5c941f 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -189,7 +189,7 @@ describe("CopilotClient", () => { expect(resumePayload.contextTier).toBe("default"); }); - it("opts into GitHub telemetry redirection when onGitHubTelemetry is provided", async () => { + it("opts into GitHub telemetry forwarding when onGitHubTelemetry is provided", async () => { const client = new CopilotClient({ onGitHubTelemetry: () => {} }); await client.start(); onTestFinished(() => client.forceStop()); @@ -211,11 +211,11 @@ describe("CopilotClient", () => { const resumePayload = spy.mock.calls.find( ([method]) => method === "session.resume" )![1] as any; - expect(createPayload.enableGitHubTelemetryRedirection).toBe(true); - expect(resumePayload.enableGitHubTelemetryRedirection).toBe(true); + expect(createPayload.enableGitHubTelemetryForwarding).toBe(true); + expect(resumePayload.enableGitHubTelemetryForwarding).toBe(true); }); - it("does not opt into GitHub telemetry redirection without a handler", async () => { + it("does not opt into GitHub telemetry forwarding without a handler", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); @@ -232,7 +232,7 @@ describe("CopilotClient", () => { const createPayload = spy.mock.calls.find( ([method]) => method === "session.create" )![1] as any; - expect(createPayload.enableGitHubTelemetryRedirection).toBe(false); + expect(createPayload.enableGitHubTelemetryForwarding).toBe(false); }); it("dispatches a real gitHubTelemetry.event wire notification to the handler", async () => { diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index ff13d47de3..30e2e71999 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -74,6 +74,9 @@ LlmInferenceHeaders, ) from .generated.rpc import ( + GitHubTelemetryClientInfo, + GitHubTelemetryEvent, + GitHubTelemetryNotification, ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, ) @@ -218,6 +221,9 @@ "GetAuthStatusResponse", "BearerTokenProvider", "GetStatusResponse", + "GitHubTelemetryClientInfo", + "GitHubTelemetryEvent", + "GitHubTelemetryNotification", "InfiniteSessionConfig", "InputOptions", "LargeToolOutputConfig", diff --git a/python/copilot/client.py b/python/copilot/client.py index d8ea14f5fb..786dff75ac 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -1161,7 +1161,7 @@ def __init__( on_github_telemetry: Internal. Callback invoked when the runtime forwards a GitHub telemetry event for a session. Registering a handler opts every session opened by this client into telemetry - redirection. + forwarding. Example: >>> # Default — spawns runtime using stdio with the bundled binary @@ -2004,7 +2004,7 @@ async def create_session( # Opt this connection into gitHubTelemetry.event notifications when a # telemetry handler was registered on the client. if self._on_github_telemetry is not None: - payload["enableGitHubTelemetryRedirection"] = True + payload["enableGitHubTelemetryForwarding"] = True # Add provider configuration if provided if provider: @@ -2597,7 +2597,7 @@ async def resume_session( # Opt this connection into gitHubTelemetry.event notifications when a # telemetry handler was registered on the client. if self._on_github_telemetry is not None: - payload["enableGitHubTelemetryRedirection"] = True + payload["enableGitHubTelemetryForwarding"] = True # Enable permission request callback if handler provided payload["requestPermission"] = bool(on_permission_request) diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index fb1deb8eed..a71a016132 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -21427,7 +21427,7 @@ def to_dict(self) -> dict: @dataclass class GitHubTelemetryNotification: """Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the - runtime forwards to a host connection that opted into telemetry redirection for the + runtime forwards to a host connection that opted into telemetry forwarding for the session. """ event: GitHubTelemetryEvent @@ -26789,7 +26789,7 @@ async def http_request_chunk(self, params: LlmInferenceHTTPRequestChunkRequest) # Experimental: this API group is experimental and may change or be removed. class GitHubTelemetryHandler(Protocol): async def event(self, params: GitHubTelemetryNotification) -> None: - "Forwards a single GitHub telemetry event to a host connection that opted into telemetry redirection for the session.\n\nArgs:\n params: Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry redirection for the session." + "Forwards a single GitHub telemetry event to a host connection that opted into telemetry forwarding for the session.\n\nArgs:\n params: Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry forwarding for the session." pass @dataclass diff --git a/python/test_client.py b/python/test_client.py index 251450d50b..0da2abf384 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -1927,7 +1927,7 @@ class TestGitHubTelemetry: """Unit tests for the experimental gitHubTelemetry.event consumer surface.""" @pytest.mark.asyncio - async def test_create_session_enables_redirection_when_handler_registered(self): + async def test_create_session_enables_forwarding_when_handler_registered(self): client = CopilotClient( connection=RuntimeConnection.for_stdio(path=CLI_PATH), on_github_telemetry=lambda _notification: None, @@ -1946,12 +1946,12 @@ async def mock_request(method, params, **kwargs): await client.create_session( on_permission_request=PermissionHandler.approve_all, ) - assert captured["session.create"]["enableGitHubTelemetryRedirection"] is True + assert captured["session.create"]["enableGitHubTelemetryForwarding"] is True finally: await client.force_stop() @pytest.mark.asyncio - async def test_create_session_omits_redirection_without_handler(self): + async def test_create_session_omits_forwarding_without_handler(self): client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) await client.start() @@ -1967,12 +1967,12 @@ async def mock_request(method, params, **kwargs): await client.create_session( on_permission_request=PermissionHandler.approve_all, ) - assert "enableGitHubTelemetryRedirection" not in captured["session.create"] + assert "enableGitHubTelemetryForwarding" not in captured["session.create"] finally: await client.force_stop() @pytest.mark.asyncio - async def test_resume_session_enables_redirection_when_handler_registered(self): + async def test_resume_session_enables_forwarding_when_handler_registered(self): client = CopilotClient( connection=RuntimeConnection.for_stdio(path=CLI_PATH), on_github_telemetry=lambda _notification: None, @@ -1998,12 +1998,12 @@ async def mock_request(method, params, **kwargs): session.session_id, on_permission_request=PermissionHandler.approve_all, ) - assert captured["session.resume"]["enableGitHubTelemetryRedirection"] is True + assert captured["session.resume"]["enableGitHubTelemetryForwarding"] is True finally: await client.force_stop() @pytest.mark.asyncio - async def test_resume_session_omits_redirection_without_handler(self): + async def test_resume_session_omits_forwarding_without_handler(self): client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) await client.start() @@ -2026,7 +2026,7 @@ async def mock_request(method, params, **kwargs): session.session_id, on_permission_request=PermissionHandler.approve_all, ) - assert "enableGitHubTelemetryRedirection" not in captured["session.resume"] + assert "enableGitHubTelemetryForwarding" not in captured["session.resume"] finally: await client.force_stop() diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index 8a3e09928f..4d8653967f 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -14490,7 +14490,7 @@ pub struct GitHubTelemetryEvent { pub session_id: Option, } -/// Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry redirection for the session. +/// Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry forwarding for the session. /// ///

/// diff --git a/rust/src/github_telemetry.rs b/rust/src/github_telemetry.rs index 509035a224..9ef5c6e2e8 100644 --- a/rust/src/github_telemetry.rs +++ b/rust/src/github_telemetry.rs @@ -1,4 +1,4 @@ -//! GitHub telemetry redirection callback surface. +//! GitHub telemetry forwarding callback surface. //! //! The runtime forwards per-session GitHub (hydro) telemetry to opted-in host //! connections via the `gitHubTelemetry.event` JSON-RPC notification. The @@ -7,7 +7,7 @@ //! re-exported here so consumers can register a callback against them via //! [`ClientOptions::on_github_telemetry`](crate::ClientOptions::on_github_telemetry). //! -//! Experimental: this surface is part of the GitHub telemetry redirection +//! Experimental: this surface is part of the GitHub telemetry forwarding //! feature and may change or be removed without notice. use std::sync::Arc; @@ -18,11 +18,11 @@ pub use crate::generated::api_types::{ }; /// Callback invoked for each `gitHubTelemetry.event` notification forwarded by -/// the runtime to a connection that opted into telemetry redirection. +/// the runtime to a connection that opted into telemetry forwarding. /// /// Set via /// [`ClientOptions::on_github_telemetry`](crate::ClientOptions::on_github_telemetry). -/// Registering a callback auto-enables telemetry redirection on every session +/// Registering a callback auto-enables telemetry forwarding on every session /// created or resumed by the client. #[doc(hidden)] pub type GitHubTelemetryCallback = Arc; diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 136def8570..c31e80dc52 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -15,7 +15,7 @@ pub use errors::*; /// model-layer HTTP and WebSocket traffic the runtime issues for both CAPI and /// BYOK sessions. pub mod copilot_request_handler; -/// GitHub telemetry redirection callback surface (experimental). Public but +/// GitHub telemetry forwarding callback surface (experimental). Public but /// `#[doc(hidden)]` — re-exports the generated telemetry payload types. #[doc(hidden)] pub mod github_telemetry; @@ -261,10 +261,10 @@ pub struct ClientOptions { /// [`CopilotRequestHandler`] /// instead of issuing the calls itself. pub request_handler: Option>, - /// Connection-level GitHub telemetry redirection callback (experimental). + /// Connection-level GitHub telemetry forwarding callback (experimental). /// /// When set, every session created or resumed on this client opts into - /// telemetry redirection (`enableGitHubTelemetryRedirection`) and the + /// telemetry forwarding (`enableGitHubTelemetryForwarding`) and the /// callback is invoked for each `gitHubTelemetry.event` notification the /// runtime forwards. `#[doc(hidden)]`, consistent with the experimental /// telemetry payload types. @@ -746,9 +746,9 @@ impl ClientOptions { self } - /// Register a connection-level GitHub telemetry redirection callback + /// Register a connection-level GitHub telemetry forwarding callback /// (internal/experimental). Registering a callback auto-enables telemetry - /// redirection on every session created or resumed on this client; the + /// forwarding on every session created or resumed on this client; the /// callback fires for each forwarded `gitHubTelemetry.event` notification. /// The callback is wrapped in `Arc` internally. #[doc(hidden)] @@ -885,9 +885,9 @@ struct ClientInner { /// Inbound `llmInference.*` dispatcher, installed when /// [`ClientOptions::request_handler`] is set. llm_inference: OnceLock>, - /// Connection-level GitHub telemetry redirection callback, set from + /// Connection-level GitHub telemetry forwarding callback, set from /// [`ClientOptions::on_github_telemetry`]. Drives the - /// `enableGitHubTelemetryRedirection` wire flag and the + /// `enableGitHubTelemetryForwarding` wire flag and the /// `gitHubTelemetry.event` notification dispatch. on_github_telemetry: Option, on_get_trace_context: Option>, @@ -1230,7 +1230,7 @@ impl Client { } /// Construct a [`Client`] from raw streams with a preset GitHub telemetry - /// callback, for integration testing telemetry redirection. + /// callback, for integration testing telemetry forwarding. #[doc(hidden)] #[cfg(any(test, feature = "test-support"))] pub fn from_streams_with_github_telemetry( diff --git a/rust/src/session.rs b/rust/src/session.rs index 08b7215c6e..c490e62062 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -873,7 +873,7 @@ impl Client { let opt_coauthor_enabled = config.coauthor_enabled; let opt_manage_schedule_enabled = config.manage_schedule_enabled; let (mut wire, mut runtime) = config.into_wire(local_session_id.clone())?; - wire.enable_github_telemetry_redirection = + wire.enable_github_telemetry_forwarding = self.inner.on_github_telemetry.is_some().then_some(true); let permission_handler = crate::permission::resolve_handler( @@ -1133,7 +1133,7 @@ impl Client { let opt_coauthor_enabled = config.coauthor_enabled; let opt_manage_schedule_enabled = config.manage_schedule_enabled; let (mut wire, mut runtime) = config.into_wire()?; - wire.enable_github_telemetry_redirection = + wire.enable_github_telemetry_forwarding = self.inner.on_github_telemetry.is_some().then_some(true); let permission_handler = crate::permission::resolve_handler( diff --git a/rust/src/types.rs b/rust/src/types.rs index 3a92bbd852..af8715f249 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -2135,7 +2135,7 @@ impl SessionConfig { remote_session: self.remote_session, cloud: self.cloud, include_sub_agent_streaming_events: self.include_sub_agent_streaming_events, - enable_github_telemetry_redirection: None, + enable_github_telemetry_forwarding: None, commands: wire_commands, exp_assignments: self.exp_assignments, }; @@ -3094,7 +3094,7 @@ impl ResumeSessionConfig { github_token: self.github_token, remote_session: self.remote_session, include_sub_agent_streaming_events: self.include_sub_agent_streaming_events, - enable_github_telemetry_redirection: None, + enable_github_telemetry_forwarding: None, commands: wire_commands, exp_assignments: self.exp_assignments, suppress_resume_event: self.suppress_resume_event, diff --git a/rust/src/wire.rs b/rust/src/wire.rs index ba5141f682..960f24ebc4 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -154,10 +154,10 @@ pub(crate) struct SessionCreateWire { #[serde(skip_serializing_if = "Option::is_none")] pub include_sub_agent_streaming_events: Option, #[serde( - rename = "enableGitHubTelemetryRedirection", + rename = "enableGitHubTelemetryForwarding", skip_serializing_if = "Option::is_none" )] - pub enable_github_telemetry_redirection: Option, + pub enable_github_telemetry_forwarding: Option, #[serde(skip_serializing_if = "Option::is_none")] pub commands: Option>, #[serde(skip_serializing_if = "Option::is_none")] @@ -274,10 +274,10 @@ pub(crate) struct SessionResumeWire { #[serde(skip_serializing_if = "Option::is_none")] pub include_sub_agent_streaming_events: Option, #[serde( - rename = "enableGitHubTelemetryRedirection", + rename = "enableGitHubTelemetryForwarding", skip_serializing_if = "Option::is_none" )] - pub enable_github_telemetry_redirection: Option, + pub enable_github_telemetry_forwarding: Option, #[serde(skip_serializing_if = "Option::is_none")] pub commands: Option>, /// Maps to wire field `disableResume`. diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index ff8f7f3223..f3f324c171 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -389,7 +389,7 @@ fn make_client_with_telemetry( } #[tokio::test] -async fn create_and_resume_send_github_telemetry_redirection_when_callback_registered() { +async fn create_and_resume_send_github_telemetry_forwarding_when_callback_registered() { use github_copilot_sdk::types::ResumeSessionConfig; let callback: github_copilot_sdk::github_telemetry::GitHubTelemetryCallback = @@ -408,7 +408,7 @@ async fn create_and_resume_send_github_telemetry_redirection_when_callback_regis let request = read_framed(&mut server_read).await; assert_eq!(request["method"], "session.create"); - assert_eq!(request["params"]["enableGitHubTelemetryRedirection"], true); + assert_eq!(request["params"]["enableGitHubTelemetryForwarding"], true); let id = request["id"].as_u64().unwrap(); let session_id = requested_session_id(&request).to_string(); @@ -433,7 +433,7 @@ async fn create_and_resume_send_github_telemetry_redirection_when_callback_regis let request = read_framed(&mut server_read).await; assert_eq!(request["method"], "session.resume"); - assert_eq!(request["params"]["enableGitHubTelemetryRedirection"], true); + assert_eq!(request["params"]["enableGitHubTelemetryForwarding"], true); let id = request["id"].as_u64().unwrap(); let response = serde_json::json!({ @@ -453,7 +453,7 @@ async fn create_and_resume_send_github_telemetry_redirection_when_callback_regis } #[tokio::test] -async fn create_session_omits_github_telemetry_redirection_without_callback() { +async fn create_session_omits_github_telemetry_forwarding_without_callback() { let (client, mut server_read, mut server_write) = make_client(); let create_handle = tokio::spawn({ @@ -470,9 +470,9 @@ async fn create_session_omits_github_telemetry_redirection_without_callback() { assert_eq!(request["method"], "session.create"); assert!( request["params"] - .get("enableGitHubTelemetryRedirection") + .get("enableGitHubTelemetryForwarding") .is_none_or(Value::is_null), - "redirection flag should be omitted when no callback is registered" + "forwarding flag should be omitted when no callback is registered" ); let id = request["id"].as_u64().unwrap(); @@ -487,7 +487,7 @@ async fn create_session_omits_github_telemetry_redirection_without_callback() { } #[tokio::test] -async fn resume_session_omits_github_telemetry_redirection_without_callback() { +async fn resume_session_omits_github_telemetry_forwarding_without_callback() { use github_copilot_sdk::types::ResumeSessionConfig; let (client, mut server_read, mut server_write) = make_client(); @@ -508,9 +508,9 @@ async fn resume_session_omits_github_telemetry_redirection_without_callback() { assert_eq!(request["method"], "session.resume"); assert!( request["params"] - .get("enableGitHubTelemetryRedirection") + .get("enableGitHubTelemetryForwarding") .is_none_or(Value::is_null), - "redirection flag should be omitted when no callback is registered" + "forwarding flag should be omitted when no callback is registered" ); let id = request["id"].as_u64().unwrap(); diff --git a/scripts/codegen/schema-overrides/api-additions.schema.json b/scripts/codegen/schema-overrides/api-additions.schema.json index b5f2fd70d6..0f2ede75db 100644 --- a/scripts/codegen/schema-overrides/api-additions.schema.json +++ b/scripts/codegen/schema-overrides/api-additions.schema.json @@ -3,10 +3,10 @@ "gitHubTelemetry": { "event": { "rpcMethod": "gitHubTelemetry.event", - "description": "Forwards a single GitHub telemetry event to a host connection that opted into telemetry redirection for the session.", + "description": "Forwards a single GitHub telemetry event to a host connection that opted into telemetry forwarding for the session.", "params": { "$ref": "#/definitions/GitHubTelemetryNotification", - "description": "Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry redirection for the session." + "description": "Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry forwarding for the session." }, "result": { "type": "null" @@ -158,7 +158,7 @@ "event" ], "additionalProperties": false, - "description": "Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry redirection for the session.", + "description": "Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry forwarding for the session.", "title": "GitHubTelemetryNotification", "stability": "experimental" } From da54ca340de8b40c2abfa12f3800c4090377235a Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 30 Jun 2026 12:56:16 -0700 Subject: [PATCH 10/26] nodejs: align GitHub telemetry forwarding with cross-SDK contract Address PR review feedback on the Node telemetry surface: - Omit enableGitHubTelemetryForwarding from session.create/resume unless a handler is registered, matching Go/Python/Rust/dotnet/Java (which all omit the field when off) instead of always sending false. - Isolate consumer callback failures: await the handler inside try/catch so async callbacks or thrown errors cannot leak into the JSON-RPC dispatch path (mirrors the panic isolation in the other SDKs). - Widen onGitHubTelemetry to return void | Promise so consumers can supply async callbacks. - Update the no-handler test to assert the field is absent rather than false. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 14 +++++++++++--- nodejs/src/types.ts | 2 +- nodejs/test/client.test.ts | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index a142fe7269..809848eebc 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -772,7 +772,11 @@ export class CopilotClient { const onGitHubTelemetry = this.onGitHubTelemetry; handlers.gitHubTelemetry = { event: async (notification) => { - onGitHubTelemetry(notification); + try { + await onGitHubTelemetry(notification); + } catch { + // Ignore handler errors + } }, }; } @@ -1436,7 +1440,9 @@ export class CopilotClient { workingDirectory: config.workingDirectory, streaming: config.streaming, includeSubAgentStreamingEvents: config.includeSubAgentStreamingEvents ?? true, - enableGitHubTelemetryForwarding: this.onGitHubTelemetry != null, + ...(this.onGitHubTelemetry != null + ? { enableGitHubTelemetryForwarding: true } + : {}), mcpServers: toWireMcpServers(config.mcpServers), mcpOAuthTokenStorage: config.mcpOAuthTokenStorage, envValueMode: "direct", @@ -1656,7 +1662,9 @@ export class CopilotClient { enableSkills: config.enableSkills, streaming: config.streaming, includeSubAgentStreamingEvents: config.includeSubAgentStreamingEvents ?? true, - enableGitHubTelemetryForwarding: this.onGitHubTelemetry != null, + ...(this.onGitHubTelemetry != null + ? { enableGitHubTelemetryForwarding: true } + : {}), mcpServers: toWireMcpServers(config.mcpServers), mcpOAuthTokenStorage: config.mcpOAuthTokenStorage, envValueMode: "direct", diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 2f83d392c8..02dcf9b33d 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -354,7 +354,7 @@ export interface CopilotClientOptions { * * @experimental */ - onGitHubTelemetry?: (notification: GitHubTelemetryNotification) => void; + onGitHubTelemetry?: (notification: GitHubTelemetryNotification) => void | Promise; /** * Server-wide idle timeout for sessions in seconds. diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 07afca9a4d..09c49a3eb5 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -454,7 +454,7 @@ describe("CopilotClient", () => { const createPayload = spy.mock.calls.find( ([method]) => method === "session.create" )![1] as any; - expect(createPayload.enableGitHubTelemetryForwarding).toBe(false); + expect(createPayload.enableGitHubTelemetryForwarding).toBeUndefined(); }); it("dispatches a real gitHubTelemetry.event wire notification to the handler", async () => { From 85726bccd886e3c45a6d456b6243df1e3f2e149d Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 30 Jun 2026 12:56:18 -0700 Subject: [PATCH 11/26] python: address telemetry review nits - Use asyncio.create_task instead of the legacy asyncio.ensure_future when scheduling awaitable notification handlers on the event loop thread. - Drop the redundant local `import asyncio` in the telemetry transport test (asyncio is already imported at module scope), clearing CodeQL py/repeated-import. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/copilot/_jsonrpc.py | 2 +- python/test_client.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/python/copilot/_jsonrpc.py b/python/copilot/_jsonrpc.py index 58f75b6ed1..ed70e4e8d0 100644 --- a/python/copilot/_jsonrpc.py +++ b/python/copilot/_jsonrpc.py @@ -463,7 +463,7 @@ async def _await_outcome(): except Exception: # pylint: disable=broad-except logger.warning("Notification handler raised", exc_info=True) - asyncio.ensure_future(_await_outcome()) + asyncio.create_task(_await_outcome()) async def _dispatch_request(self, message: dict, handler: RequestHandler): try: diff --git a/python/test_client.py b/python/test_client.py index be17966bb9..36b891b070 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -2342,8 +2342,6 @@ async def mock_request(method, params, **kwargs): @pytest.mark.asyncio async def test_event_routes_to_handler_via_notification_transport(self): - import asyncio - from copilot.generated.rpc import GitHubTelemetryNotification received: list = [] From 1c398a4dca21170d042fa0a2919d742bb31b7481 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 30 Jun 2026 12:56:18 -0700 Subject: [PATCH 12/26] dotnet: observe expected exception in telemetry test teardown The DisposeAsync teardown catch swallows expected listener/socket shutdown exceptions. Observe the caught exception so the block is no longer empty, clearing CodeQL cs/empty-catch-block. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/test/Unit/GitHubTelemetryTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dotnet/test/Unit/GitHubTelemetryTests.cs b/dotnet/test/Unit/GitHubTelemetryTests.cs index dbe008ea49..b2df78ba7f 100644 --- a/dotnet/test/Unit/GitHubTelemetryTests.cs +++ b/dotnet/test/Unit/GitHubTelemetryTests.cs @@ -213,7 +213,8 @@ public async ValueTask DisposeAsync() catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or IOException or SocketException) { // Expected during teardown: the listener/socket is torn down while the - // server loop is still awaiting I/O. Nothing to clean up beyond this. + // server loop is still awaiting I/O. Observe the exception and move on. + _ = ex; } _cts.Dispose(); From dcef40efdd7f7a63a3d3574a20e7543666784c14 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 30 Jun 2026 15:16:10 -0700 Subject: [PATCH 13/26] go: format telemetry forwarding fields Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go/client_test.go | 2 +- go/types.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go/client_test.go b/go/client_test.go index 62dd321e4a..7e84cc5ba1 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -2293,7 +2293,7 @@ func TestCreateSessionRequest_EnableGitHubTelemetryForwarding(t *testing.T) { func TestResumeSessionRequest_EnableGitHubTelemetryForwarding(t *testing.T) { t.Run("forwards explicit true", func(t *testing.T) { req := resumeSessionRequest{ - SessionID: "s1", + SessionID: "s1", EnableGitHubTelemetryForwarding: Bool(true), } data, err := json.Marshal(req) diff --git a/go/types.go b/go/types.go index 4a114baf72..dad1f9cd8a 100644 --- a/go/types.go +++ b/go/types.go @@ -2053,7 +2053,7 @@ type createSessionRequest struct { WorkingDirectory string `json:"workingDirectory,omitempty"` Streaming *bool `json:"streaming,omitempty"` IncludeSubAgentStreamingEvents *bool `json:"includeSubAgentStreamingEvents,omitempty"` - EnableGitHubTelemetryForwarding *bool `json:"enableGitHubTelemetryForwarding,omitempty"` + EnableGitHubTelemetryForwarding *bool `json:"enableGitHubTelemetryForwarding,omitempty"` MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` MCPOAuthTokenStorage string `json:"mcpOAuthTokenStorage,omitempty"` EnvValueMode string `json:"envValueMode,omitempty"` @@ -2149,7 +2149,7 @@ type resumeSessionRequest struct { ContinuePendingWork *bool `json:"continuePendingWork,omitempty"` Streaming *bool `json:"streaming,omitempty"` IncludeSubAgentStreamingEvents *bool `json:"includeSubAgentStreamingEvents,omitempty"` - EnableGitHubTelemetryForwarding *bool `json:"enableGitHubTelemetryForwarding,omitempty"` + EnableGitHubTelemetryForwarding *bool `json:"enableGitHubTelemetryForwarding,omitempty"` MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` MCPOAuthTokenStorage string `json:"mcpOAuthTokenStorage,omitempty"` EnvValueMode string `json:"envValueMode,omitempty"` From bb65bd8a1d70bcc71a558eb292e9369ca75de42e Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 30 Jun 2026 16:24:13 -0700 Subject: [PATCH 14/26] codegen: remove temporary GitHub telemetry schema overlay The telemetry types ship natively in @github/copilot 1.0.67, so the hand-maintained schema overlay that injected GitHubTelemetryNotification, GitHubTelemetryEvent, GitHubTelemetryClientInfo, and the gitHubTelemetry.event clientGlobal method is no longer needed. Remove the api-additions overlay and its application plumbing in utils.ts, and revert rust.ts to read the schema directly. The generic notification-flag codegen handling is retained, since the published schema marks gitHubTelemetry.event as a notification. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/codegen/rust.ts | 10 +- .../api-additions.schema.json | 166 ------------------ scripts/codegen/utils.ts | 57 +----- 3 files changed, 8 insertions(+), 225 deletions(-) delete mode 100644 scripts/codegen/schema-overrides/api-additions.schema.json diff --git a/scripts/codegen/rust.ts b/scripts/codegen/rust.ts index 3d6208204e..3a3ce39a2f 100644 --- a/scripts/codegen/rust.ts +++ b/scripts/codegen/rust.ts @@ -40,7 +40,7 @@ import { isSchemaExperimental, isSchemaInternal, isVoidSchema, - loadSchemaJson, + normalizeSchemaBrandCasing, fixBrandCasing, parseExternalSchemaRef, postProcessSchema, @@ -2155,8 +2155,12 @@ async function generate(): Promise { schemaArgs.sessionEventsSchemaPath || (await getSessionEventsSchemaPath()); const apiSchemaPath = await getApiSchemaPath(schemaArgs.apiSchemaPath); - const sessionEventsRaw = await loadSchemaJson(sessionEventsSchemaPath); - const apiRaw = await loadSchemaJson(apiSchemaPath); + const sessionEventsRaw = normalizeSchemaBrandCasing( + JSON.parse(await fs.readFile(sessionEventsSchemaPath, "utf-8")), + ); + const apiRaw = normalizeSchemaBrandCasing( + JSON.parse(await fs.readFile(apiSchemaPath, "utf-8")) as ApiSchema, + ); const sessionEventsSchema = propagateInternalVisibility( postProcessSchema( diff --git a/scripts/codegen/schema-overrides/api-additions.schema.json b/scripts/codegen/schema-overrides/api-additions.schema.json deleted file mode 100644 index 0f2ede75db..0000000000 --- a/scripts/codegen/schema-overrides/api-additions.schema.json +++ /dev/null @@ -1,166 +0,0 @@ -{ - "clientGlobal": { - "gitHubTelemetry": { - "event": { - "rpcMethod": "gitHubTelemetry.event", - "description": "Forwards a single GitHub telemetry event to a host connection that opted into telemetry forwarding for the session.", - "params": { - "$ref": "#/definitions/GitHubTelemetryNotification", - "description": "Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry forwarding for the session." - }, - "result": { - "type": "null" - }, - "notification": true, - "stability": "experimental" - } - } - }, - "definitions": { - "GitHubTelemetryClientInfo": { - "type": "object", - "properties": { - "cli_version": { - "type": "string", - "description": "Copilot CLI version string." - }, - "os_platform": { - "type": "string", - "description": "Operating system platform (e.g. darwin, linux, win32)." - }, - "os_version": { - "type": "string", - "description": "Operating system version string." - }, - "os_arch": { - "type": "string", - "description": "Operating system architecture (e.g. arm64, x64)." - }, - "node_version": { - "type": "string", - "description": "Node.js runtime version string." - }, - "copilot_plan": { - "type": "string", - "description": "Copilot subscription plan, when known." - }, - "client_type": { - "type": "string", - "description": "Type of client." - }, - "client_name": { - "type": "string", - "description": "Name of the client application." - }, - "is_staff": { - "type": "boolean", - "description": "Whether the user is a GitHub/Microsoft staff member." - }, - "dev_device_id": { - "type": "string", - "description": "Stable machine identifier for the device." - } - }, - "required": [ - "cli_version", - "os_platform", - "os_version", - "os_arch", - "node_version" - ], - "additionalProperties": false, - "description": "Client environment metadata describing the process that produced a telemetry event.", - "title": "GitHubTelemetryClientInfo", - "stability": "experimental" - }, - "GitHubTelemetryEvent": { - "type": "object", - "properties": { - "kind": { - "type": "string", - "description": "Event type/kind (e.g. get_completion_with_tools_turn, tool_call_executed)." - }, - "created_at": { - "type": "string", - "description": "Timestamp when the event was created (ISO 8601 format)." - }, - "model_call_id": { - "type": "string", - "description": "Reference to the model call that produced this event." - }, - "properties": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "description": "String-valued properties as a map from key to value." - }, - "metrics": { - "type": "object", - "additionalProperties": { - "type": "number" - }, - "description": "Numeric metrics as a map from key to value." - }, - "exp_assignment_context": { - "type": "string", - "description": "Experiment assignment context." - }, - "features": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "description": "Feature flags enabled for this session, as a map from flag to value." - }, - "session_id": { - "type": "string", - "description": "Session identifier the event belongs to." - }, - "copilot_tracking_id": { - "type": "string", - "description": "Copilot tracking ID for user-level attribution." - }, - "client": { - "$ref": "#/definitions/GitHubTelemetryClientInfo", - "description": "Client environment metadata." - } - }, - "required": [ - "kind", - "properties", - "metrics" - ], - "additionalProperties": false, - "description": "A single telemetry event in the runtime's native GitHub-shaped telemetry format, forwarded verbatim to opted-in hosts. The `restricted` flag on the enclosing GitHubTelemetryNotification distinguishes standard from restricted events; the payload shape is identical for both.", - "title": "GitHubTelemetryEvent", - "stability": "experimental" - }, - "GitHubTelemetryNotification": { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "Session the telemetry event belongs to." - }, - "restricted": { - "type": "boolean", - "description": "Whether this is a restricted telemetry event (cli.restricted_telemetry). Hosts must route restricted events to first-party Microsoft stores only." - }, - "event": { - "$ref": "#/definitions/GitHubTelemetryEvent", - "description": "The telemetry event, in the runtime's native GitHub-shaped telemetry format." - } - }, - "required": [ - "sessionId", - "restricted", - "event" - ], - "additionalProperties": false, - "description": "Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry forwarding for the session.", - "title": "GitHubTelemetryNotification", - "stability": "experimental" - } - } -} diff --git a/scripts/codegen/utils.ts b/scripts/codegen/utils.ts index 37f191edb3..9ab335b05f 100644 --- a/scripts/codegen/utils.ts +++ b/scripts/codegen/utils.ts @@ -46,7 +46,6 @@ export type SchemaWithSharedDefinitions = T // ── Schema paths ──────────────────────────────────────────────────────────── const SDK_NODE_MODULES = path.join(REPO_ROOT, "nodejs/node_modules"); -const API_SCHEMA_ADDITIONS_PATH = path.join(__dirname, "schema-overrides/api-additions.schema.json"); /** * Resolve a JSON schema shipped by the `@github/copilot` CLI package. @@ -186,61 +185,7 @@ function renameBrandDefinitionKeys(defs: Record): void { /** Load a JSON schema file and normalize GitHub brand casing in titles, refs, and definition keys. */ export async function loadSchemaJson(filePath: string): Promise { const parsed = JSON.parse(await fs.readFile(filePath, "utf-8")) as T; - const normalized = normalizeSchemaBrandCasing(parsed); - return applyApiSchemaAdditions(normalized, filePath); -} - -async function applyApiSchemaAdditions(schema: T, filePath: string): Promise { - if (path.basename(filePath) !== "api.schema.json") return schema; - - let additions: ApiSchema; - try { - additions = normalizeSchemaBrandCasing( - JSON.parse(await fs.readFile(API_SCHEMA_ADDITIONS_PATH, "utf-8")) as ApiSchema - ); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "ENOENT") return schema; - throw err; - } - - const apiSchema = schema as ApiSchema; - mergeSchemaAdditions(apiSchema, "definitions", additions.definitions); - mergeSchemaAdditions(apiSchema, "$defs", additions.$defs); - mergeSchemaAdditions(apiSchema, "server", additions.server); - mergeSchemaAdditions(apiSchema, "session", additions.session); - mergeSchemaAdditions(apiSchema, "clientSession", additions.clientSession); - mergeSchemaAdditions(apiSchema, "clientGlobal", additions.clientGlobal); - return schema; -} - -function mergeSchemaAdditions( - schema: ApiSchema, - key: keyof ApiSchema, - additions: Record | undefined -): void { - if (!additions) return; - mergeMissingEntries((schema[key] ??= {}) as Record, additions); -} - -function mergeMissingEntries(target: Record, additions: Record | undefined): void { - if (!additions) return; - - for (const [key, value] of Object.entries(additions)) { - if (!(key in target)) { - target[key] = value; - continue; - } - - const existing = target[key]; - if (isPlainObject(existing) && isPlainObject(value)) { - mergeMissingEntries(existing, value); - } - } -} - -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); + return normalizeSchemaBrandCasing(parsed); } // ── Schema processing ─────────────────────────────────────────────────────── From 7c49ebbbcae34f290b13f7cb9d5f298b9784a46b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:22:18 +0000 Subject: [PATCH 15/26] Reconcile Node/Python/Go telemetry glue with main's generated dispatch Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- go/client.go | 4 ++-- nodejs/test/client.test.ts | 9 ++++---- python/copilot/_jsonrpc.py | 42 ++------------------------------------ python/test_client.py | 39 +++++++++++++---------------------- 4 files changed, 22 insertions(+), 72 deletions(-) diff --git a/go/client.go b/go/client.go index 1bbf615d7b..3cf0320650 100644 --- a/go/client.go +++ b/go/client.go @@ -2086,10 +2086,10 @@ type gitHubTelemetryAdapter struct { callback func(notification *rpc.GitHubTelemetryNotification) } -func (a *gitHubTelemetryAdapter) Event(request *rpc.GitHubTelemetryNotification) error { +func (a *gitHubTelemetryAdapter) Event(request *rpc.GitHubTelemetryNotification) (*rpc.GitHubTelemetryEventResult, error) { defer func() { recover() }() // Ignore handler panics a.callback(request) - return nil + return &rpc.GitHubTelemetryEventResult{}, nil } func (c *Client) handleSessionEvent(req sessionEventRequest) { diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index c62d53230f..b8d641673d 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -508,7 +508,7 @@ describe("CopilotClient", () => { expect(createPayload.enableGitHubTelemetryForwarding).toBeUndefined(); }); - it("dispatches a real gitHubTelemetry.event wire notification to the handler", async () => { + it("dispatches a real gitHubTelemetry.event wire message to the handler", async () => { const { createMessageConnection, StreamMessageReader, StreamMessageWriter } = await import("vscode-jsonrpc/node.js"); const { registerClientGlobalApiHandlers } = await import("../src/generated/rpc.js"); @@ -557,10 +557,9 @@ describe("CopilotClient", () => { }, }; - // Send as a real JSON-RPC notification (no id). A regression that wires - // this method up as a request handler would never fire and this await - // would hang. - await serverConn.sendNotification("gitHubTelemetry.event", notification); + // Deliver the event over the real wire and confirm the generated + // dispatcher routes it to the registered handler. + await serverConn.sendRequest("gitHubTelemetry.event", notification); await got; expect(received).toEqual([notification]); diff --git a/python/copilot/_jsonrpc.py b/python/copilot/_jsonrpc.py index ed70e4e8d0..a58908d08d 100644 --- a/python/copilot/_jsonrpc.py +++ b/python/copilot/_jsonrpc.py @@ -80,7 +80,6 @@ def __init__(self, process): self.pending_requests: dict[str, asyncio.Future] = {} self._pending_inline_callbacks: dict[str, Callable[[Any], None]] = {} self.notification_handler: Callable[[str, dict], None] | None = None - self.notification_method_handlers: dict[str, Callable[[dict], Any]] = {} self.request_handlers: dict[str, RequestHandler] = {} self._running = False self._read_thread: threading.Thread | None = None @@ -233,19 +232,6 @@ def set_notification_handler(self, handler: Callable[[str, dict], None]): """Set the handler for incoming notifications from the server.""" self.notification_handler = handler - def set_notification_method_handler(self, method: str, handler: Callable[[dict], Any] | None): - """Register a handler for a specific server-to-client notification method. - - Notifications carry no ``id`` and expect no response, so they are - dispatched separately from request handlers. A registered method - handler takes precedence over the generic notification handler. The - handler may be a coroutine function; its result is awaited. - """ - if handler is None: - self.notification_method_handlers.pop(method, None) - else: - self.notification_method_handlers[method] = handler - def set_request_handler(self, method: str, handler: RequestHandler): if handler is None: self.request_handlers.pop(method, None) @@ -411,14 +397,9 @@ def _handle_message(self, message: dict): # Check if it's a notification from the server if "method" in message and "id" not in message: - method = message["method"] - params = message.get("params", {}) - handler = self.notification_method_handlers.get(method) - if handler is not None and self._loop: - # Method-specific notification handler takes precedence. - self._loop.call_soon_threadsafe(self._dispatch_notification, handler, params) - return if self.notification_handler and self._loop: + method = message["method"] + params = message.get("params", {}) # Schedule notification handler on the event loop for thread safety self._loop.call_soon_threadsafe(self.notification_handler, method, params) return @@ -446,25 +427,6 @@ def _handle_request(self, message: dict): self._loop, ) - def _dispatch_notification(self, handler: Callable[[dict], Any], params: dict): - """Invoke a method-specific notification handler. Runs on the event loop; - coroutine results are scheduled and any error is logged (notifications - carry no response, so failures never propagate to the server).""" - try: - outcome = handler(params) - except Exception: # pylint: disable=broad-except - logger.warning("Notification handler raised", exc_info=True) - return - if inspect.isawaitable(outcome): - - async def _await_outcome(): - try: - await outcome - except Exception: # pylint: disable=broad-except - logger.warning("Notification handler raised", exc_info=True) - - asyncio.create_task(_await_outcome()) - async def _dispatch_request(self, message: dict, handler: RequestHandler): try: params = message.get("params", {}) diff --git a/python/test_client.py b/python/test_client.py index 52824b7ade..7a3f4bf7b2 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -2382,15 +2382,13 @@ async def mock_request(method, params, **kwargs): await client.force_stop() @pytest.mark.asyncio - async def test_event_routes_to_handler_via_notification_transport(self): + async def test_event_routes_to_handler(self): from copilot.generated.rpc import GitHubTelemetryNotification received: list = [] - done = asyncio.Event() def on_telemetry(notification): received.append(notification) - done.set() client = CopilotClient( connection=RuntimeConnection.for_stdio(path=CLI_PATH), @@ -2399,33 +2397,25 @@ def on_telemetry(notification): await client.start() try: - # The method must be wired as a notification handler, NOT a request - # handler: the runtime forwards telemetry via send_notification (an - # id-less message), which never reaches the request-handler table. - assert "gitHubTelemetry.event" in client._client.notification_method_handlers - assert "gitHubTelemetry.event" not in client._client.request_handlers + # The generated client-global dispatcher wires gitHubTelemetry.event + # into the request-handler table; invoking it exercises the full + # from_dict decode + adapter + user-callback path. + assert "gitHubTelemetry.event" in client._client.request_handlers - # Drive a real JSON-RPC notification (no "id") through the transport's - # message dispatch — the exact path the runtime uses. - client._client._handle_message( + handler = client._client.request_handlers["gitHubTelemetry.event"] + await handler( { - "jsonrpc": "2.0", - "method": "gitHubTelemetry.event", - "params": { - "sessionId": "sess-telemetry", - "restricted": True, - "event": { - "kind": "tool_call_executed", - "metrics": {"duration_ms": 12.5}, - "properties": {"tool": "shell"}, - "session_id": "sess-telemetry", - }, + "sessionId": "sess-telemetry", + "restricted": True, + "event": { + "kind": "tool_call_executed", + "metrics": {"duration_ms": 12.5}, + "properties": {"tool": "shell"}, + "session_id": "sess-telemetry", }, } ) - await asyncio.wait_for(done.wait(), timeout=5) - assert len(received) == 1 notification = received[0] assert isinstance(notification, GitHubTelemetryNotification) @@ -2443,7 +2433,6 @@ async def test_event_handler_not_registered_without_option(self): await client.start() try: - assert "gitHubTelemetry.event" not in client._client.notification_method_handlers assert "gitHubTelemetry.event" not in client._client.request_handlers finally: await client.force_stop() From 279a0be8ab7908484c017d6f4ef1fe293330ee46 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:32:34 +0000 Subject: [PATCH 16/26] Repoint Java telemetry glue at generated types; finish Rust/.NET reconcile Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../com/github/copilot/CopilotClient.java | 2 +- .../copilot/GitHubTelemetryAdapter.java | 2 +- .../copilot/rpc/CopilotClientOptions.java | 1 + .../rpc/GitHubTelemetryClientInfo.java | 143 ----------------- .../copilot/rpc/GitHubTelemetryEvent.java | 147 ------------------ .../rpc/GitHubTelemetryNotification.java | 62 -------- .../github/copilot/GitHubTelemetryTest.java | 46 +++--- 7 files changed, 26 insertions(+), 377 deletions(-) delete mode 100644 java/src/main/java/com/github/copilot/rpc/GitHubTelemetryClientInfo.java delete mode 100644 java/src/main/java/com/github/copilot/rpc/GitHubTelemetryEvent.java delete mode 100644 java/src/main/java/com/github/copilot/rpc/GitHubTelemetryNotification.java diff --git a/java/src/main/java/com/github/copilot/CopilotClient.java b/java/src/main/java/com/github/copilot/CopilotClient.java index e9d0184c0b..d86f063e79 100644 --- a/java/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/CopilotClient.java @@ -259,7 +259,7 @@ private Connection startCoreBody() { } // Register the GitHub telemetry forwarding handler when configured. - java.util.function.Consumer onGitHubTelemetry = this.options + java.util.function.Consumer onGitHubTelemetry = this.options .getOnGitHubTelemetry(); if (onGitHubTelemetry != null) { GitHubTelemetryAdapter telemetryAdapter = new GitHubTelemetryAdapter(onGitHubTelemetry); diff --git a/java/src/main/java/com/github/copilot/GitHubTelemetryAdapter.java b/java/src/main/java/com/github/copilot/GitHubTelemetryAdapter.java index 7157a882d7..b9d3db11fd 100644 --- a/java/src/main/java/com/github/copilot/GitHubTelemetryAdapter.java +++ b/java/src/main/java/com/github/copilot/GitHubTelemetryAdapter.java @@ -10,7 +10,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.github.copilot.rpc.GitHubTelemetryNotification; +import com.github.copilot.generated.rpc.GitHubTelemetryNotification; /** * Bridges the runtime's {@code gitHubTelemetry.event} client-global diff --git a/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java b/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java index 96cd17f59e..f070f99735 100644 --- a/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java +++ b/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java @@ -18,6 +18,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.github.copilot.CopilotExperimental; import com.github.copilot.CopilotRequestHandler; +import com.github.copilot.generated.rpc.GitHubTelemetryNotification; import java.util.Optional; import java.util.OptionalInt; diff --git a/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryClientInfo.java b/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryClientInfo.java deleted file mode 100644 index 7680e9444d..0000000000 --- a/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryClientInfo.java +++ /dev/null @@ -1,143 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - *--------------------------------------------------------------------------------------------*/ - -package com.github.copilot.rpc; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import com.github.copilot.CopilotExperimental; - -/** - * Client environment metadata describing the process that produced a telemetry - * event. - * - *

- * Internal/experimental: this type is part of the GitHub telemetry forwarding - * surface and may change or be removed without notice. - * - * @since 1.0.0 - */ -@CopilotExperimental -public class GitHubTelemetryClientInfo { - - @JsonProperty("cli_version") - private String cliVersion = ""; - - @JsonProperty("client_name") - private String clientName; - - @JsonProperty("client_type") - private String clientType; - - @JsonProperty("copilot_plan") - private String copilotPlan; - - @JsonProperty("dev_device_id") - private String devDeviceId; - - @JsonProperty("is_staff") - private Boolean isStaff; - - @JsonProperty("node_version") - private String nodeVersion = ""; - - @JsonProperty("os_arch") - private String osArch = ""; - - @JsonProperty("os_platform") - private String osPlatform = ""; - - @JsonProperty("os_version") - private String osVersion = ""; - - /** - * Gets the Copilot CLI version string. - * - * @return the CLI version - */ - public String getCliVersion() { - return cliVersion; - } - - /** - * Gets the name of the client application. - * - * @return the client name, or {@code null} if unknown - */ - public String getClientName() { - return clientName; - } - - /** - * Gets the type of client. - * - * @return the client type, or {@code null} if unknown - */ - public String getClientType() { - return clientType; - } - - /** - * Gets the Copilot subscription plan, when known. - * - * @return the Copilot plan, or {@code null} if unknown - */ - public String getCopilotPlan() { - return copilotPlan; - } - - /** - * Gets the stable machine identifier for the device. - * - * @return the device identifier, or {@code null} if unknown - */ - public String getDevDeviceId() { - return devDeviceId; - } - - /** - * Gets whether the user is a GitHub/Microsoft staff member. - * - * @return the staff flag, or {@code null} if unknown - */ - public Boolean getIsStaff() { - return isStaff; - } - - /** - * Gets the Node.js runtime version string. - * - * @return the Node.js version - */ - public String getNodeVersion() { - return nodeVersion; - } - - /** - * Gets the operating system architecture (e.g. arm64, x64). - * - * @return the OS architecture - */ - public String getOsArch() { - return osArch; - } - - /** - * Gets the operating system platform (e.g. darwin, linux, win32). - * - * @return the OS platform - */ - public String getOsPlatform() { - return osPlatform; - } - - /** - * Gets the operating system version string. - * - * @return the OS version - */ - public String getOsVersion() { - return osVersion; - } -} diff --git a/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryEvent.java b/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryEvent.java deleted file mode 100644 index e257733b2c..0000000000 --- a/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryEvent.java +++ /dev/null @@ -1,147 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - *--------------------------------------------------------------------------------------------*/ - -package com.github.copilot.rpc; - -import java.util.Collections; -import java.util.Map; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import com.github.copilot.CopilotExperimental; - -/** - * A single telemetry event in the runtime's native GitHub-shaped telemetry - * format, forwarded verbatim to opted-in hosts. - * - *

- * Internal/experimental: this type is part of the GitHub telemetry forwarding - * surface and may change or be removed without notice. - * - * @since 1.0.0 - */ -@CopilotExperimental -public class GitHubTelemetryEvent { - - @JsonProperty("client") - private GitHubTelemetryClientInfo client; - - @JsonProperty("copilot_tracking_id") - private String copilotTrackingId; - - @JsonProperty("created_at") - private String createdAt; - - @JsonProperty("exp_assignment_context") - private String expAssignmentContext; - - @JsonProperty("features") - private Map features; - - @JsonProperty("kind") - private String kind = ""; - - @JsonProperty("metrics") - private Map metrics = Collections.emptyMap(); - - @JsonProperty("model_call_id") - private String modelCallId; - - @JsonProperty("properties") - private Map properties = Collections.emptyMap(); - - @JsonProperty("session_id") - private String sessionId; - - /** - * Gets the client environment metadata. - * - * @return the client info, or {@code null} if absent - */ - public GitHubTelemetryClientInfo getClient() { - return client; - } - - /** - * Gets the Copilot tracking ID for user-level attribution. - * - * @return the tracking ID, or {@code null} if absent - */ - public String getCopilotTrackingId() { - return copilotTrackingId; - } - - /** - * Gets the timestamp when the event was created (ISO 8601 format). - * - * @return the creation timestamp, or {@code null} if absent - */ - public String getCreatedAt() { - return createdAt; - } - - /** - * Gets the experiment assignment context. - * - * @return the assignment context, or {@code null} if absent - */ - public String getExpAssignmentContext() { - return expAssignmentContext; - } - - /** - * Gets the feature flags enabled for this session, as a map from flag to value. - * - * @return the features map, or {@code null} if absent - */ - public Map getFeatures() { - return features; - } - - /** - * Gets the event type/kind (e.g. get_completion_with_tools_turn, - * tool_call_executed). - * - * @return the event kind - */ - public String getKind() { - return kind; - } - - /** - * Gets the numeric metrics as a map from key to value. - * - * @return the metrics map - */ - public Map getMetrics() { - return metrics; - } - - /** - * Gets the reference to the model call that produced this event. - * - * @return the model call ID, or {@code null} if absent - */ - public String getModelCallId() { - return modelCallId; - } - - /** - * Gets the string-valued properties as a map from key to value. - * - * @return the properties map - */ - public Map getProperties() { - return properties; - } - - /** - * Gets the session identifier the event belongs to. - * - * @return the session ID, or {@code null} if absent - */ - public String getSessionId() { - return sessionId; - } -} diff --git a/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryNotification.java b/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryNotification.java deleted file mode 100644 index a06008600c..0000000000 --- a/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryNotification.java +++ /dev/null @@ -1,62 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - *--------------------------------------------------------------------------------------------*/ - -package com.github.copilot.rpc; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import com.github.copilot.CopilotExperimental; - -/** - * Payload for a {@code gitHubTelemetry.event} notification: a single GitHub - * telemetry event the runtime forwards to a host connection that opted into - * telemetry forwarding for the session. - * - *

- * Internal/experimental: this type is part of the GitHub telemetry forwarding - * surface and may change or be removed without notice. - * - * @since 1.0.0 - */ -@CopilotExperimental -public class GitHubTelemetryNotification { - - @JsonProperty("event") - private GitHubTelemetryEvent event = new GitHubTelemetryEvent(); - - @JsonProperty("restricted") - private boolean restricted; - - @JsonProperty("sessionId") - private String sessionId = ""; - - /** - * Gets the telemetry event, in the runtime's native GitHub-shaped telemetry - * format. - * - * @return the telemetry event - */ - public GitHubTelemetryEvent getEvent() { - return event; - } - - /** - * Gets whether this is a restricted telemetry event (cli.restricted_telemetry). - * Hosts must route restricted events to first-party Microsoft stores only. - * - * @return {@code true} if the event is restricted - */ - public boolean isRestricted() { - return restricted; - } - - /** - * Gets the session the telemetry event belongs to. - * - * @return the session ID - */ - public String getSessionId() { - return sessionId; - } -} diff --git a/java/src/test/java/com/github/copilot/GitHubTelemetryTest.java b/java/src/test/java/com/github/copilot/GitHubTelemetryTest.java index cfb443dba6..b785d2ac38 100644 --- a/java/src/test/java/com/github/copilot/GitHubTelemetryTest.java +++ b/java/src/test/java/com/github/copilot/GitHubTelemetryTest.java @@ -23,8 +23,8 @@ import org.junit.jupiter.api.Test; import com.fasterxml.jackson.databind.JsonNode; +import com.github.copilot.generated.rpc.GitHubTelemetryNotification; import com.github.copilot.rpc.CopilotClientOptions; -import com.github.copilot.rpc.GitHubTelemetryNotification; import com.github.copilot.rpc.PermissionHandler; import com.github.copilot.rpc.ResumeSessionConfig; import com.github.copilot.rpc.SessionConfig; @@ -104,28 +104,28 @@ void adapterDispatchesNotificationToHandlerWithTypedPayload() throws Exception { writeRpcMessage(pair.serverSide().getOutputStream(), notification); GitHubTelemetryNotification result = received.get(5, TimeUnit.SECONDS); - assertEquals("sess-123", result.getSessionId()); - assertTrue(result.isRestricted()); + assertEquals("sess-123", result.sessionId()); + assertTrue(result.restricted()); - var event = result.getEvent(); + var event = result.event(); assertNotNull(event); - assertEquals("tool_call_executed", event.getKind()); - assertEquals("2024-01-01T00:00:00Z", event.getCreatedAt()); - assertEquals("call-9", event.getModelCallId()); - assertEquals("shell", event.getProperties().get("tool")); - assertEquals(42.5, event.getMetrics().get("duration_ms")); - assertEquals("ctx", event.getExpAssignmentContext()); - assertEquals("on", event.getFeatures().get("flag_a")); - assertEquals("sess-123", event.getSessionId()); - assertEquals("track-1", event.getCopilotTrackingId()); - - var client = event.getClient(); + assertEquals("tool_call_executed", event.kind()); + assertEquals("2024-01-01T00:00:00Z", event.createdAt()); + assertEquals("call-9", event.modelCallId()); + assertEquals("shell", event.properties().get("tool")); + assertEquals(42.5, event.metrics().get("duration_ms")); + assertEquals("ctx", event.expAssignmentContext()); + assertEquals("on", event.features().get("flag_a")); + assertEquals("sess-123", event.sessionId()); + assertEquals("track-1", event.copilotTrackingId()); + + var client = event.client(); assertNotNull(client); - assertEquals("1.2.3", client.getCliVersion()); - assertEquals("win32", client.getOsPlatform()); - assertEquals("x64", client.getOsArch()); - assertEquals("20.0.0", client.getNodeVersion()); - assertEquals(Boolean.FALSE, client.getIsStaff()); + assertEquals("1.2.3", client.cliVersion()); + assertEquals("win32", client.osPlatform()); + assertEquals("x64", client.osArch()); + assertEquals("20.0.0", client.nodeVersion()); + assertEquals(Boolean.FALSE, client.isStaff()); } } @@ -151,9 +151,9 @@ void clientOptsSessionsIntoForwardingAndReceivesEvents() throws Exception { server.sendTelemetry(Map.of("sessionId", "sess-xyz", "restricted", false, "event", Map.of("kind", "session_started", "session_id", "sess-xyz"))); GitHubTelemetryNotification event = received.get(5, TimeUnit.SECONDS); - assertEquals("sess-xyz", event.getSessionId()); - assertFalse(event.isRestricted()); - assertEquals("session_started", event.getEvent().getKind()); + assertEquals("sess-xyz", event.sessionId()); + assertFalse(event.restricted()); + assertEquals("session_started", event.event().kind()); // Resuming a session must opt it in as well. client.resumeSession("resume-1", From d7fac924d715979fc917ebb9d3e45dc99e356b4a Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Wed, 1 Jul 2026 09:27:39 -0700 Subject: [PATCH 17/26] codegen: restore notification dispatch for gitHubTelemetry.event The clientGlobal method gitHubTelemetry.event is a JSON-RPC notification (schema notification: true, void result). Restore the codegen notification-flag machinery so TypeScript/Python/Go emit notification registration (onNotification / notification-handler table / nil-result SetRequestHandler) instead of request-style onRequest dispatch, and regenerate the affected bindings. Also restore the Python _jsonrpc notification registry and fix the Go adapter to return only an error. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- go/client.go | 4 ++-- go/rpc/zrpc.go | 13 ++++------ nodejs/src/generated/rpc.ts | 6 ++--- python/copilot/_jsonrpc.py | 42 +++++++++++++++++++++++++++++++-- python/copilot/generated/rpc.py | 6 ++--- scripts/codegen/go.ts | 24 +++++++++++++++++++ scripts/codegen/python.ts | 14 +++++++++++ scripts/codegen/typescript.ts | 19 ++++++++++++++- scripts/codegen/utils.ts | 1 + 9 files changed, 109 insertions(+), 20 deletions(-) diff --git a/go/client.go b/go/client.go index 3cf0320650..1bbf615d7b 100644 --- a/go/client.go +++ b/go/client.go @@ -2086,10 +2086,10 @@ type gitHubTelemetryAdapter struct { callback func(notification *rpc.GitHubTelemetryNotification) } -func (a *gitHubTelemetryAdapter) Event(request *rpc.GitHubTelemetryNotification) (*rpc.GitHubTelemetryEventResult, error) { +func (a *gitHubTelemetryAdapter) Event(request *rpc.GitHubTelemetryNotification) error { defer func() { recover() }() // Ignore handler panics a.callback(request) - return &rpc.GitHubTelemetryEventResult{}, nil + return nil } func (c *Client) handleSessionEvent(req sessionEventRequest) { diff --git a/go/rpc/zrpc.go b/go/rpc/zrpc.go index 03cec0dc3c..7a021c0ab7 100644 --- a/go/rpc/zrpc.go +++ b/go/rpc/zrpc.go @@ -18721,7 +18721,7 @@ type GitHubTelemetryHandler interface { // Parameters: Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry // event the runtime forwards to a host connection that opted into telemetry forwarding for // the session. - Event(request *GitHubTelemetryNotification) (*GitHubTelemetryEventResult, error) + Event(request *GitHubTelemetryNotification) error } // Experimental: LlmInferenceHandler contains experimental APIs that may change or be @@ -18784,17 +18784,12 @@ func RegisterClientGlobalAPIHandlers(client *jsonrpc2.Client, handlers *ClientGl return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("Invalid params: %v", err)} } if handlers == nil || handlers.GitHubTelemetry == nil { - return nil, &jsonrpc2.Error{Code: -32603, Message: "No gitHubTelemetry client-global handler registered"} + return nil, nil } - result, err := handlers.GitHubTelemetry.Event(&request) - if err != nil { + if err := handlers.GitHubTelemetry.Event(&request); err != nil { return nil, clientGlobalHandlerError(err) } - raw, err := json.Marshal(result) - if err != nil { - return nil, &jsonrpc2.Error{Code: -32603, Message: fmt.Sprintf("Failed to marshal response: %v", err)} - } - return raw, nil + return nil, nil }) client.SetRequestHandler("llmInference.httpRequestChunk", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { var request LlmInferenceHTTPRequestChunkRequest diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 79991dae9e..02c6f19e55 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -17151,9 +17151,9 @@ export function registerClientGlobalApiHandlers( if (!handler) throw new Error("No llmInference client-global handler registered"); return handler.httpRequestChunk(params); }); - connection.onRequest("gitHubTelemetry.event", async (params: GitHubTelemetryNotification) => { + connection.onNotification("gitHubTelemetry.event", async (params: GitHubTelemetryNotification) => { const handler = handlers.gitHubTelemetry; - if (!handler) throw new Error("No gitHubTelemetry client-global handler registered"); - return handler.event(params); + if (!handler) return; + await handler.event(params); }); } diff --git a/python/copilot/_jsonrpc.py b/python/copilot/_jsonrpc.py index a58908d08d..ed70e4e8d0 100644 --- a/python/copilot/_jsonrpc.py +++ b/python/copilot/_jsonrpc.py @@ -80,6 +80,7 @@ def __init__(self, process): self.pending_requests: dict[str, asyncio.Future] = {} self._pending_inline_callbacks: dict[str, Callable[[Any], None]] = {} self.notification_handler: Callable[[str, dict], None] | None = None + self.notification_method_handlers: dict[str, Callable[[dict], Any]] = {} self.request_handlers: dict[str, RequestHandler] = {} self._running = False self._read_thread: threading.Thread | None = None @@ -232,6 +233,19 @@ def set_notification_handler(self, handler: Callable[[str, dict], None]): """Set the handler for incoming notifications from the server.""" self.notification_handler = handler + def set_notification_method_handler(self, method: str, handler: Callable[[dict], Any] | None): + """Register a handler for a specific server-to-client notification method. + + Notifications carry no ``id`` and expect no response, so they are + dispatched separately from request handlers. A registered method + handler takes precedence over the generic notification handler. The + handler may be a coroutine function; its result is awaited. + """ + if handler is None: + self.notification_method_handlers.pop(method, None) + else: + self.notification_method_handlers[method] = handler + def set_request_handler(self, method: str, handler: RequestHandler): if handler is None: self.request_handlers.pop(method, None) @@ -397,9 +411,14 @@ def _handle_message(self, message: dict): # Check if it's a notification from the server if "method" in message and "id" not in message: + method = message["method"] + params = message.get("params", {}) + handler = self.notification_method_handlers.get(method) + if handler is not None and self._loop: + # Method-specific notification handler takes precedence. + self._loop.call_soon_threadsafe(self._dispatch_notification, handler, params) + return if self.notification_handler and self._loop: - method = message["method"] - params = message.get("params", {}) # Schedule notification handler on the event loop for thread safety self._loop.call_soon_threadsafe(self.notification_handler, method, params) return @@ -427,6 +446,25 @@ def _handle_request(self, message: dict): self._loop, ) + def _dispatch_notification(self, handler: Callable[[dict], Any], params: dict): + """Invoke a method-specific notification handler. Runs on the event loop; + coroutine results are scheduled and any error is logged (notifications + carry no response, so failures never propagate to the server).""" + try: + outcome = handler(params) + except Exception: # pylint: disable=broad-except + logger.warning("Notification handler raised", exc_info=True) + return + if inspect.isawaitable(outcome): + + async def _await_outcome(): + try: + await outcome + except Exception: # pylint: disable=broad-except + logger.warning("Notification handler raised", exc_info=True) + + asyncio.create_task(_await_outcome()) + async def _dispatch_request(self, message: dict, handler: RequestHandler): try: params = message.get("params", {}) diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index 89b724ce9c..72e1023b1b 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -27096,13 +27096,13 @@ async def handle_llm_inference_http_request_chunk(params: dict) -> dict | None: result = await handler.http_request_chunk(request) return result.to_dict() client.set_request_handler("llmInference.httpRequestChunk", handle_llm_inference_http_request_chunk) - async def handle_git_hub_telemetry_event(params: dict) -> dict | None: + async def handle_git_hub_telemetry_event(params: dict) -> None: request = GitHubTelemetryNotification.from_dict(params) handler = handlers.git_hub_telemetry - if handler is None: raise RuntimeError("No git_hub_telemetry client-global handler registered") + if handler is None: return None await handler.event(request) return None - client.set_request_handler("gitHubTelemetry.event", handle_git_hub_telemetry_event) + client.set_notification_method_handler("gitHubTelemetry.event", handle_git_hub_telemetry_event) __all__ = [ "APIKeyAuthInfo", diff --git a/scripts/codegen/go.ts b/scripts/codegen/go.ts index 57957499e2..1e6cf0a42c 100644 --- a/scripts/codegen/go.ts +++ b/scripts/codegen/go.ts @@ -4385,6 +4385,11 @@ function emitClientGlobalApiRegistration(lines: string[], clientSchema: Record None:`); + lines.push(` request = ${paramsType}.from_dict(params)`); + lines.push(` handler = handlers.${handlerField}`); + lines.push(` if handler is None: return None`); + lines.push(` await handler.${handlerMethod}(request)`); + lines.push(` return None`); + lines.push(` client.set_notification_method_handler("${method.rpcMethod}", ${handlerVariableName})`); + return; + } + lines.push(` async def ${handlerVariableName}(params: dict) -> dict | None:`); lines.push(` request = ${paramsType}.from_dict(params)`); lines.push(` handler = handlers.${handlerField}`); diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index 1303a4979c..497c909ea5 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -1011,7 +1011,24 @@ function emitClientGlobalApiRegistration(clientSchema: Record): const pType = paramsTypeName(method); const hasParams = hasSchemaPayload(getMethodParamsSchema(method)); - if (hasParams) { + if (method.notification) { + // Notification methods carry no response; the server dispatches + // them via `sendNotification`, which only fires `onNotification` + // handlers (an `onRequest` handler would never be invoked). + if (hasParams) { + lines.push(` connection.onNotification("${method.rpcMethod}", async (params: ${pType}) => {`); + lines.push(` const handler = handlers.${groupName};`); + lines.push(` if (!handler) return;`); + lines.push(` await handler.${name}(params);`); + lines.push(` });`); + } else { + lines.push(` connection.onNotification("${method.rpcMethod}", async () => {`); + lines.push(` const handler = handlers.${groupName};`); + lines.push(` if (!handler) return;`); + lines.push(` await handler.${name}();`); + lines.push(` });`); + } + } else if (hasParams) { lines.push(` connection.onRequest("${method.rpcMethod}", async (params: ${pType}) => {`); lines.push(` const handler = handlers.${groupName};`); lines.push(` if (!handler) throw new Error("No ${groupName} client-global handler registered");`); diff --git a/scripts/codegen/utils.ts b/scripts/codegen/utils.ts index c63f9732c4..9ab335b05f 100644 --- a/scripts/codegen/utils.ts +++ b/scripts/codegen/utils.ts @@ -383,6 +383,7 @@ export interface RpcMethod { stability?: string; visibility?: string; deprecated?: boolean; + notification?: boolean; } export function getRpcSchemaTypeName(schema: JSONSchema7 | null | undefined, fallback: string): string { From fa3fccbb74dcd5d79c408b720fdc3b8cc6d1a083 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Wed, 1 Jul 2026 09:27:49 -0700 Subject: [PATCH 18/26] test: guard gitHubTelemetry.event notification-style dispatch Send an id-less JSON-RPC notification (not a request) through the real wire in the Node and Python unit tests so a regression back to onRequest dispatch is caught. Add a Node E2E that creates a forwarding-enabled session against a live CLI and asserts a gitHubTelemetry.event notification is delivered to the onGitHubTelemetry handler. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- nodejs/test/client.test.ts | 9 ++-- nodejs/test/e2e/github_telemetry.e2e.test.ts | 55 ++++++++++++++++++++ python/test_client.py | 40 +++++++++----- 3 files changed, 88 insertions(+), 16 deletions(-) create mode 100644 nodejs/test/e2e/github_telemetry.e2e.test.ts diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index b8d641673d..b174494548 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -557,9 +557,12 @@ describe("CopilotClient", () => { }, }; - // Deliver the event over the real wire and confirm the generated - // dispatcher routes it to the registered handler. - await serverConn.sendRequest("gitHubTelemetry.event", notification); + // Deliver the event as a real JSON-RPC *notification* (no id) and confirm + // the generated dispatcher routes it to the registered handler. The runtime + // forwards telemetry via `sendNotification`, which only fires `onNotification` + // handlers — an `onRequest` registration would never be invoked, so sending a + // notification here guards against regressing back to request-style dispatch. + serverConn.sendNotification("gitHubTelemetry.event", notification); await got; expect(received).toEqual([notification]); diff --git a/nodejs/test/e2e/github_telemetry.e2e.test.ts b/nodejs/test/e2e/github_telemetry.e2e.test.ts new file mode 100644 index 0000000000..babe523ca9 --- /dev/null +++ b/nodejs/test/e2e/github_telemetry.e2e.test.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "vitest"; +import { approveAll, GitHubTelemetryNotification } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +// Experimental: exercises the end-to-end GitHub (hydro) telemetry forwarding +// path. The runtime forwards per-session telemetry to opted-in connections via +// the `gitHubTelemetry.event` JSON-RPC *notification*; the SDK opts in +// automatically whenever an `onGitHubTelemetry` handler is registered. Creating +// a session emits an early `session.start` hydro event, so no model round-trip +// (and therefore no recorded CAPI exchange) is needed to observe forwarding. +describe("GitHub telemetry forwarding", async () => { + const received: GitHubTelemetryNotification[] = []; + + const { copilotClient: client } = await createSdkTestContext({ + copilotClientOptions: { + onGitHubTelemetry: (notification) => { + received.push(notification); + }, + }, + }); + + it( + "forwards gitHubTelemetry.event notifications from a live session", + { timeout: 60_000 }, + async () => { + received.length = 0; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + }); + + // Telemetry forwarding is asynchronous and in-process; poll until the + // runtime has forwarded at least one event or we time out. + const deadline = Date.now() + 30_000; + while (received.length === 0 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + expect(received.length).toBeGreaterThan(0); + + const notification = received[0]; + expect(typeof notification.sessionId).toBe("string"); + expect(notification.sessionId.length).toBeGreaterThan(0); + expect(typeof notification.restricted).toBe("boolean"); + expect(notification.event).toBeDefined(); + expect(typeof notification.event.kind).toBe("string"); + + await session.disconnect(); + } + ); +}); diff --git a/python/test_client.py b/python/test_client.py index 7a3f4bf7b2..dcf88bd12f 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -2397,25 +2397,38 @@ def on_telemetry(notification): await client.start() try: - # The generated client-global dispatcher wires gitHubTelemetry.event - # into the request-handler table; invoking it exercises the full - # from_dict decode + adapter + user-callback path. - assert "gitHubTelemetry.event" in client._client.request_handlers + # gitHubTelemetry.event is a JSON-RPC *notification*: the generated + # client-global dispatcher wires it into the notification-handler + # table, never the request-handler table. Regressing to request-style + # dispatch would drop the runtime's id-less telemetry frames. + assert "gitHubTelemetry.event" in client._client.notification_method_handlers + assert "gitHubTelemetry.event" not in client._client.request_handlers - handler = client._client.request_handlers["gitHubTelemetry.event"] - await handler( + # Drive a real id-less notification frame through the dispatcher to + # exercise the full from_dict decode + adapter + user-callback path. + client._client._handle_message( { - "sessionId": "sess-telemetry", - "restricted": True, - "event": { - "kind": "tool_call_executed", - "metrics": {"duration_ms": 12.5}, - "properties": {"tool": "shell"}, - "session_id": "sess-telemetry", + "jsonrpc": "2.0", + "method": "gitHubTelemetry.event", + "params": { + "sessionId": "sess-telemetry", + "restricted": True, + "event": { + "kind": "tool_call_executed", + "metrics": {"duration_ms": 12.5}, + "properties": {"tool": "shell"}, + "session_id": "sess-telemetry", + }, }, } ) + # Notifications dispatch onto the event loop; yield until delivered. + for _ in range(100): + if received: + break + await asyncio.sleep(0.01) + assert len(received) == 1 notification = received[0] assert isinstance(notification, GitHubTelemetryNotification) @@ -2433,6 +2446,7 @@ async def test_event_handler_not_registered_without_option(self): await client.start() try: + assert "gitHubTelemetry.event" not in client._client.notification_method_handlers assert "gitHubTelemetry.event" not in client._client.request_handlers finally: await client.force_stop() From e7d173e555f8e35520bd307caecdf6cea71917d9 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Wed, 1 Jul 2026 09:41:24 -0700 Subject: [PATCH 19/26] test: clarify telemetry forwarding comment in e2e Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- nodejs/test/e2e/github_telemetry.e2e.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nodejs/test/e2e/github_telemetry.e2e.test.ts b/nodejs/test/e2e/github_telemetry.e2e.test.ts index babe523ca9..9ab297d5c1 100644 --- a/nodejs/test/e2e/github_telemetry.e2e.test.ts +++ b/nodejs/test/e2e/github_telemetry.e2e.test.ts @@ -33,8 +33,9 @@ describe("GitHub telemetry forwarding", async () => { onPermissionRequest: approveAll, }); - // Telemetry forwarding is asynchronous and in-process; poll until the - // runtime has forwarded at least one event or we time out. + // The CLI forwards telemetry over the JSON-RPC connection + // asynchronously, so poll until at least one event arrives or we + // time out. const deadline = Date.now() + 30_000; while (received.length === 0 && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 100)); From 52099d1251b4cee7ede84515cabc4c178651372e Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Wed, 1 Jul 2026 09:45:02 -0700 Subject: [PATCH 20/26] test: use shared waitForCondition helper in telemetry e2e Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- nodejs/test/e2e/github_telemetry.e2e.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/nodejs/test/e2e/github_telemetry.e2e.test.ts b/nodejs/test/e2e/github_telemetry.e2e.test.ts index 9ab297d5c1..c8e841a355 100644 --- a/nodejs/test/e2e/github_telemetry.e2e.test.ts +++ b/nodejs/test/e2e/github_telemetry.e2e.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { approveAll, GitHubTelemetryNotification } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; +import { waitForCondition } from "./harness/sdkTestHelper.js"; // Experimental: exercises the end-to-end GitHub (hydro) telemetry forwarding // path. The runtime forwards per-session telemetry to opted-in connections via @@ -34,12 +35,12 @@ describe("GitHub telemetry forwarding", async () => { }); // The CLI forwards telemetry over the JSON-RPC connection - // asynchronously, so poll until at least one event arrives or we + // asynchronously, so wait until at least one event arrives or we // time out. - const deadline = Date.now() + 30_000; - while (received.length === 0 && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 100)); - } + await waitForCondition(() => received.length > 0, { + timeoutMs: 30_000, + timeoutMessage: "Timed out waiting for a gitHubTelemetry.event notification.", + }); expect(received.length).toBeGreaterThan(0); From b2a2567c3fdb22ed4aea135a1dd9a31ca85d570f Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Wed, 1 Jul 2026 10:24:40 -0700 Subject: [PATCH 21/26] test: add live gitHubTelemetry forwarding E2E for all languages Adds a live-CLI E2E in Go, Python, .NET, Rust, and Java that registers an onGitHubTelemetry callback, creates a session, and asserts at least one gitHubTelemetry.event notification is forwarded with a well-typed payload. This brings the other SDKs to parity with the existing nodejs E2E. Session creation emits an early session.start hydro event, so no model round-trip (and no recorded CAPI exchange) is needed; the tests are snapshot-free. The Rust change also refactors the E2E harness to run a native COPILOT_CLI_PATH binary directly (non-.js), leaving the node_modules index.js path unchanged for CI. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../E2E/GitHubTelemetryForwardingE2ETests.cs | 59 ++++++++++++++++ go/internal/e2e/github_telemetry_e2e_test.go | 68 +++++++++++++++++++ .../copilot/GitHubTelemetryForwardingIT.java | 58 ++++++++++++++++ python/e2e/test_github_telemetry_e2e.py | 57 ++++++++++++++++ rust/tests/e2e.rs | 2 + rust/tests/e2e/github_telemetry.rs | 60 ++++++++++++++++ rust/tests/e2e/support.rs | 36 ++++++---- 7 files changed, 328 insertions(+), 12 deletions(-) create mode 100644 dotnet/test/E2E/GitHubTelemetryForwardingE2ETests.cs create mode 100644 go/internal/e2e/github_telemetry_e2e_test.go create mode 100644 java/src/test/java/com/github/copilot/GitHubTelemetryForwardingIT.java create mode 100644 python/e2e/test_github_telemetry_e2e.py create mode 100644 rust/tests/e2e/github_telemetry.rs diff --git a/dotnet/test/E2E/GitHubTelemetryForwardingE2ETests.cs b/dotnet/test/E2E/GitHubTelemetryForwardingE2ETests.cs new file mode 100644 index 0000000000..85a83aee05 --- /dev/null +++ b/dotnet/test/E2E/GitHubTelemetryForwardingE2ETests.cs @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using System.Collections.Concurrent; +using GitHub.Copilot.Rpc; +using GitHub.Copilot.Test.Harness; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.Test.E2E; + +#pragma warning disable GHCP001 // GitHub telemetry forwarding is experimental. + +public class GitHubTelemetryForwardingE2ETests(E2ETestFixture fixture, ITestOutputHelper output) + : E2ETestBase(fixture, "github_telemetry", output) +{ + [Fact] + public async Task Should_Forward_GitHub_Telemetry_For_A_Live_Session() + { + var notifications = new ConcurrentQueue(); + + await using var client = Ctx.CreateClient(options: new CopilotClientOptions + { + OnGitHubTelemetry = notifications.Enqueue, + }); + + CopilotSession? session = null; + try + { + session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + await TestHelper.WaitForConditionAsync( + () => !notifications.IsEmpty, + timeout: TimeSpan.FromSeconds(30), + timeoutMessage: "Timed out waiting for GitHub telemetry notification."); + + Assert.True(notifications.TryPeek(out var notification)); + Assert.NotEmpty(notification.SessionId); + Assert.NotNull(notification.Event); + Assert.NotEmpty(notification.Event.Kind); + Assert.IsType(notification.Restricted); + } + finally + { + if (session is not null) + { + await session.DisposeAsync(); + } + + await client.StopAsync(); + } + } +} + +#pragma warning restore GHCP001 diff --git a/go/internal/e2e/github_telemetry_e2e_test.go b/go/internal/e2e/github_telemetry_e2e_test.go new file mode 100644 index 0000000000..666817451e --- /dev/null +++ b/go/internal/e2e/github_telemetry_e2e_test.go @@ -0,0 +1,68 @@ +package e2e + +import ( + "sync" + "testing" + "time" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" + "github.com/github/copilot-sdk/go/rpc" +) + +func TestGitHubTelemetryE2E(t *testing.T) { + t.Run("should forward github telemetry for a live session", func(t *testing.T) { + ctx := testharness.NewTestContext(t) + ctx.ConfigureForTest(t) + + var mu sync.Mutex + var notifications []*rpc.GitHubTelemetryNotification + client := ctx.NewClient(func(opts *copilot.ClientOptions) { + opts.OnGitHubTelemetry = func(notification *rpc.GitHubTelemetryNotification) { + mu.Lock() + notifications = append(notifications, notification) + mu.Unlock() + } + }) + t.Cleanup(func() { client.ForceStop() }) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + t.Cleanup(func() { session.Disconnect() }) + + notification := waitForGitHubTelemetryNotification(t, &mu, ¬ifications, 30*time.Second) + if notification.SessionID == "" { + t.Fatal("Expected a non-empty SessionID") + } + if notification.Event.Kind == "" { + t.Fatal("Expected a non-empty Event.Kind") + } + }) +} + +func waitForGitHubTelemetryNotification(t *testing.T, mu *sync.Mutex, notifications *[]*rpc.GitHubTelemetryNotification, timeout time.Duration) *rpc.GitHubTelemetryNotification { + t.Helper() + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + mu.Lock() + if len(*notifications) > 0 { + notification := (*notifications)[0] + mu.Unlock() + if notification != nil { + return notification + } + t.Fatal("Received nil GitHub telemetry notification") + } + mu.Unlock() + + time.Sleep(50 * time.Millisecond) + } + + t.Fatalf("Timed out waiting for GitHub telemetry notification after %s", timeout) + return nil +} diff --git a/java/src/test/java/com/github/copilot/GitHubTelemetryForwardingIT.java b/java/src/test/java/com/github/copilot/GitHubTelemetryForwardingIT.java new file mode 100644 index 0000000000..4cfd89b7ce --- /dev/null +++ b/java/src/test/java/com/github/copilot/GitHubTelemetryForwardingIT.java @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import com.github.copilot.generated.rpc.GitHubTelemetryNotification; +import com.github.copilot.rpc.CopilotClientOptions; +import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.SessionConfig; + +/** + * Failsafe integration test that verifies the live CLI forwards GitHub + * telemetry notifications during session creation. + */ +@AllowCopilotExperimental +class GitHubTelemetryForwardingIT { + + @Test + void forwardsGitHubTelemetryForALiveSession() throws Exception { + var notifications = new CopyOnWriteArrayList(); + var firstNotification = new CompletableFuture(); + + try (E2ETestContext ctx = E2ETestContext.create()) { + var options = new CopilotClientOptions().setOnGitHubTelemetry(notification -> { + notifications.add(notification); + firstNotification.complete(notification); + }); + + try (CopilotClient client = ctx.createClient(options); + CopilotSession session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) + .get(30, TimeUnit.SECONDS)) { + + GitHubTelemetryNotification notification = firstNotification.get(30, TimeUnit.SECONDS); + + assertFalse(notifications.isEmpty(), "Expected at least one GitHub telemetry notification"); + assertNotNull(notification, "Expected a GitHub telemetry notification"); + assertNotNull(notification.sessionId(), "Telemetry notification sessionId must be present"); + assertTrue(!notification.sessionId().isBlank(), "Telemetry notification sessionId must be non-empty"); + assertNotNull(notification.restricted(), "Telemetry notification restricted flag must be present"); + assertNotNull(notification.event(), "Telemetry notification event must be present"); + assertNotNull(notification.event().kind(), "Telemetry event kind must be present"); + assertTrue(!notification.event().kind().isBlank(), "Telemetry event kind must be non-empty"); + } + } + } +} diff --git a/python/e2e/test_github_telemetry_e2e.py b/python/e2e/test_github_telemetry_e2e.py new file mode 100644 index 0000000000..976b0b616e --- /dev/null +++ b/python/e2e/test_github_telemetry_e2e.py @@ -0,0 +1,57 @@ +"""Live CLI E2E coverage for forwarded GitHub telemetry notifications.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from copilot import CopilotClient, GitHubTelemetryNotification, RuntimeConnection +from copilot.session import PermissionHandler + +from .testharness import DEFAULT_GITHUB_TOKEN, E2ETestContext +from .testharness.context import get_cli_path_for_tests + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +class TestGitHubTelemetryE2E: + async def test_should_receive_session_start_github_telemetry(self, ctx: E2ETestContext): + received: list[GitHubTelemetryNotification] = [] + + def on_github_telemetry(notification: GitHubTelemetryNotification) -> None: + received.append(notification) + + client = CopilotClient( + connection=RuntimeConnection.for_stdio(path=get_cli_path_for_tests(), args=()), + working_directory=ctx.work_dir, + env=ctx.get_env(), + github_token=DEFAULT_GITHUB_TOKEN, + on_github_telemetry=on_github_telemetry, + ) + + session = None + try: + await client.start() + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + + for _ in range(600): + if received: + break + await asyncio.sleep(0.05) + + assert received + notification = received[0] + assert isinstance(notification.session_id, str) + assert notification.session_id + assert isinstance(notification.restricted, bool) + assert notification.event is not None + assert isinstance(notification.event.kind, str) + finally: + try: + if session is not None: + await session.disconnect() + finally: + await client.stop() diff --git a/rust/tests/e2e.rs b/rust/tests/e2e.rs index 79059c7f28..62412963b8 100644 --- a/rust/tests/e2e.rs +++ b/rust/tests/e2e.rs @@ -31,6 +31,8 @@ mod elicitation; mod error_resilience; #[path = "e2e/event_fidelity.rs"] mod event_fidelity; +#[path = "e2e/github_telemetry.rs"] +mod github_telemetry; #[path = "e2e/hooks.rs"] mod hooks; #[path = "e2e/hooks_extended.rs"] diff --git a/rust/tests/e2e/github_telemetry.rs b/rust/tests/e2e/github_telemetry.rs new file mode 100644 index 0000000000..26e2a3f94b --- /dev/null +++ b/rust/tests/e2e/github_telemetry.rs @@ -0,0 +1,60 @@ +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use github_copilot_sdk::github_telemetry::GitHubTelemetryNotification; +use github_copilot_sdk::handler::ApproveAllHandler; +use github_copilot_sdk::{Client, SessionConfig}; + +use super::support::{DEFAULT_TEST_TOKEN, with_e2e_context_no_snapshot}; + +#[tokio::test] +async fn should_forward_github_telemetry_on_session_create() { + with_e2e_context_no_snapshot(|ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + + let notifications = Arc::new(Mutex::new(Vec::::new())); + let collected = notifications.clone(); + let client = Client::start(ctx.client_options().with_on_github_telemetry(move |n| { + collected.lock().unwrap().push(n); + })) + .await + .expect("start client"); + let session = client + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_permission_handler(Arc::new(ApproveAllHandler)), + ) + .await + .expect("create session"); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + loop { + if !notifications.lock().unwrap().is_empty() { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "timed out waiting for github telemetry notification" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } + + { + let notifications = notifications.lock().unwrap(); + assert!(!notifications.is_empty()); + let first = notifications + .first() + .expect("github telemetry notification"); + assert!(!first.session_id.is_empty()); + let _: bool = first.restricted; + assert!(!first.event.kind.is_empty()); + } + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} diff --git a/rust/tests/e2e/support.rs b/rust/tests/e2e/support.rs index 5052ef1be4..7e47d8fbae 100644 --- a/rust/tests/e2e/support.rs +++ b/rust/tests/e2e/support.rs @@ -157,12 +157,7 @@ impl E2eContext { } pub fn client_options(&self) -> ClientOptions { - ClientOptions::new() - .with_program(CliProgram::Path(PathBuf::from(node_program()))) - .with_prefix_args([self.cli_path.as_os_str().to_owned()]) - .with_cwd(self.work_dir.path()) - .with_env(self.environment()) - .with_use_logged_in_user(false) + client_options_for_cli(&self.cli_path, self.work_dir.path(), self.environment()) } pub fn client_options_with_transport(&self, transport: Transport) -> ClientOptions { @@ -188,12 +183,7 @@ impl E2eContext { .iter() .map(|(key, value)| (OsString::from(*key), OsString::from(*value))), ); - let options = ClientOptions::new() - .with_program(CliProgram::Path(PathBuf::from(node_program()))) - .with_prefix_args([self.cli_path.as_os_str().to_owned()]) - .with_cwd(self.work_dir.path()) - .with_env(env) - .with_use_logged_in_user(false) + let options = client_options_for_cli(&self.cli_path, self.work_dir.path(), env) .with_request_handler(handler); Client::start(options).await.expect("start E2E LLM client") } @@ -627,6 +617,28 @@ fn cli_path(repo_root: &Path) -> std::io::Result { )) } +fn client_options_for_cli( + cli_path: &Path, + cwd: &Path, + env: Vec<(OsString, OsString)>, +) -> ClientOptions { + let options = ClientOptions::new() + .with_cwd(cwd) + .with_env(env) + .with_use_logged_in_user(false); + if cli_path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("js")) + { + options + .with_program(CliProgram::Path(PathBuf::from(node_program()))) + .with_prefix_args([cli_path.as_os_str().to_owned()]) + } else { + options.with_program(CliProgram::Path(cli_path.to_path_buf())) + } +} + fn canonical_temp_path(path: &Path) -> PathBuf { std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } From 8c40c1dd9490e8e44c40439f9ab193946377df0b Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Wed, 1 Jul 2026 10:47:01 -0700 Subject: [PATCH 22/26] fix: guard gitHubTelemetry callbacks in Python and .NET adapters Wrap the user-provided on_github_telemetry / OnGitHubTelemetry callback invocation in exception handling so a throwing handler cannot propagate into the JSON-RPC dispatch machinery. Mirrors the existing guards in the Node, Go, Java, and Rust adapters. Errors are logged (Python module logger; .NET ILogger, falling back to NullLogger.Instance) rather than silently swallowed, matching the Java (SEVERE) and Rust (warn) behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 14 +++++++++++--- python/copilot/client.py | 5 ++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 19bf4979c4..a1ae0f91dc 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1744,7 +1744,7 @@ await Rpc.SessionFs.SetProviderAsync( return new ClientGlobalApiHandlers { LlmInference = handler is null ? null : new LlmInferenceAdapter(handler, () => _serverRpc), - GitHubTelemetry = onGitHubTelemetry is null ? null : new GitHubTelemetryAdapter(onGitHubTelemetry), + GitHubTelemetry = onGitHubTelemetry is null ? null : new GitHubTelemetryAdapter(onGitHubTelemetry, _logger), }; } @@ -2728,14 +2728,22 @@ public sealed class ToolResultAIContent(ToolResultObject toolResult) : AIContent /// payload unchanged. ///

[Experimental(Diagnostics.Experimental)] -internal sealed class GitHubTelemetryAdapter(Action callback) : Rpc.IGitHubTelemetryHandler +internal sealed class GitHubTelemetryAdapter(Action callback, ILogger logger) : Rpc.IGitHubTelemetryHandler { private readonly Action _callback = callback ?? throw new ArgumentNullException(nameof(callback)); + private readonly ILogger _logger = logger ?? NullLogger.Instance; public Task EventAsync(Rpc.GitHubTelemetryNotification request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); - _callback(request); + try + { + _callback(request); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error handling gitHubTelemetry.event notification"); + } return Task.CompletedTask; } } diff --git a/python/copilot/client.py b/python/copilot/client.py index 760aaa4c08..90c142a12f 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -409,7 +409,10 @@ def __init__(self, callback: Callable[[GitHubTelemetryNotification], None]) -> N self._callback = callback async def event(self, params: GitHubTelemetryNotification) -> None: - self._callback(params) + try: + self._callback(params) + except Exception: + logger.exception("Error handling gitHubTelemetry.event notification") @dataclass From 086b20da59d2a1d9978979224c59dc003c91fdec Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Wed, 1 Jul 2026 11:11:51 -0700 Subject: [PATCH 23/26] test: normalize telemetry E2E line endings Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../copilot/GitHubTelemetryForwardingIT.java | 116 +++++++++--------- nodejs/test/e2e/github_telemetry.e2e.test.ts | 114 ++++++++--------- 2 files changed, 115 insertions(+), 115 deletions(-) diff --git a/java/src/test/java/com/github/copilot/GitHubTelemetryForwardingIT.java b/java/src/test/java/com/github/copilot/GitHubTelemetryForwardingIT.java index 4cfd89b7ce..0cc1695350 100644 --- a/java/src/test/java/com/github/copilot/GitHubTelemetryForwardingIT.java +++ b/java/src/test/java/com/github/copilot/GitHubTelemetryForwardingIT.java @@ -1,58 +1,58 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - *--------------------------------------------------------------------------------------------*/ - -package com.github.copilot; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.TimeUnit; - -import org.junit.jupiter.api.Test; - -import com.github.copilot.generated.rpc.GitHubTelemetryNotification; -import com.github.copilot.rpc.CopilotClientOptions; -import com.github.copilot.rpc.PermissionHandler; -import com.github.copilot.rpc.SessionConfig; - -/** - * Failsafe integration test that verifies the live CLI forwards GitHub - * telemetry notifications during session creation. - */ -@AllowCopilotExperimental -class GitHubTelemetryForwardingIT { - - @Test - void forwardsGitHubTelemetryForALiveSession() throws Exception { - var notifications = new CopyOnWriteArrayList(); - var firstNotification = new CompletableFuture(); - - try (E2ETestContext ctx = E2ETestContext.create()) { - var options = new CopilotClientOptions().setOnGitHubTelemetry(notification -> { - notifications.add(notification); - firstNotification.complete(notification); - }); - - try (CopilotClient client = ctx.createClient(options); - CopilotSession session = client - .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) - .get(30, TimeUnit.SECONDS)) { - - GitHubTelemetryNotification notification = firstNotification.get(30, TimeUnit.SECONDS); - - assertFalse(notifications.isEmpty(), "Expected at least one GitHub telemetry notification"); - assertNotNull(notification, "Expected a GitHub telemetry notification"); - assertNotNull(notification.sessionId(), "Telemetry notification sessionId must be present"); - assertTrue(!notification.sessionId().isBlank(), "Telemetry notification sessionId must be non-empty"); - assertNotNull(notification.restricted(), "Telemetry notification restricted flag must be present"); - assertNotNull(notification.event(), "Telemetry notification event must be present"); - assertNotNull(notification.event().kind(), "Telemetry event kind must be present"); - assertTrue(!notification.event().kind().isBlank(), "Telemetry event kind must be non-empty"); - } - } - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import com.github.copilot.generated.rpc.GitHubTelemetryNotification; +import com.github.copilot.rpc.CopilotClientOptions; +import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.SessionConfig; + +/** + * Failsafe integration test that verifies the live CLI forwards GitHub + * telemetry notifications during session creation. + */ +@AllowCopilotExperimental +class GitHubTelemetryForwardingIT { + + @Test + void forwardsGitHubTelemetryForALiveSession() throws Exception { + var notifications = new CopyOnWriteArrayList(); + var firstNotification = new CompletableFuture(); + + try (E2ETestContext ctx = E2ETestContext.create()) { + var options = new CopilotClientOptions().setOnGitHubTelemetry(notification -> { + notifications.add(notification); + firstNotification.complete(notification); + }); + + try (CopilotClient client = ctx.createClient(options); + CopilotSession session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) + .get(30, TimeUnit.SECONDS)) { + + GitHubTelemetryNotification notification = firstNotification.get(30, TimeUnit.SECONDS); + + assertFalse(notifications.isEmpty(), "Expected at least one GitHub telemetry notification"); + assertNotNull(notification, "Expected a GitHub telemetry notification"); + assertNotNull(notification.sessionId(), "Telemetry notification sessionId must be present"); + assertTrue(!notification.sessionId().isBlank(), "Telemetry notification sessionId must be non-empty"); + assertNotNull(notification.restricted(), "Telemetry notification restricted flag must be present"); + assertNotNull(notification.event(), "Telemetry notification event must be present"); + assertNotNull(notification.event().kind(), "Telemetry event kind must be present"); + assertTrue(!notification.event().kind().isBlank(), "Telemetry event kind must be non-empty"); + } + } + } +} diff --git a/nodejs/test/e2e/github_telemetry.e2e.test.ts b/nodejs/test/e2e/github_telemetry.e2e.test.ts index c8e841a355..e33178f9d0 100644 --- a/nodejs/test/e2e/github_telemetry.e2e.test.ts +++ b/nodejs/test/e2e/github_telemetry.e2e.test.ts @@ -1,57 +1,57 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - *--------------------------------------------------------------------------------------------*/ - -import { describe, expect, it } from "vitest"; -import { approveAll, GitHubTelemetryNotification } from "../../src/index.js"; -import { createSdkTestContext } from "./harness/sdkTestContext.js"; -import { waitForCondition } from "./harness/sdkTestHelper.js"; - -// Experimental: exercises the end-to-end GitHub (hydro) telemetry forwarding -// path. The runtime forwards per-session telemetry to opted-in connections via -// the `gitHubTelemetry.event` JSON-RPC *notification*; the SDK opts in -// automatically whenever an `onGitHubTelemetry` handler is registered. Creating -// a session emits an early `session.start` hydro event, so no model round-trip -// (and therefore no recorded CAPI exchange) is needed to observe forwarding. -describe("GitHub telemetry forwarding", async () => { - const received: GitHubTelemetryNotification[] = []; - - const { copilotClient: client } = await createSdkTestContext({ - copilotClientOptions: { - onGitHubTelemetry: (notification) => { - received.push(notification); - }, - }, - }); - - it( - "forwards gitHubTelemetry.event notifications from a live session", - { timeout: 60_000 }, - async () => { - received.length = 0; - - const session = await client.createSession({ - onPermissionRequest: approveAll, - }); - - // The CLI forwards telemetry over the JSON-RPC connection - // asynchronously, so wait until at least one event arrives or we - // time out. - await waitForCondition(() => received.length > 0, { - timeoutMs: 30_000, - timeoutMessage: "Timed out waiting for a gitHubTelemetry.event notification.", - }); - - expect(received.length).toBeGreaterThan(0); - - const notification = received[0]; - expect(typeof notification.sessionId).toBe("string"); - expect(notification.sessionId.length).toBeGreaterThan(0); - expect(typeof notification.restricted).toBe("boolean"); - expect(notification.event).toBeDefined(); - expect(typeof notification.event.kind).toBe("string"); - - await session.disconnect(); - } - ); -}); +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "vitest"; +import { approveAll, GitHubTelemetryNotification } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; +import { waitForCondition } from "./harness/sdkTestHelper.js"; + +// Experimental: exercises the end-to-end GitHub (hydro) telemetry forwarding +// path. The runtime forwards per-session telemetry to opted-in connections via +// the `gitHubTelemetry.event` JSON-RPC *notification*; the SDK opts in +// automatically whenever an `onGitHubTelemetry` handler is registered. Creating +// a session emits an early `session.start` hydro event, so no model round-trip +// (and therefore no recorded CAPI exchange) is needed to observe forwarding. +describe("GitHub telemetry forwarding", async () => { + const received: GitHubTelemetryNotification[] = []; + + const { copilotClient: client } = await createSdkTestContext({ + copilotClientOptions: { + onGitHubTelemetry: (notification) => { + received.push(notification); + }, + }, + }); + + it( + "forwards gitHubTelemetry.event notifications from a live session", + { timeout: 60_000 }, + async () => { + received.length = 0; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + }); + + // The CLI forwards telemetry over the JSON-RPC connection + // asynchronously, so wait until at least one event arrives or we + // time out. + await waitForCondition(() => received.length > 0, { + timeoutMs: 30_000, + timeoutMessage: "Timed out waiting for a gitHubTelemetry.event notification.", + }); + + expect(received.length).toBeGreaterThan(0); + + const notification = received[0]; + expect(typeof notification.sessionId).toBe("string"); + expect(notification.sessionId.length).toBeGreaterThan(0); + expect(typeof notification.restricted).toBe("boolean"); + expect(notification.event).toBeDefined(); + expect(typeof notification.event.kind).toBe("string"); + + await session.disconnect(); + } + ); +}); From d4781da29ce653557f28ee2041fef650991e14ff Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Wed, 1 Jul 2026 11:41:06 -0700 Subject: [PATCH 24/26] fix(java): log gitHubTelemetry callback errors at WARNING not SEVERE A user-supplied onGitHubTelemetry callback throwing is a routine, recoverable condition, not a serious system failure. Level.WARNING matches the Java SDK''s own convention (CopilotSession "Error in event handler") and the .NET (LogWarning) and Rust (warn) adapters. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../main/java/com/github/copilot/GitHubTelemetryAdapter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/java/src/main/java/com/github/copilot/GitHubTelemetryAdapter.java b/java/src/main/java/com/github/copilot/GitHubTelemetryAdapter.java index b9d3db11fd..00f8fb7686 100644 --- a/java/src/main/java/com/github/copilot/GitHubTelemetryAdapter.java +++ b/java/src/main/java/com/github/copilot/GitHubTelemetryAdapter.java @@ -40,7 +40,7 @@ private void handleEvent(JsonNode params) { callback.accept(notification); } } catch (Exception e) { - LOG.log(Level.SEVERE, "Error handling gitHubTelemetry.event notification", e); + LOG.log(Level.WARNING, "Error handling gitHubTelemetry.event notification", e); } } } From 3f67d2c246604a44a66de9be04e80a2be8107926 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Wed, 1 Jul 2026 13:19:22 -0700 Subject: [PATCH 25/26] fix(python): log gitHubTelemetry callback errors at WARNING not ERROR A user-supplied on_github_telemetry callback throwing is a routine, recoverable condition, not an error. Use logger.warning(..., exc_info=True) instead of logger.exception (which logs at ERROR), matching the .NET, Java, and Rust adapters and client.py''s own convention (exc_info=True elsewhere, never logger.exception). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- python/copilot/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/copilot/client.py b/python/copilot/client.py index 90c142a12f..d1b80825a9 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -412,7 +412,7 @@ async def event(self, params: GitHubTelemetryNotification) -> None: try: self._callback(params) except Exception: - logger.exception("Error handling gitHubTelemetry.event notification") + logger.warning("Error handling gitHubTelemetry.event notification", exc_info=True) @dataclass From 7dde071957803c50d51a633304c68df5abe7ccde Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Wed, 1 Jul 2026 14:52:44 -0700 Subject: [PATCH 26/26] Make GitHub telemetry callback async in nodejs, .NET, Java, and Python The runtime-forwarded gitHubTelemetry.event callback now returns an awaitable so consumers can perform async I/O in their telemetry sink, addressing PR feedback. Signatures: - nodejs: (n) => void | Promise - .NET: Func - Java: Function> - Python: Callable[[GitHubTelemetryNotification], None | Awaitable[None]] Each adapter awaits the result and logs handler failures at WARNING. Go and Rust are intentionally left synchronous; neither SDK has an async-callback idiom. Unit and E2E call sites updated accordingly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 9 ++-- dotnet/src/Types.cs | 3 +- .../E2E/GitHubTelemetryForwardingE2ETests.cs | 6 ++- dotnet/test/Unit/GitHubTelemetryTests.cs | 16 ++++-- .../com/github/copilot/CopilotClient.java | 4 +- .../copilot/GitHubTelemetryAdapter.java | 18 +++++-- .../copilot/rpc/CopilotClientOptions.java | 17 ++++--- .../copilot/GitHubTelemetryForwardingIT.java | 1 + .../github/copilot/GitHubTelemetryTest.java | 16 ++++-- nodejs/src/client.ts | 2 +- python/copilot/client.py | 22 ++++++--- python/test_client.py | 49 +++++++++++++++++++ 12 files changed, 126 insertions(+), 37 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index a1ae0f91dc..6041fe2391 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -2728,22 +2728,21 @@ public sealed class ToolResultAIContent(ToolResultObject toolResult) : AIContent /// payload unchanged. /// [Experimental(Diagnostics.Experimental)] -internal sealed class GitHubTelemetryAdapter(Action callback, ILogger logger) : Rpc.IGitHubTelemetryHandler +internal sealed class GitHubTelemetryAdapter(Func callback, ILogger logger) : Rpc.IGitHubTelemetryHandler { - private readonly Action _callback = callback ?? throw new ArgumentNullException(nameof(callback)); + private readonly Func _callback = callback ?? throw new ArgumentNullException(nameof(callback)); private readonly ILogger _logger = logger ?? NullLogger.Instance; - public Task EventAsync(Rpc.GitHubTelemetryNotification request, CancellationToken cancellationToken = default) + public async Task EventAsync(Rpc.GitHubTelemetryNotification request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); try { - _callback(request); + await _callback(request).ConfigureAwait(false); } catch (Exception ex) { _logger.LogWarning(ex, "Error handling gitHubTelemetry.event notification"); } - return Task.CompletedTask; } } diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 2e2eb2d5fb..37cb0ebe67 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -382,10 +382,11 @@ private CopilotClientOptions(CopilotClientOptions? other) /// /// Experimental. Receives GitHub telemetry events the runtime forwards to this /// connection; setting a handler opts created/resumed sessions into forwarding. + /// The SDK awaits the handler task so it may perform asynchronous work. /// [Experimental(Diagnostics.Experimental)] [EditorBrowsable(EditorBrowsableState.Never)] - public Action? OnGitHubTelemetry { get; set; } + public Func? OnGitHubTelemetry { get; set; } /// /// OpenTelemetry configuration for the runtime. diff --git a/dotnet/test/E2E/GitHubTelemetryForwardingE2ETests.cs b/dotnet/test/E2E/GitHubTelemetryForwardingE2ETests.cs index 85a83aee05..85f3706e25 100644 --- a/dotnet/test/E2E/GitHubTelemetryForwardingE2ETests.cs +++ b/dotnet/test/E2E/GitHubTelemetryForwardingE2ETests.cs @@ -22,7 +22,11 @@ public async Task Should_Forward_GitHub_Telemetry_For_A_Live_Session() await using var client = Ctx.CreateClient(options: new CopilotClientOptions { - OnGitHubTelemetry = notifications.Enqueue, + OnGitHubTelemetry = notification => + { + notifications.Enqueue(notification); + return Task.CompletedTask; + }, }); CopilotSession? session = null; diff --git a/dotnet/test/Unit/GitHubTelemetryTests.cs b/dotnet/test/Unit/GitHubTelemetryTests.cs index b2df78ba7f..4a41c1cb83 100644 --- a/dotnet/test/Unit/GitHubTelemetryTests.cs +++ b/dotnet/test/Unit/GitHubTelemetryTests.cs @@ -24,7 +24,7 @@ public async Task CreateSession_Opts_Into_Forwarding_When_Handler_Provided() await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url), - OnGitHubTelemetry = _ => { }, + OnGitHubTelemetry = _ => Task.CompletedTask, }); await client.StartAsync(); @@ -42,7 +42,7 @@ public async Task ResumeSession_Opts_Into_Forwarding_When_Handler_Provided() await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url), - OnGitHubTelemetry = _ => { }, + OnGitHubTelemetry = _ => Task.CompletedTask, }); await client.StartAsync(); @@ -80,7 +80,11 @@ public async Task GitHubTelemetry_Event_Is_Forwarded_To_OnGitHubTelemetry() await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url), - OnGitHubTelemetry = notification => received.TrySetResult(notification), + OnGitHubTelemetry = notification => + { + received.TrySetResult(notification); + return Task.CompletedTask; + }, }); await client.StartAsync(); @@ -115,7 +119,11 @@ public async Task GitHubTelemetry_Event_Maps_Restricted_And_ClientInfo() await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url), - OnGitHubTelemetry = notification => received.TrySetResult(notification), + OnGitHubTelemetry = notification => + { + received.TrySetResult(notification); + return Task.CompletedTask; + }, }); await client.StartAsync(); diff --git a/java/src/main/java/com/github/copilot/CopilotClient.java b/java/src/main/java/com/github/copilot/CopilotClient.java index d86f063e79..31a8929142 100644 --- a/java/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/CopilotClient.java @@ -17,6 +17,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; +import java.util.function.Function; import java.util.logging.Level; import java.util.logging.Logger; @@ -26,6 +27,7 @@ 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.GitHubTelemetryNotification; import com.github.copilot.generated.rpc.ServerRpc; import com.github.copilot.generated.rpc.SessionEventLogRegisterInterestParams; import com.github.copilot.rpc.DeleteSessionResponse; @@ -259,7 +261,7 @@ private Connection startCoreBody() { } // Register the GitHub telemetry forwarding handler when configured. - java.util.function.Consumer onGitHubTelemetry = this.options + Function> onGitHubTelemetry = this.options .getOnGitHubTelemetry(); if (onGitHubTelemetry != null) { GitHubTelemetryAdapter telemetryAdapter = new GitHubTelemetryAdapter(onGitHubTelemetry); diff --git a/java/src/main/java/com/github/copilot/GitHubTelemetryAdapter.java b/java/src/main/java/com/github/copilot/GitHubTelemetryAdapter.java index 00f8fb7686..1fdb2a4737 100644 --- a/java/src/main/java/com/github/copilot/GitHubTelemetryAdapter.java +++ b/java/src/main/java/com/github/copilot/GitHubTelemetryAdapter.java @@ -4,7 +4,8 @@ package com.github.copilot; -import java.util.function.Consumer; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; import java.util.logging.Level; import java.util.logging.Logger; @@ -14,7 +15,7 @@ /** * Bridges the runtime's {@code gitHubTelemetry.event} client-global - * notification to a consumer's {@code onGitHubTelemetry} callback. The + * notification to a consumer's async {@code onGitHubTelemetry} callback. The * notification carries per-session GitHub (hydro) telemetry the runtime * forwards to connections that opted into telemetry forwarding. */ @@ -23,9 +24,9 @@ final class GitHubTelemetryAdapter { private static final Logger LOG = Logger.getLogger(GitHubTelemetryAdapter.class.getName()); private static final ObjectMapper MAPPER = JsonRpcClient.getObjectMapper(); - private final Consumer callback; + private final Function> callback; - GitHubTelemetryAdapter(Consumer callback) { + GitHubTelemetryAdapter(Function> callback) { this.callback = callback; } @@ -37,7 +38,14 @@ private void handleEvent(JsonNode params) { try { GitHubTelemetryNotification notification = MAPPER.treeToValue(params, GitHubTelemetryNotification.class); if (notification != null) { - callback.accept(notification); + CompletableFuture result = callback.apply(notification); + if (result != null) { + result.whenComplete((unused, error) -> { + if (error != null) { + LOG.log(Level.WARNING, "Error handling gitHubTelemetry.event notification", error); + } + }); + } } } catch (Exception e) { LOG.log(Level.WARNING, "Error handling gitHubTelemetry.event notification", e); diff --git a/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java b/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java index f070f99735..0d4494d738 100644 --- a/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java +++ b/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java @@ -11,7 +11,7 @@ import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; -import java.util.function.Consumer; +import java.util.function.Function; import java.util.function.Supplier; import com.fasterxml.jackson.annotation.JsonInclude; @@ -60,7 +60,7 @@ public class CopilotClientOptions { private CopilotClientMode mode = CopilotClientMode.COPILOT_CLI; private Supplier>> onListModels; private CopilotRequestHandler requestHandler; - private Consumer onGitHubTelemetry; + private Function> onGitHubTelemetry; private int port; private TelemetryConfig telemetry; private Integer sessionIdleTimeoutSeconds; @@ -494,11 +494,11 @@ public CopilotClientOptions setRequestHandler(CopilotRequestHandler requestHandl *

* Experimental: this option may change or be removed without notice. * - * @return the telemetry handler, or {@code null} if not set + * @return the async telemetry handler, or {@code null} if not set */ @JsonIgnore @CopilotExperimental - public Consumer getOnGitHubTelemetry() { + public Function> getOnGitHubTelemetry() { return onGitHubTelemetry; } @@ -509,16 +509,19 @@ public Consumer getOnGitHubTelemetry() { *

* When provided, the client opts every session it creates or resumes into * telemetry forwarding, and the runtime forwards each per-session telemetry - * event to this handler via the {@code gitHubTelemetry.event} notification. + * event to this handler via the {@code gitHubTelemetry.event} notification. The + * handler returns a {@link CompletableFuture} that completes when asynchronous + * processing is finished. * * @param onGitHubTelemetry - * the telemetry handler (must not be {@code null}) + * the async telemetry handler (must not be {@code null}) * @return this options instance for method chaining * @throws IllegalArgumentException * if {@code onGitHubTelemetry} is {@code null} */ @CopilotExperimental - public CopilotClientOptions setOnGitHubTelemetry(Consumer onGitHubTelemetry) { + public CopilotClientOptions setOnGitHubTelemetry( + Function> onGitHubTelemetry) { this.onGitHubTelemetry = Objects.requireNonNull(onGitHubTelemetry, "onGitHubTelemetry must not be null"); return this; } diff --git a/java/src/test/java/com/github/copilot/GitHubTelemetryForwardingIT.java b/java/src/test/java/com/github/copilot/GitHubTelemetryForwardingIT.java index 0cc1695350..d41c5f97dc 100644 --- a/java/src/test/java/com/github/copilot/GitHubTelemetryForwardingIT.java +++ b/java/src/test/java/com/github/copilot/GitHubTelemetryForwardingIT.java @@ -35,6 +35,7 @@ void forwardsGitHubTelemetryForALiveSession() throws Exception { var options = new CopilotClientOptions().setOnGitHubTelemetry(notification -> { notifications.add(notification); firstNotification.complete(notification); + return CompletableFuture.completedFuture(null); }); try (CopilotClient client = ctx.createClient(options); diff --git a/java/src/test/java/com/github/copilot/GitHubTelemetryTest.java b/java/src/test/java/com/github/copilot/GitHubTelemetryTest.java index b785d2ac38..ad950b8233 100644 --- a/java/src/test/java/com/github/copilot/GitHubTelemetryTest.java +++ b/java/src/test/java/com/github/copilot/GitHubTelemetryTest.java @@ -18,7 +18,7 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; +import java.util.function.Function; import org.junit.jupiter.api.Test; @@ -69,7 +69,10 @@ private void writeRpcMessage(OutputStream out, String json) throws IOException { void adapterDispatchesNotificationToHandlerWithTypedPayload() throws Exception { try (var pair = createSocketPair()) { var received = new CompletableFuture(); - Consumer handler = received::complete; + Function> handler = notification -> { + received.complete(notification); + return CompletableFuture.completedFuture(null); + }; new GitHubTelemetryAdapter(handler).registerHandlers(pair.client()); String notification = """ @@ -132,7 +135,10 @@ void adapterDispatchesNotificationToHandlerWithTypedPayload() throws Exception { @Test void clientOptsSessionsIntoForwardingAndReceivesEvents() throws Exception { var received = new CompletableFuture(); - Consumer handler = received::complete; + Function> handler = notification -> { + received.complete(notification); + return CompletableFuture.completedFuture(null); + }; try (var server = new FakeRuntimeServer(); var client = new CopilotClient( @@ -189,8 +195,8 @@ void clientOmitsForwardingWhenNoHandler() throws Exception { @Test void optionsRetainAndCloneTelemetryHandler() { - Consumer handler = n -> { - }; + Function> handler = n -> CompletableFuture + .completedFuture(null); var options = new CopilotClientOptions().setOnGitHubTelemetry(handler); assertSame(handler, options.getOnGitHubTelemetry()); diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 3a4de38b38..160a12d480 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -518,7 +518,7 @@ export class CopilotClient { /** Connection-level session filesystem config, set via constructor option. */ private sessionFsConfig: SessionFsConfig | null = null; private requestHandler: CopilotRequestHandler | null = null; - private onGitHubTelemetry?: (notification: GitHubTelemetryNotification) => void; + private onGitHubTelemetry?: (notification: GitHubTelemetryNotification) => void | Promise; private clientGlobalHandlers: import("./generated/rpc.js").ClientGlobalApiHandlers = {}; /** diff --git a/python/copilot/client.py b/python/copilot/client.py index d1b80825a9..269aaf96ce 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -405,12 +405,17 @@ class _GitHubTelemetryAdapter: ``GitHubTelemetryHandler`` protocol. """ - def __init__(self, callback: Callable[[GitHubTelemetryNotification], None]) -> None: + def __init__( + self, + callback: Callable[[GitHubTelemetryNotification], None | Awaitable[None]], + ) -> None: self._callback = callback async def event(self, params: GitHubTelemetryNotification) -> None: try: - self._callback(params) + result = self._callback(params) + if inspect.isawaitable(result): + await result except Exception: logger.warning("Error handling gitHubTelemetry.event notification", exc_info=True) @@ -436,7 +441,9 @@ class _CopilotClientOptions: session_idle_timeout_seconds: int | None = None enable_remote_sessions: bool = False on_list_models: Callable[[], list[ModelInfo] | Awaitable[list[ModelInfo]]] | None = None - on_github_telemetry: Callable[[GitHubTelemetryNotification], None] | None = None + on_github_telemetry: Callable[[GitHubTelemetryNotification], None | Awaitable[None]] | None = ( + None + ) mode: CopilotClientMode = "copilot-cli" @@ -1126,7 +1133,8 @@ def __init__( session_idle_timeout_seconds: int | None = None, enable_remote_sessions: bool = False, on_list_models: Callable[[], list[ModelInfo] | Awaitable[list[ModelInfo]]] | None = None, - on_github_telemetry: Callable[[GitHubTelemetryNotification], None] | None = None, + on_github_telemetry: Callable[[GitHubTelemetryNotification], None | Awaitable[None]] + | None = None, mode: CopilotClientMode = "copilot-cli", ): """ @@ -1172,9 +1180,9 @@ def __init__( provided, the handler is called instead of querying the runtime server. on_github_telemetry: Internal. Callback invoked when the runtime - forwards a GitHub telemetry event for a session. Registering a - handler opts every session opened by this client into telemetry - forwarding. + forwards a GitHub telemetry event for a session. The callback + may be sync or async. Registering a handler opts every session + opened by this client into telemetry forwarding. Example: >>> # Default — spawns runtime using stdio with the bundled binary diff --git a/python/test_client.py b/python/test_client.py index dcf88bd12f..13fc50e73f 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -2440,6 +2440,55 @@ def on_telemetry(notification): finally: await client.force_stop() + @pytest.mark.asyncio + async def test_event_routes_to_async_handler(self): + from copilot.generated.rpc import GitHubTelemetryNotification + + received: list = [] + delivered = asyncio.Event() + + async def on_telemetry(notification): + await asyncio.sleep(0) + received.append(notification) + delivered.set() + + client = CopilotClient( + connection=RuntimeConnection.for_stdio(path=CLI_PATH), + on_github_telemetry=on_telemetry, + ) + await client.start() + + try: + client._client._handle_message( + { + "jsonrpc": "2.0", + "method": "gitHubTelemetry.event", + "params": { + "sessionId": "sess-async-telemetry", + "restricted": False, + "event": { + "kind": "tool_call_executed", + "metrics": {"duration_ms": 3.5}, + "properties": {"tool": "python"}, + "session_id": "sess-async-telemetry", + }, + }, + } + ) + + await asyncio.wait_for(delivered.wait(), timeout=1) + + assert len(received) == 1 + notification = received[0] + assert isinstance(notification, GitHubTelemetryNotification) + assert notification.session_id == "sess-async-telemetry" + assert notification.restricted is False + assert notification.event.kind == "tool_call_executed" + assert notification.event.metrics["duration_ms"] == 3.5 + assert notification.event.properties["tool"] == "python" + finally: + await client.force_stop() + @pytest.mark.asyncio async def test_event_handler_not_registered_without_option(self): client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH))