diff --git a/docs/getting-started.md b/docs/getting-started.md index f4e92f6fe..ad401894f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -2227,11 +2227,14 @@ Dependency: `io.opentelemetry:opentelemetry-api` | Option | Node.js | Python | Go | Rust | Java | .NET | Description | |---|---|---|---|---|---|---|---| | OTLP endpoint | `otlpEndpoint` | `otlp_endpoint` | `OTLPEndpoint` | `otlp_endpoint` | `otlpEndpoint` | `OtlpEndpoint` | OTLP HTTP endpoint URL | +| OTLP protocol | `otlpProtocol` | `otlp_protocol` | `OTLPProtocol` | `otlp_protocol` | `otlpProtocol` | `OtlpProtocol` | OTLP HTTP protocol for all signals: `"http/json"` or `"http/protobuf"` | | File path | `filePath` | `file_path` | `FilePath` | `file_path` | `filePath` | `FilePath` | File path for JSON-lines trace output | | Exporter type | `exporterType` | `exporter_type` | `ExporterType` | `exporter_type` | `exporterType` | `ExporterType` | `"otlp-http"` or `"file"` | | Source name | `sourceName` | `source_name` | `SourceName` | `source_name` | `sourceName` | `SourceName` | Instrumentation scope name | | Capture content | `captureContent` | `capture_content` | `CaptureContent` | `capture_content` | `captureContent` | `CaptureContent` | Whether to capture message content | +The OTLP protocol field configures the CLI's `"otlp-http"` exporter for all signals. Leave it unset to use the CLI default, or set it to `"http/protobuf"` to export protobuf over HTTP. + ### File export To write traces to a local file instead of an OTLP endpoint: diff --git a/docs/observability/opentelemetry.md b/docs/observability/opentelemetry.md index d89f68ca9..022a41c79 100644 --- a/docs/observability/opentelemetry.md +++ b/docs/observability/opentelemetry.md @@ -104,11 +104,14 @@ let client = Client::start(ClientOptions::new() | Option | Node.js | Python | Go | .NET | Java | Rust | Description | |---|---|---|---|---|---|---|---| | OTLP endpoint | `otlpEndpoint` | `otlp_endpoint` | `OTLPEndpoint` | `OtlpEndpoint` | `otlpEndpoint` | `otlp_endpoint` | OTLP HTTP endpoint URL | +| OTLP protocol | `otlpProtocol` | `otlp_protocol` | `OTLPProtocol` | `OtlpProtocol` | `otlpProtocol` | `otlp_protocol` | OTLP HTTP protocol for all signals: `"http/json"` or `"http/protobuf"` | | File path | `filePath` | `file_path` | `FilePath` | `FilePath` | `filePath` | `file_path` | File path for JSON-lines trace output | | Exporter type | `exporterType` | `exporter_type` | `ExporterType` | `ExporterType` | `exporterType` | `exporter_type` | `"otlp-http"` or `"file"` | | Source name | `sourceName` | `source_name` | `SourceName` | `SourceName` | `sourceName` | `source_name` | Instrumentation scope name | | Capture content | `captureContent` | `capture_content` | `CaptureContent` | `CaptureContent` | `captureContent` | `capture_content` | Whether to capture message content | +The OTLP protocol field configures the CLI's `"otlp-http"` exporter for all signals. Leave it unset to use the CLI default, or set it to `"http/protobuf"` to export protobuf over HTTP. + ### Trace context propagation > **Most users don't need this.** The `TelemetryConfig` above is all you need to collect traces from the CLI. The trace context propagation described in this section is an **advanced feature** for applications that create their own OpenTelemetry spans and want them to appear in the **same distributed trace** as the CLI's spans. diff --git a/dotnet/README.md b/dotnet/README.md index c2829277d..b7a73e826 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -738,6 +738,7 @@ var client = new CopilotClient(new CopilotClientOptions **TelemetryConfig properties:** - `OtlpEndpoint` - OTLP HTTP endpoint URL +- `OtlpProtocol` - OTLP HTTP protocol for all signals (`"http/json"` or `"http/protobuf"`) - `FilePath` - File path for JSON-lines trace output - `ExporterType` - `"otlp-http"` or `"file"` - `SourceName` - Instrumentation scope name diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index e98513c53..e83c71bc0 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1805,6 +1805,7 @@ private static bool IsUnsupportedConnectMethod(RemoteRpcException ex) { startInfo.Environment["COPILOT_OTEL_ENABLED"] = "true"; if (telemetry.OtlpEndpoint is not null) startInfo.Environment["OTEL_EXPORTER_OTLP_ENDPOINT"] = telemetry.OtlpEndpoint; + if (telemetry.OtlpProtocol is not null) startInfo.Environment["OTEL_EXPORTER_OTLP_PROTOCOL"] = telemetry.OtlpProtocol; if (telemetry.FilePath is not null) startInfo.Environment["COPILOT_OTEL_FILE_EXPORTER_PATH"] = telemetry.FilePath; if (telemetry.ExporterType is not null) startInfo.Environment["COPILOT_OTEL_EXPORTER_TYPE"] = telemetry.ExporterType; if (telemetry.SourceName is not null) startInfo.Environment["COPILOT_OTEL_SOURCE_NAME"] = telemetry.SourceName; diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 7a2ad2951..c6c30e3e8 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -413,6 +413,14 @@ public sealed class TelemetryConfig /// public string? OtlpEndpoint { get; set; } + /// + /// OTLP HTTP protocol for all signals ("http/json" or "http/protobuf"). + /// + /// + /// Maps to the OTEL_EXPORTER_OTLP_PROTOCOL environment variable. + /// + public string? OtlpProtocol { get; set; } + /// /// File path for the file exporter. /// diff --git a/dotnet/test/E2E/ClientOptionsE2ETests.cs b/dotnet/test/E2E/ClientOptionsE2ETests.cs index 6360cb55a..c2f16a042 100644 --- a/dotnet/test/E2E/ClientOptionsE2ETests.cs +++ b/dotnet/test/E2E/ClientOptionsE2ETests.cs @@ -78,6 +78,7 @@ public async Task Should_Propagate_Process_Options_To_Spawned_Cli() Telemetry = new TelemetryConfig { OtlpEndpoint = "http://127.0.0.1:4318", + OtlpProtocol = "http/protobuf", FilePath = telemetryPath, ExporterType = "file", SourceName = "dotnet-sdk-e2e", @@ -104,6 +105,7 @@ public async Task Should_Propagate_Process_Options_To_Spawned_Cli() Assert.Equal("process-option-token", capturedEnv.GetProperty("COPILOT_SDK_AUTH_TOKEN").GetString()); Assert.Equal("true", capturedEnv.GetProperty("COPILOT_OTEL_ENABLED").GetString()); Assert.Equal("http://127.0.0.1:4318", capturedEnv.GetProperty("OTEL_EXPORTER_OTLP_ENDPOINT").GetString()); + Assert.Equal("http/protobuf", capturedEnv.GetProperty("OTEL_EXPORTER_OTLP_PROTOCOL").GetString()); Assert.Equal(telemetryPath, capturedEnv.GetProperty("COPILOT_OTEL_FILE_EXPORTER_PATH").GetString()); Assert.Equal("file", capturedEnv.GetProperty("COPILOT_OTEL_EXPORTER_TYPE").GetString()); Assert.Equal("dotnet-sdk-e2e", capturedEnv.GetProperty("COPILOT_OTEL_SOURCE_NAME").GetString()); @@ -642,6 +644,7 @@ function saveCapture() { COPILOT_SDK_AUTH_TOKEN: process.env.COPILOT_SDK_AUTH_TOKEN, COPILOT_OTEL_ENABLED: process.env.COPILOT_OTEL_ENABLED, OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, + OTEL_EXPORTER_OTLP_PROTOCOL: process.env.OTEL_EXPORTER_OTLP_PROTOCOL, COPILOT_OTEL_FILE_EXPORTER_PATH: process.env.COPILOT_OTEL_FILE_EXPORTER_PATH, COPILOT_OTEL_EXPORTER_TYPE: process.env.COPILOT_OTEL_EXPORTER_TYPE, COPILOT_OTEL_SOURCE_NAME: process.env.COPILOT_OTEL_SOURCE_NAME, diff --git a/dotnet/test/Unit/TelemetryTests.cs b/dotnet/test/Unit/TelemetryTests.cs index 9229285b9..979e575b3 100644 --- a/dotnet/test/Unit/TelemetryTests.cs +++ b/dotnet/test/Unit/TelemetryTests.cs @@ -16,6 +16,7 @@ public void TelemetryConfig_DefaultValues_AreNull() var config = new TelemetryConfig(); Assert.Null(config.OtlpEndpoint); + Assert.Null(config.OtlpProtocol); Assert.Null(config.FilePath); Assert.Null(config.ExporterType); Assert.Null(config.SourceName); @@ -28,6 +29,7 @@ public void TelemetryConfig_CanSetAllProperties() var config = new TelemetryConfig { OtlpEndpoint = "http://localhost:4318", + OtlpProtocol = "http/protobuf", FilePath = "/tmp/traces.json", ExporterType = "otlp-http", SourceName = "my-app", @@ -35,6 +37,7 @@ public void TelemetryConfig_CanSetAllProperties() }; Assert.Equal("http://localhost:4318", config.OtlpEndpoint); + Assert.Equal("http/protobuf", config.OtlpProtocol); Assert.Equal("/tmp/traces.json", config.FilePath); Assert.Equal("otlp-http", config.ExporterType); Assert.Equal("my-app", config.SourceName); diff --git a/go/README.md b/go/README.md index 4bdbc75f5..f595eb769 100644 --- a/go/README.md +++ b/go/README.md @@ -573,6 +573,7 @@ client, err := copilot.NewClient(copilot.ClientOptions{ **TelemetryConfig fields:** - `OTLPEndpoint` (string): OTLP HTTP endpoint URL +- `OTLPProtocol` (string): OTLP HTTP protocol for all signals (`"http/json"` or `"http/protobuf"`) - `FilePath` (string): File path for JSON-lines trace output - `ExporterType` (string): `"otlp-http"` or `"file"` - `SourceName` (string): Instrumentation scope name diff --git a/go/client.go b/go/client.go index cad460557..b73240b1b 100644 --- a/go/client.go +++ b/go/client.go @@ -1695,6 +1695,9 @@ func (c *Client) startCLIServer(ctx context.Context) error { if t.OTLPEndpoint != "" { c.process.Env = setEnvValue(c.process.Env, "OTEL_EXPORTER_OTLP_ENDPOINT", t.OTLPEndpoint) } + if t.OTLPProtocol != "" { + c.process.Env = setEnvValue(c.process.Env, "OTEL_EXPORTER_OTLP_PROTOCOL", t.OTLPProtocol) + } if t.FilePath != "" { c.process.Env = setEnvValue(c.process.Env, "COPILOT_OTEL_FILE_EXPORTER_PATH", t.FilePath) } diff --git a/go/internal/e2e/client_options_e2e_test.go b/go/internal/e2e/client_options_e2e_test.go index 4f8b74f2b..aa42be1f3 100644 --- a/go/internal/e2e/client_options_e2e_test.go +++ b/go/internal/e2e/client_options_e2e_test.go @@ -110,6 +110,7 @@ func TestClientOptionsE2E(t *testing.T) { opts.SessionIdleTimeoutSeconds = 17 opts.Telemetry = &copilot.TelemetryConfig{ OTLPEndpoint: "http://127.0.0.1:4318", + OTLPProtocol: "http/protobuf", FilePath: telemetryPath, ExporterType: "file", SourceName: "go-sdk-e2e", @@ -147,6 +148,7 @@ func TestClientOptionsE2E(t *testing.T) { "COPILOT_SDK_AUTH_TOKEN": "process-option-token", "COPILOT_OTEL_ENABLED": "true", "OTEL_EXPORTER_OTLP_ENDPOINT": "http://127.0.0.1:4318", + "OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf", "COPILOT_OTEL_FILE_EXPORTER_PATH": telemetryPath, "COPILOT_OTEL_EXPORTER_TYPE": "file", "COPILOT_OTEL_SOURCE_NAME": "go-sdk-e2e", @@ -195,6 +197,7 @@ func TestClientOptionsE2E(t *testing.T) { t.Errorf("Expected session.create.params.includeSubAgentStreamingEvents=false, got %v", params["includeSubAgentStreamingEvents"]) } }) + } // --------------------------------------------------------------------------- @@ -372,6 +375,7 @@ function saveCapture() { COPILOT_SDK_AUTH_TOKEN: process.env.COPILOT_SDK_AUTH_TOKEN, COPILOT_OTEL_ENABLED: process.env.COPILOT_OTEL_ENABLED, OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, + OTEL_EXPORTER_OTLP_PROTOCOL: process.env.OTEL_EXPORTER_OTLP_PROTOCOL, COPILOT_OTEL_FILE_EXPORTER_PATH: process.env.COPILOT_OTEL_FILE_EXPORTER_PATH, COPILOT_OTEL_EXPORTER_TYPE: process.env.COPILOT_OTEL_EXPORTER_TYPE, COPILOT_OTEL_SOURCE_NAME: process.env.COPILOT_OTEL_SOURCE_NAME, diff --git a/go/internal/e2e/telemetry_e2e_test.go b/go/internal/e2e/telemetry_e2e_test.go index 071030281..67a4cf82e 100644 --- a/go/internal/e2e/telemetry_e2e_test.go +++ b/go/internal/e2e/telemetry_e2e_test.go @@ -307,6 +307,9 @@ func TestTelemetryConfigUnit(t *testing.T) { if cfg.OTLPEndpoint != "" { t.Errorf("Expected empty OTLPEndpoint, got %q", cfg.OTLPEndpoint) } + if cfg.OTLPProtocol != "" { + t.Errorf("Expected empty OTLPProtocol, got %q", cfg.OTLPProtocol) + } if cfg.FilePath != "" { t.Errorf("Expected empty FilePath, got %q", cfg.FilePath) } @@ -325,6 +328,7 @@ func TestTelemetryConfigUnit(t *testing.T) { // Mirrors: TelemetryConfig_CanSetAllProperties cfg := copilot.TelemetryConfig{ OTLPEndpoint: "http://localhost:4318", + OTLPProtocol: "http/protobuf", FilePath: "/tmp/traces.json", ExporterType: "otlp-http", SourceName: "my-app", @@ -333,6 +337,9 @@ func TestTelemetryConfigUnit(t *testing.T) { if cfg.OTLPEndpoint != "http://localhost:4318" { t.Errorf("OTLPEndpoint mismatch: %q", cfg.OTLPEndpoint) } + if cfg.OTLPProtocol != "http/protobuf" { + t.Errorf("OTLPProtocol mismatch: %q", cfg.OTLPProtocol) + } if cfg.FilePath != "/tmp/traces.json" { t.Errorf("FilePath mismatch: %q", cfg.FilePath) } diff --git a/go/types.go b/go/types.go index 6245ce519..8225db0ef 100644 --- a/go/types.go +++ b/go/types.go @@ -159,6 +159,10 @@ type TelemetryConfig struct { // Sets OTEL_EXPORTER_OTLP_ENDPOINT. OTLPEndpoint string + // OTLPProtocol is the OTLP HTTP protocol for all signals. + // Sets OTEL_EXPORTER_OTLP_PROTOCOL. + OTLPProtocol string + // FilePath is the file path for JSON-lines trace output. // Sets COPILOT_OTEL_FILE_EXPORTER_PATH. FilePath string diff --git a/java/src/main/java/com/github/copilot/CliServerManager.java b/java/src/main/java/com/github/copilot/CliServerManager.java index a6a08a848..acc683a72 100644 --- a/java/src/main/java/com/github/copilot/CliServerManager.java +++ b/java/src/main/java/com/github/copilot/CliServerManager.java @@ -150,6 +150,9 @@ ProcessInfo startCliServer() throws IOException, InterruptedException { if (telemetry.getOtlpEndpoint() != null) { pb.environment().put("OTEL_EXPORTER_OTLP_ENDPOINT", telemetry.getOtlpEndpoint()); } + if (telemetry.getOtlpProtocol() != null) { + pb.environment().put("OTEL_EXPORTER_OTLP_PROTOCOL", telemetry.getOtlpProtocol()); + } if (telemetry.getFilePath() != null) { pb.environment().put("COPILOT_OTEL_FILE_EXPORTER_PATH", telemetry.getFilePath()); } diff --git a/java/src/main/java/com/github/copilot/rpc/TelemetryConfig.java b/java/src/main/java/com/github/copilot/rpc/TelemetryConfig.java index c0b75f29d..593908ff8 100644 --- a/java/src/main/java/com/github/copilot/rpc/TelemetryConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/TelemetryConfig.java @@ -30,6 +30,7 @@ public class TelemetryConfig { private String otlpEndpoint; + private String otlpProtocol; private String filePath; private String exporterType; private String sourceName; @@ -58,6 +59,29 @@ public TelemetryConfig setOtlpEndpoint(String otlpEndpoint) { return this; } + /** + * Gets the OTLP HTTP protocol for all signals. + *

+ * Maps to the {@code OTEL_EXPORTER_OTLP_PROTOCOL} environment variable. + * + * @return the OTLP HTTP protocol, or {@code null} + */ + public String getOtlpProtocol() { + return otlpProtocol; + } + + /** + * Sets the OTLP HTTP protocol for all signals. + * + * @param otlpProtocol + * the protocol ({@code "http/json"} or {@code "http/protobuf"}) + * @return this config for method chaining + */ + public TelemetryConfig setOtlpProtocol(String otlpProtocol) { + this.otlpProtocol = otlpProtocol; + return this; + } + /** * Gets the file path for the file exporter. *

diff --git a/java/src/test/java/com/github/copilot/CliServerManagerTest.java b/java/src/test/java/com/github/copilot/CliServerManagerTest.java index 2df5dafab..b445d6153 100644 --- a/java/src/test/java/com/github/copilot/CliServerManagerTest.java +++ b/java/src/test/java/com/github/copilot/CliServerManagerTest.java @@ -231,8 +231,9 @@ void startCliServerWithNullCliPath() throws Exception { void startCliServerWithTelemetryAllOptions() throws Exception { // The telemetry env vars are applied before ProcessBuilder.start() // so even with a nonexistent CLI path, the telemetry code path is exercised - var telemetry = new TelemetryConfig().setOtlpEndpoint("http://localhost:4318").setFilePath("/tmp/telemetry.log") - .setExporterType("otlp-http").setSourceName("test-app").setCaptureContent(true); + var telemetry = new TelemetryConfig().setOtlpEndpoint("http://localhost:4318").setOtlpProtocol("http/protobuf") + .setFilePath("/tmp/telemetry.log").setExporterType("otlp-http").setSourceName("test-app") + .setCaptureContent(true); var options = new CopilotClientOptions().setCliPath(NONEXISTENT_CLI).setTelemetry(telemetry).setUseStdio(true); var manager = new CliServerManager(options); diff --git a/java/src/test/java/com/github/copilot/TelemetryConfigTest.java b/java/src/test/java/com/github/copilot/TelemetryConfigTest.java index 2dd41c28a..739d6a510 100644 --- a/java/src/test/java/com/github/copilot/TelemetryConfigTest.java +++ b/java/src/test/java/com/github/copilot/TelemetryConfigTest.java @@ -19,6 +19,7 @@ class TelemetryConfigTest { void defaultValuesAreNull() { var config = new TelemetryConfig(); assertNull(config.getOtlpEndpoint()); + assertNull(config.getOtlpProtocol()); assertNull(config.getFilePath()); assertNull(config.getExporterType()); assertNull(config.getSourceName()); @@ -32,6 +33,13 @@ void otlpEndpointGetterSetter() { assertEquals("http://localhost:4318", config.getOtlpEndpoint()); } + @Test + void otlpProtocolGetterSetter() { + var config = new TelemetryConfig(); + config.setOtlpProtocol("http/protobuf"); + assertEquals("http/protobuf", config.getOtlpProtocol()); + } + @Test void filePathGetterSetter() { var config = new TelemetryConfig(); @@ -65,10 +73,12 @@ void captureContentGetterSetter() { @Test void fluentChainingReturnsThis() { - var config = new TelemetryConfig().setOtlpEndpoint("http://localhost:4318").setFilePath("/tmp/spans.json") - .setExporterType("file").setSourceName("sdk-test").setCaptureContent(true); + var config = new TelemetryConfig().setOtlpEndpoint("http://localhost:4318").setOtlpProtocol("http/protobuf") + .setFilePath("/tmp/spans.json").setExporterType("file").setSourceName("sdk-test") + .setCaptureContent(true); assertEquals("http://localhost:4318", config.getOtlpEndpoint()); + assertEquals("http/protobuf", config.getOtlpProtocol()); assertEquals("/tmp/spans.json", config.getFilePath()); assertEquals("file", config.getExporterType()); assertEquals("sdk-test", config.getSourceName()); diff --git a/nodejs/README.md b/nodejs/README.md index 91aa5c4be..08a691a29 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -786,6 +786,7 @@ With just this configuration, the CLI emits spans for every session, message, an **TelemetryConfig options:** - `otlpEndpoint?: string` - OTLP HTTP endpoint URL +- `otlpProtocol?: "http/json" | "http/protobuf"` - OTLP HTTP protocol for all signals - `filePath?: string` - File path for JSON-lines trace output - `exporterType?: string` - `"otlp-http"` or `"file"` - `sourceName?: string` - Instrumentation scope name diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index c59bd3e94..223e92468 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1929,6 +1929,8 @@ export class CopilotClient { envWithoutNodeDebug.COPILOT_OTEL_ENABLED = "true"; if (t.otlpEndpoint !== undefined) envWithoutNodeDebug.OTEL_EXPORTER_OTLP_ENDPOINT = t.otlpEndpoint; + if (t.otlpProtocol !== undefined) + envWithoutNodeDebug.OTEL_EXPORTER_OTLP_PROTOCOL = t.otlpProtocol; if (t.filePath !== undefined) envWithoutNodeDebug.COPILOT_OTEL_FILE_EXPORTER_PATH = t.filePath; if (t.exporterType !== undefined) diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 647ca79c1..4d36ca0a9 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -55,6 +55,8 @@ export type TraceContextProvider = () => TraceContext | Promise; export interface TelemetryConfig { /** OTLP HTTP endpoint URL for trace/metric export. Sets OTEL_EXPORTER_OTLP_ENDPOINT. */ otlpEndpoint?: string; + /** OTLP HTTP protocol for all signals. Sets OTEL_EXPORTER_OTLP_PROTOCOL. */ + otlpProtocol?: "http/json" | "http/protobuf"; /** File path for JSON-lines trace output. Sets COPILOT_OTEL_FILE_EXPORTER_PATH. */ filePath?: string; /** Exporter backend type: "otlp-http" or "file". Sets COPILOT_OTEL_EXPORTER_TYPE. */ diff --git a/nodejs/test/e2e/client_options.e2e.test.ts b/nodejs/test/e2e/client_options.e2e.test.ts index dadce08e1..2cfc69456 100644 --- a/nodejs/test/e2e/client_options.e2e.test.ts +++ b/nodejs/test/e2e/client_options.e2e.test.ts @@ -29,6 +29,7 @@ function saveCapture() { COPILOT_SDK_AUTH_TOKEN: process.env.COPILOT_SDK_AUTH_TOKEN, COPILOT_OTEL_ENABLED: process.env.COPILOT_OTEL_ENABLED, OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, + OTEL_EXPORTER_OTLP_PROTOCOL: process.env.OTEL_EXPORTER_OTLP_PROTOCOL, COPILOT_OTEL_FILE_EXPORTER_PATH: process.env.COPILOT_OTEL_FILE_EXPORTER_PATH, COPILOT_OTEL_EXPORTER_TYPE: process.env.COPILOT_OTEL_EXPORTER_TYPE, COPILOT_OTEL_SOURCE_NAME: process.env.COPILOT_OTEL_SOURCE_NAME, @@ -247,6 +248,7 @@ describe("Client options", async () => { sessionIdleTimeoutSeconds: 17, telemetry: { otlpEndpoint: "http://127.0.0.1:4318", + otlpProtocol: "http/protobuf", filePath: telemetryPath, exporterType: "file", sourceName: "ts-sdk-e2e", @@ -283,6 +285,7 @@ describe("Client options", async () => { expect(capture.env.COPILOT_SDK_AUTH_TOKEN).toBe("process-option-token"); expect(capture.env.COPILOT_OTEL_ENABLED).toBe("true"); expect(capture.env.OTEL_EXPORTER_OTLP_ENDPOINT).toBe("http://127.0.0.1:4318"); + expect(capture.env.OTEL_EXPORTER_OTLP_PROTOCOL).toBe("http/protobuf"); expect(capture.env.COPILOT_OTEL_FILE_EXPORTER_PATH).toBe(telemetryPath); expect(capture.env.COPILOT_OTEL_EXPORTER_TYPE).toBe("file"); expect(capture.env.COPILOT_OTEL_SOURCE_NAME).toBe("ts-sdk-e2e"); diff --git a/nodejs/test/telemetry.test.ts b/nodejs/test/telemetry.test.ts index 9ad97b63a..78d9654ed 100644 --- a/nodejs/test/telemetry.test.ts +++ b/nodejs/test/telemetry.test.ts @@ -64,6 +64,7 @@ describe("telemetry", () => { it("sets correct env vars for full telemetry config", async () => { const telemetry = { otlpEndpoint: "http://localhost:4318", + otlpProtocol: "http/protobuf", filePath: "/tmp/traces.jsonl", exporterType: "otlp-http", sourceName: "my-app", @@ -76,6 +77,7 @@ describe("telemetry", () => { const t = telemetry; env.COPILOT_OTEL_ENABLED = "true"; if (t.otlpEndpoint !== undefined) env.OTEL_EXPORTER_OTLP_ENDPOINT = t.otlpEndpoint; + if (t.otlpProtocol !== undefined) env.OTEL_EXPORTER_OTLP_PROTOCOL = t.otlpProtocol; if (t.filePath !== undefined) env.COPILOT_OTEL_FILE_EXPORTER_PATH = t.filePath; if (t.exporterType !== undefined) env.COPILOT_OTEL_EXPORTER_TYPE = t.exporterType; if (t.sourceName !== undefined) env.COPILOT_OTEL_SOURCE_NAME = t.sourceName; @@ -88,6 +90,7 @@ describe("telemetry", () => { expect(env).toEqual({ COPILOT_OTEL_ENABLED: "true", OTEL_EXPORTER_OTLP_ENDPOINT: "http://localhost:4318", + OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf", COPILOT_OTEL_FILE_EXPORTER_PATH: "/tmp/traces.jsonl", COPILOT_OTEL_EXPORTER_TYPE: "otlp-http", COPILOT_OTEL_SOURCE_NAME: "my-app", @@ -103,6 +106,7 @@ describe("telemetry", () => { const t = telemetry as any; env.COPILOT_OTEL_ENABLED = "true"; if (t.otlpEndpoint !== undefined) env.OTEL_EXPORTER_OTLP_ENDPOINT = t.otlpEndpoint; + if (t.otlpProtocol !== undefined) env.OTEL_EXPORTER_OTLP_PROTOCOL = t.otlpProtocol; if (t.filePath !== undefined) env.COPILOT_OTEL_FILE_EXPORTER_PATH = t.filePath; if (t.exporterType !== undefined) env.COPILOT_OTEL_EXPORTER_TYPE = t.exporterType; if (t.sourceName !== undefined) env.COPILOT_OTEL_SOURCE_NAME = t.sourceName; diff --git a/python/README.md b/python/README.md index e77fcf16c..19373567c 100644 --- a/python/README.md +++ b/python/README.md @@ -557,6 +557,7 @@ client = CopilotClient( **TelemetryConfig options:** - `otlp_endpoint` (str): OTLP HTTP endpoint URL +- `otlp_protocol` (str): OTLP HTTP protocol for all signals (`"http/json"` or `"http/protobuf"`) - `file_path` (str): File path for JSON-lines trace output - `exporter_type` (str): `"otlp-http"` or `"file"` - `source_name` (str): Instrumentation scope name diff --git a/python/copilot/client.py b/python/copilot/client.py index 24eec9d72..7dadad6c8 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -182,6 +182,11 @@ class TelemetryConfig(TypedDict, total=False): otlp_endpoint: str """OTLP HTTP endpoint URL for trace/metric export. Sets OTEL_EXPORTER_OTLP_ENDPOINT.""" + otlp_protocol: Literal["http/json", "http/protobuf"] + """OTLP HTTP protocol for all signals. + + Allowed values are "http/json" and "http/protobuf". Sets OTEL_EXPORTER_OTLP_PROTOCOL. + """ file_path: str """File path for JSON-lines trace output. Sets COPILOT_OTEL_FILE_EXPORTER_PATH.""" exporter_type: str @@ -3225,6 +3230,8 @@ async def _start_cli_server(self) -> None: env["COPILOT_OTEL_ENABLED"] = "true" if "otlp_endpoint" in telemetry: env["OTEL_EXPORTER_OTLP_ENDPOINT"] = telemetry["otlp_endpoint"] + if "otlp_protocol" in telemetry: + env["OTEL_EXPORTER_OTLP_PROTOCOL"] = telemetry["otlp_protocol"] if "file_path" in telemetry: env["COPILOT_OTEL_FILE_EXPORTER_PATH"] = telemetry["file_path"] if "exporter_type" in telemetry: diff --git a/python/e2e/test_client_options_e2e.py b/python/e2e/test_client_options_e2e.py index f32d923a3..9a70c6cbf 100644 --- a/python/e2e/test_client_options_e2e.py +++ b/python/e2e/test_client_options_e2e.py @@ -92,6 +92,7 @@ def _get_available_port() -> int: COPILOT_SDK_AUTH_TOKEN: process.env.COPILOT_SDK_AUTH_TOKEN, COPILOT_OTEL_ENABLED: process.env.COPILOT_OTEL_ENABLED, OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, + OTEL_EXPORTER_OTLP_PROTOCOL: process.env.OTEL_EXPORTER_OTLP_PROTOCOL, COPILOT_OTEL_FILE_EXPORTER_PATH: process.env.COPILOT_OTEL_FILE_EXPORTER_PATH, COPILOT_OTEL_EXPORTER_TYPE: process.env.COPILOT_OTEL_EXPORTER_TYPE, COPILOT_OTEL_SOURCE_NAME: process.env.COPILOT_OTEL_SOURCE_NAME, @@ -218,6 +219,7 @@ async def test_should_propagate_process_options_to_spawned_cli(self, ctx: E2ETes session_idle_timeout_seconds=17, telemetry={ "otlp_endpoint": "http://127.0.0.1:4318", + "otlp_protocol": "http/protobuf", "file_path": telemetry_path, "exporter_type": "file", "source_name": "python-sdk-e2e", @@ -246,6 +248,7 @@ async def test_should_propagate_process_options_to_spawned_cli(self, ctx: E2ETes assert env["COPILOT_SDK_AUTH_TOKEN"] == "process-option-token" assert env["COPILOT_OTEL_ENABLED"] == "true" assert env["OTEL_EXPORTER_OTLP_ENDPOINT"] == "http://127.0.0.1:4318" + assert env["OTEL_EXPORTER_OTLP_PROTOCOL"] == "http/protobuf" assert env["COPILOT_OTEL_FILE_EXPORTER_PATH"] == telemetry_path assert env["COPILOT_OTEL_EXPORTER_TYPE"] == "file" assert env["COPILOT_OTEL_SOURCE_NAME"] == "python-sdk-e2e" diff --git a/python/e2e/test_telemetry_e2e.py b/python/e2e/test_telemetry_e2e.py index f18a9fb88..cc87cf321 100644 --- a/python/e2e/test_telemetry_e2e.py +++ b/python/e2e/test_telemetry_e2e.py @@ -186,6 +186,7 @@ async def test_default_values_are_unset(self): # constructor leaves every field unset (equivalent to C#'s null defaults). cfg: TelemetryConfig = TelemetryConfig() assert cfg.get("otlp_endpoint") is None + assert cfg.get("otlp_protocol") is None assert cfg.get("file_path") is None assert cfg.get("exporter_type") is None assert cfg.get("source_name") is None @@ -194,12 +195,14 @@ async def test_default_values_are_unset(self): async def test_can_set_all_properties(self): cfg: TelemetryConfig = TelemetryConfig( otlp_endpoint="http://localhost:4318", + otlp_protocol="http/protobuf", file_path="/tmp/traces.json", exporter_type="otlp-http", source_name="my-app", capture_content=True, ) assert cfg["otlp_endpoint"] == "http://localhost:4318" + assert cfg["otlp_protocol"] == "http/protobuf" assert cfg["file_path"] == "/tmp/traces.json" assert cfg["exporter_type"] == "otlp-http" assert cfg["source_name"] == "my-app" diff --git a/python/test_telemetry.py b/python/test_telemetry.py index 6481fd525..8a34f19b2 100644 --- a/python/test_telemetry.py +++ b/python/test_telemetry.py @@ -77,6 +77,7 @@ def test_telemetry_env_var_mapping(self): """TelemetryConfig fields map to expected environment variable names.""" config: TelemetryConfig = { "otlp_endpoint": "http://localhost:4318", + "otlp_protocol": "http/protobuf", "file_path": "/tmp/traces.jsonl", "exporter_type": "file", "source_name": "test-app", @@ -87,6 +88,8 @@ def test_telemetry_env_var_mapping(self): env["COPILOT_OTEL_ENABLED"] = "true" if "otlp_endpoint" in config: env["OTEL_EXPORTER_OTLP_ENDPOINT"] = config["otlp_endpoint"] + if "otlp_protocol" in config: + env["OTEL_EXPORTER_OTLP_PROTOCOL"] = config["otlp_protocol"] if "file_path" in config: env["COPILOT_OTEL_FILE_EXPORTER_PATH"] = config["file_path"] if "exporter_type" in config: @@ -100,6 +103,7 @@ def test_telemetry_env_var_mapping(self): assert env["COPILOT_OTEL_ENABLED"] == "true" assert env["OTEL_EXPORTER_OTLP_ENDPOINT"] == "http://localhost:4318" + assert env["OTEL_EXPORTER_OTLP_PROTOCOL"] == "http/protobuf" assert env["COPILOT_OTEL_FILE_EXPORTER_PATH"] == "/tmp/traces.jsonl" assert env["COPILOT_OTEL_EXPORTER_TYPE"] == "file" assert env["COPILOT_OTEL_SOURCE_NAME"] == "test-app" diff --git a/rust/README.md b/rust/README.md index 0b5bec1cd..52ccad635 100644 --- a/rust/README.md +++ b/rust/README.md @@ -588,11 +588,12 @@ Provider types include `"openai"`, `"azure"`, and `"anthropic"`. Set `wire_api` Forward OpenTelemetry signals from the spawned CLI process to your collector: ```rust,ignore -use github_copilot_sdk::{ClientOptions, OtelExporterType, TelemetryConfig}; +use github_copilot_sdk::{ClientOptions, OtelExporterType, OtlpHttpProtocol, TelemetryConfig}; let mut telem = TelemetryConfig::default(); telem.exporter_type = Some(OtelExporterType::OtlpHttp); telem.otlp_endpoint = Some("http://localhost:4318".to_string()); +telem.otlp_protocol = Some(OtlpHttpProtocol::HttpProtobuf); telem.source_name = Some("my-app".to_string()); let mut opts = ClientOptions::default(); @@ -600,7 +601,7 @@ opts.telemetry = Some(telem); let client = Client::start(opts).await?; ``` -The SDK injects the appropriate environment variables (`COPILOT_OTEL_EXPORTER_TYPE`, `OTEL_EXPORTER_OTLP_ENDPOINT`, ...) into the spawned CLI process. The SDK takes no OpenTelemetry dependency; the CLI itself owns the exporter pipeline. Caller-supplied `ClientOptions::env` entries override telemetry-injected values. +The SDK injects the appropriate environment variables (`COPILOT_OTEL_EXPORTER_TYPE`, `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_PROTOCOL`, ...) into the spawned CLI process. The SDK takes no OpenTelemetry dependency; the CLI itself owns the exporter pipeline. Caller-supplied `ClientOptions::env` entries override telemetry-injected values. ### Progress Reporting (`send_and_wait`) diff --git a/rust/src/lib.rs b/rust/src/lib.rs index cab34b476..662218e46 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -402,6 +402,32 @@ impl OtelExporterType { } } +/// OTLP HTTP protocol used by the CLI's OpenTelemetry OTLP exporter. +/// +/// Maps to the standard `OTEL_EXPORTER_OTLP_PROTOCOL` environment variable on +/// the spawned CLI process. Wire values are `"http/json"` and +/// `"http/protobuf"`. +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum OtlpHttpProtocol { + /// Export using OTLP/HTTP JSON. + #[serde(rename = "http/json")] + HttpJson, + /// Export using OTLP/HTTP protobuf. + #[serde(rename = "http/protobuf")] + HttpProtobuf, +} + +impl OtlpHttpProtocol { + /// Environment-variable value (`"http/json"` or `"http/protobuf"`). + pub fn as_str(self) -> &'static str { + match self { + Self::HttpJson => "http/json", + Self::HttpProtobuf => "http/protobuf", + } + } +} + /// OpenTelemetry configuration forwarded to the spawned GitHub Copilot CLI /// process. /// @@ -417,6 +443,7 @@ impl OtelExporterType { /// |----------------------|-------------------------------------------------------| /// | (any field set) | `COPILOT_OTEL_ENABLED=true` | /// | [`otlp_endpoint`] | `OTEL_EXPORTER_OTLP_ENDPOINT` | +/// | [`otlp_protocol`] | `OTEL_EXPORTER_OTLP_PROTOCOL` | /// | [`file_path`] | `COPILOT_OTEL_FILE_EXPORTER_PATH` | /// | [`exporter_type`] | `COPILOT_OTEL_EXPORTER_TYPE` | /// | [`source_name`] | `COPILOT_OTEL_SOURCE_NAME` | @@ -430,6 +457,7 @@ impl OtelExporterType { /// added without breaking callers. /// /// [`otlp_endpoint`]: Self::otlp_endpoint +/// [`otlp_protocol`]: Self::otlp_protocol /// [`file_path`]: Self::file_path /// [`exporter_type`]: Self::exporter_type /// [`source_name`]: Self::source_name @@ -439,6 +467,8 @@ impl OtelExporterType { pub struct TelemetryConfig { /// OTLP HTTP endpoint URL for trace/metric export. pub otlp_endpoint: Option, + /// OTLP HTTP protocol for all signals. + pub otlp_protocol: Option, /// File path for JSON-lines trace output. pub file_path: Option, /// Exporter backend type. Typically [`OtelExporterType::OtlpHttp`] or @@ -467,6 +497,12 @@ impl TelemetryConfig { self } + /// Set the OTLP HTTP protocol for all signals. + pub fn with_otlp_protocol(mut self, protocol: OtlpHttpProtocol) -> Self { + self.otlp_protocol = Some(protocol); + self + } + /// Set the file path for JSON-lines trace output. pub fn with_file_path(mut self, path: impl Into) -> Self { self.file_path = Some(path.into()); @@ -499,6 +535,7 @@ impl TelemetryConfig { /// to decide whether to set `COPILOT_OTEL_ENABLED`. pub fn is_empty(&self) -> bool { self.otlp_endpoint.is_none() + && self.otlp_protocol.is_none() && self.file_path.is_none() && self.exporter_type.is_none() && self.source_name.is_none() @@ -1209,6 +1246,9 @@ impl Client { if let Some(endpoint) = &telemetry.otlp_endpoint { command.env("OTEL_EXPORTER_OTLP_ENDPOINT", endpoint); } + if let Some(protocol) = telemetry.otlp_protocol { + command.env("OTEL_EXPORTER_OTLP_PROTOCOL", protocol.as_str()); + } if let Some(path) = &telemetry.file_path { command.env("COPILOT_OTEL_FILE_EXPORTER_PATH", path); } @@ -2115,12 +2155,14 @@ mod tests { fn telemetry_config_builder_composes() { let cfg = TelemetryConfig::new() .with_otlp_endpoint("http://collector:4318") + .with_otlp_protocol(OtlpHttpProtocol::HttpProtobuf) .with_file_path(PathBuf::from("/var/log/copilot.jsonl")) .with_exporter_type(OtelExporterType::OtlpHttp) .with_source_name("my-app") .with_capture_content(true); assert_eq!(cfg.otlp_endpoint.as_deref(), Some("http://collector:4318")); + assert_eq!(cfg.otlp_protocol, Some(OtlpHttpProtocol::HttpProtobuf)); assert_eq!( cfg.file_path.as_deref(), Some(Path::new("/var/log/copilot.jsonl")), @@ -2132,11 +2174,28 @@ mod tests { assert!(TelemetryConfig::new().is_empty()); } + #[test] + fn otlp_http_protocol_serde_matches_env_value() { + for (protocol, wire) in [ + (OtlpHttpProtocol::HttpJson, "http/json"), + (OtlpHttpProtocol::HttpProtobuf, "http/protobuf"), + ] { + assert_eq!(protocol.as_str(), wire); + + let serialized = serde_json::to_string(&protocol).unwrap(); + assert_eq!(serialized, format!("\"{wire}\"")); + + let deserialized: OtlpHttpProtocol = serde_json::from_str(&serialized).unwrap(); + assert_eq!(deserialized, protocol); + } + } + #[test] fn build_command_sets_otel_env_when_telemetry_enabled() { let opts = ClientOptions { telemetry: Some(TelemetryConfig { otlp_endpoint: Some("http://collector:4318".to_string()), + otlp_protocol: Some(OtlpHttpProtocol::HttpProtobuf), file_path: Some(PathBuf::from("/var/log/copilot.jsonl")), exporter_type: Some(OtelExporterType::OtlpHttp), source_name: Some("my-app".to_string()), @@ -2153,6 +2212,10 @@ mod tests { env_value(&cmd, "OTEL_EXPORTER_OTLP_ENDPOINT"), Some(std::ffi::OsStr::new("http://collector:4318")), ); + assert_eq!( + env_value(&cmd, "OTEL_EXPORTER_OTLP_PROTOCOL"), + Some(std::ffi::OsStr::new("http/protobuf")), + ); assert_eq!( env_value(&cmd, "COPILOT_OTEL_FILE_EXPORTER_PATH"), Some(std::ffi::OsStr::new("/var/log/copilot.jsonl")), @@ -2178,6 +2241,7 @@ mod tests { for key in [ "COPILOT_OTEL_ENABLED", "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_PROTOCOL", "COPILOT_OTEL_FILE_EXPORTER_PATH", "COPILOT_OTEL_EXPORTER_TYPE", "COPILOT_OTEL_SOURCE_NAME", @@ -2211,6 +2275,7 @@ mod tests { ); // None of the other fields should leak as env vars. for key in [ + "OTEL_EXPORTER_OTLP_PROTOCOL", "COPILOT_OTEL_FILE_EXPORTER_PATH", "COPILOT_OTEL_EXPORTER_TYPE", "COPILOT_OTEL_SOURCE_NAME",