Skip to content

Commit da436ad

Browse files
stephentoubCopilot
authored andcommitted
feat: add OpenTelemetry support across all SDKs
Add TelemetryConfig to all four SDKs (Node, Python, Go, .NET) to configure OpenTelemetry instrumentation on the Copilot CLI process. This includes: - TelemetryConfig type with OTLP endpoint, file exporter, source name, and capture-content options, mapped to CLI environment variables - W3C Trace Context propagation (traceparent/tracestate) on session.create, session.resume, and session.send RPC calls - Trace context restoration in tool call handlers (v2 RPC and v3 broadcast) so user tool code executes within the correct distributed trace - Telemetry helper modules (telemetry.ts, telemetry.py, telemetry.go, Telemetry.cs) with unit tests - Updated generated types from latest schema Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5fdb577 commit da436ad

31 files changed

+984
-44
lines changed

docs/getting-started.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1426,13 +1426,13 @@ Optional peer dependency: `@opentelemetry/api`
14261426

14271427
<!-- docs-validate: skip -->
14281428
```python
1429-
from copilot import CopilotClient
1429+
from copilot import CopilotClient, SubprocessConfig
14301430

1431-
client = CopilotClient(
1431+
client = CopilotClient(SubprocessConfig(
14321432
telemetry={
14331433
"otlp_endpoint": "http://localhost:4318",
14341434
},
1435-
)
1435+
))
14361436
```
14371437

14381438
Install with telemetry extras: `pip install copilot-sdk[telemetry]` (provides `opentelemetry-api`)

docs/observability/opentelemetry.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,13 @@ const client = new CopilotClient({
2727

2828
<!-- docs-validate: skip -->
2929
```python
30-
from copilot import CopilotClient
30+
from copilot import CopilotClient, SubprocessConfig
3131

32-
client = CopilotClient(
32+
client = CopilotClient(SubprocessConfig(
3333
telemetry={
3434
"otlp_endpoint": "http://localhost:4318",
3535
},
36-
)
36+
))
3737
```
3838

3939
</details>

dotnet/src/Client.cs

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,8 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
431431

432432
try
433433
{
434+
var (traceparent, tracestate) = TelemetryHelpers.GetTraceContext();
435+
434436
var request = new CreateSessionRequest(
435437
config.Model,
436438
sessionId,
@@ -453,7 +455,9 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
453455
config.ConfigDir,
454456
config.SkillDirectories,
455457
config.DisabledSkills,
456-
config.InfiniteSessions);
458+
config.InfiniteSessions,
459+
traceparent,
460+
tracestate);
457461

458462
var response = await InvokeRpcAsync<CreateSessionResponse>(
459463
connection.Rpc, "session.create", [request], cancellationToken);
@@ -535,6 +539,8 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
535539

536540
try
537541
{
542+
var (traceparent, tracestate) = TelemetryHelpers.GetTraceContext();
543+
538544
var request = new ResumeSessionRequest(
539545
sessionId,
540546
config.ClientName,
@@ -558,7 +564,9 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
558564
config.Agent,
559565
config.SkillDirectories,
560566
config.DisabledSkills,
561-
config.InfiniteSessions);
567+
config.InfiniteSessions,
568+
traceparent,
569+
tracestate);
562570

563571
var response = await InvokeRpcAsync<ResumeSessionResponse>(
564572
connection.Rpc, "session.resume", [request], cancellationToken);
@@ -1070,6 +1078,17 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
10701078
startInfo.Environment["COPILOT_SDK_AUTH_TOKEN"] = options.GitHubToken;
10711079
}
10721080

1081+
// Set telemetry environment variables if configured
1082+
if (options.Telemetry is { } telemetry)
1083+
{
1084+
startInfo.Environment["COPILOT_OTEL_ENABLED"] = "true";
1085+
if (telemetry.OtlpEndpoint is not null) startInfo.Environment["OTEL_EXPORTER_OTLP_ENDPOINT"] = telemetry.OtlpEndpoint;
1086+
if (telemetry.FilePath is not null) startInfo.Environment["COPILOT_OTEL_FILE_EXPORTER_PATH"] = telemetry.FilePath;
1087+
if (telemetry.ExporterType is not null) startInfo.Environment["COPILOT_OTEL_EXPORTER_TYPE"] = telemetry.ExporterType;
1088+
if (telemetry.SourceName is not null) startInfo.Environment["COPILOT_OTEL_SOURCE_NAME"] = telemetry.SourceName;
1089+
if (telemetry.CaptureContent is { } capture) startInfo.Environment["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = capture ? "true" : "false";
1090+
}
1091+
10731092
var cliProcess = new Process { StartInfo = startInfo };
10741093
cliProcess.Start();
10751094

@@ -1329,8 +1348,12 @@ public async Task<HooksInvokeResponse> OnHooksInvoke(string sessionId, string ho
13291348
public async Task<ToolCallResponseV2> OnToolCallV2(string sessionId,
13301349
string toolCallId,
13311350
string toolName,
1332-
object? arguments)
1351+
object? arguments,
1352+
string? traceparent = null,
1353+
string? tracestate = null)
13331354
{
1355+
using var _ = TelemetryHelpers.RestoreTraceContext(traceparent, tracestate);
1356+
13341357
var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}");
13351358
if (session.GetTool(toolName) is not { } tool)
13361359
{
@@ -1470,7 +1493,9 @@ internal record CreateSessionRequest(
14701493
string? ConfigDir,
14711494
List<string>? SkillDirectories,
14721495
List<string>? DisabledSkills,
1473-
InfiniteSessionConfig? InfiniteSessions);
1496+
InfiniteSessionConfig? InfiniteSessions,
1497+
string? Traceparent = null,
1498+
string? Tracestate = null);
14741499

14751500
internal record ToolDefinition(
14761501
string Name,
@@ -1516,7 +1541,9 @@ internal record ResumeSessionRequest(
15161541
string? Agent,
15171542
List<string>? SkillDirectories,
15181543
List<string>? DisabledSkills,
1519-
InfiniteSessionConfig? InfiniteSessions);
1544+
InfiniteSessionConfig? InfiniteSessions,
1545+
string? Traceparent = null,
1546+
string? Tracestate = null);
15201547

15211548
internal record ResumeSessionResponse(
15221549
string SessionId,

dotnet/src/Session.cs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,12 +152,16 @@ private Task<T> InvokeRpcAsync<T>(string method, object?[]? args, CancellationTo
152152
/// </example>
153153
public async Task<string> SendAsync(MessageOptions options, CancellationToken cancellationToken = default)
154154
{
155+
var (traceparent, tracestate) = TelemetryHelpers.GetTraceContext();
156+
155157
var request = new SendMessageRequest
156158
{
157159
SessionId = SessionId,
158160
Prompt = options.Prompt,
159161
Attachments = options.Attachments,
160-
Mode = options.Mode
162+
Mode = options.Mode,
163+
Traceparent = traceparent,
164+
Tracestate = tracestate
161165
};
162166

163167
var response = await InvokeRpcAsync<SendMessageResponse>(
@@ -412,7 +416,8 @@ private async Task HandleBroadcastEventAsync(SessionEvent sessionEvent)
412416
if (tool is null)
413417
return; // This client doesn't handle this tool; another client will.
414418

415-
await ExecuteToolAndRespondAsync(data.RequestId, data.ToolName, data.ToolCallId, data.Arguments, tool);
419+
using (TelemetryHelpers.RestoreTraceContext(data.Traceparent, data.Tracestate))
420+
await ExecuteToolAndRespondAsync(data.RequestId, data.ToolName, data.ToolCallId, data.Arguments, tool);
416421
break;
417422
}
418423

@@ -822,6 +827,8 @@ internal record SendMessageRequest
822827
public string Prompt { get; init; } = string.Empty;
823828
public List<UserMessageDataAttachmentsItem>? Attachments { get; init; }
824829
public string? Mode { get; init; }
830+
public string? Traceparent { get; init; }
831+
public string? Tracestate { get; init; }
825832
}
826833

827834
internal record SendMessageResponse

dotnet/src/Telemetry.cs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
using System.Diagnostics;
6+
7+
namespace GitHub.Copilot.SDK;
8+
9+
internal static class TelemetryHelpers
10+
{
11+
internal static (string? Traceparent, string? Tracestate) GetTraceContext()
12+
{
13+
return Activity.Current is { } activity
14+
? (activity.Id, activity.TraceStateString)
15+
: (null, null);
16+
}
17+
18+
/// <summary>
19+
/// Sets <see cref="Activity.Current"/> to reflect the trace context from the given
20+
/// W3C <paramref name="traceparent"/> / <paramref name="tracestate"/> headers.
21+
/// The runtime already owns the <c>execute_tool</c> span; this just ensures
22+
/// user code runs under the correct parent so any child activities are properly parented.
23+
/// Dispose the returned <see cref="Activity"/> to restore the previous <see cref="Activity.Current"/>.
24+
/// </summary>
25+
/// <remarks>
26+
/// Because this Activity is not created via an <see cref="ActivitySource"/>, it will not
27+
/// be sampled or exported by any standard OpenTelemetry exporter — it is invisible in
28+
/// trace backends. It exists only to carry the remote parent context through
29+
/// <see cref="Activity.Current"/> so that child activities created by user tool
30+
/// handlers are parented to the CLI's span.
31+
/// </remarks>
32+
internal static Activity? RestoreTraceContext(string? traceparent, string? tracestate)
33+
{
34+
if (traceparent is not null &&
35+
ActivityContext.TryParse(traceparent, tracestate, out ActivityContext parent))
36+
{
37+
Activity activity = new("copilot.tool_handler");
38+
activity.SetParentId(parent.TraceId, parent.SpanId, parent.TraceFlags);
39+
if (tracestate is not null)
40+
{
41+
activity.TraceStateString = tracestate;
42+
}
43+
44+
activity.Start();
45+
46+
return activity;
47+
}
48+
49+
return null;
50+
}
51+
}

dotnet/src/Types.cs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ protected CopilotClientOptions(CopilotClientOptions? other)
6363
Logger = other.Logger;
6464
LogLevel = other.LogLevel;
6565
Port = other.Port;
66+
Telemetry = other.Telemetry;
6667
UseLoggedInUser = other.UseLoggedInUser;
6768
UseStdio = other.UseStdio;
6869
OnListModels = other.OnListModels;
@@ -148,6 +149,12 @@ public string? GithubToken
148149
/// </summary>
149150
public Func<CancellationToken, Task<List<ModelInfo>>>? OnListModels { get; set; }
150151

152+
/// <summary>
153+
/// OpenTelemetry configuration for the CLI server.
154+
/// When set to a non-<see langword="null"/> instance, the CLI server is started with OpenTelemetry instrumentation enabled.
155+
/// </summary>
156+
public TelemetryConfig? Telemetry { get; set; }
157+
151158
/// <summary>
152159
/// Creates a shallow clone of this <see cref="CopilotClientOptions"/> instance.
153160
/// </summary>
@@ -163,6 +170,52 @@ public virtual CopilotClientOptions Clone()
163170
}
164171
}
165172

173+
/// <summary>
174+
/// OpenTelemetry configuration for the Copilot CLI server.
175+
/// </summary>
176+
public sealed class TelemetryConfig
177+
{
178+
/// <summary>
179+
/// OTLP exporter endpoint URL.
180+
/// </summary>
181+
/// <remarks>
182+
/// Maps to the <c>OTEL_EXPORTER_OTLP_ENDPOINT</c> environment variable.
183+
/// </remarks>
184+
public string? OtlpEndpoint { get; set; }
185+
186+
/// <summary>
187+
/// File path for the file exporter.
188+
/// </summary>
189+
/// <remarks>
190+
/// Maps to the <c>COPILOT_OTEL_FILE_EXPORTER_PATH</c> environment variable.
191+
/// </remarks>
192+
public string? FilePath { get; set; }
193+
194+
/// <summary>
195+
/// Exporter type (<c>"otlp-http"</c> or <c>"file"</c>).
196+
/// </summary>
197+
/// <remarks>
198+
/// Maps to the <c>COPILOT_OTEL_EXPORTER_TYPE</c> environment variable.
199+
/// </remarks>
200+
public string? ExporterType { get; set; }
201+
202+
/// <summary>
203+
/// Source name for telemetry spans.
204+
/// </summary>
205+
/// <remarks>
206+
/// Maps to the <c>COPILOT_OTEL_SOURCE_NAME</c> environment variable.
207+
/// </remarks>
208+
public string? SourceName { get; set; }
209+
210+
/// <summary>
211+
/// Whether to capture message content as part of telemetry.
212+
/// </summary>
213+
/// <remarks>
214+
/// Maps to the <c>OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT</c> environment variable.
215+
/// </remarks>
216+
public bool? CaptureContent { get; set; }
217+
}
218+
166219
/// <summary>
167220
/// Represents a binary result returned by a tool invocation.
168221
/// </summary>

dotnet/test/TelemetryTests.cs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
using System.Diagnostics;
6+
using Xunit;
7+
8+
namespace GitHub.Copilot.SDK.Test;
9+
10+
public class TelemetryTests
11+
{
12+
[Fact]
13+
public void TelemetryConfig_DefaultValues_AreNull()
14+
{
15+
var config = new TelemetryConfig();
16+
17+
Assert.Null(config.OtlpEndpoint);
18+
Assert.Null(config.FilePath);
19+
Assert.Null(config.ExporterType);
20+
Assert.Null(config.SourceName);
21+
Assert.Null(config.CaptureContent);
22+
}
23+
24+
[Fact]
25+
public void TelemetryConfig_CanSetAllProperties()
26+
{
27+
var config = new TelemetryConfig
28+
{
29+
OtlpEndpoint = "http://localhost:4318",
30+
FilePath = "/tmp/traces.json",
31+
ExporterType = "otlp-http",
32+
SourceName = "my-app",
33+
CaptureContent = true
34+
};
35+
36+
Assert.Equal("http://localhost:4318", config.OtlpEndpoint);
37+
Assert.Equal("/tmp/traces.json", config.FilePath);
38+
Assert.Equal("otlp-http", config.ExporterType);
39+
Assert.Equal("my-app", config.SourceName);
40+
Assert.True(config.CaptureContent);
41+
}
42+
43+
[Fact]
44+
public void CopilotClientOptions_Telemetry_DefaultsToNull()
45+
{
46+
var options = new CopilotClientOptions();
47+
48+
Assert.Null(options.Telemetry);
49+
}
50+
51+
[Fact]
52+
public void CopilotClientOptions_Clone_CopiesTelemetry()
53+
{
54+
var telemetry = new TelemetryConfig
55+
{
56+
OtlpEndpoint = "http://localhost:4318",
57+
ExporterType = "otlp-http"
58+
};
59+
60+
var options = new CopilotClientOptions { Telemetry = telemetry };
61+
var clone = options.Clone();
62+
63+
Assert.Same(telemetry, clone.Telemetry);
64+
}
65+
}

go/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,8 @@ client, err := copilot.NewClient(copilot.ClientOptions{
495495

496496
Trace context (`traceparent`/`tracestate`) is automatically propagated between the SDK and CLI on `CreateSession`, `ResumeSession`, and `Send` calls, and inbound when the CLI invokes tool handlers.
497497

498+
> **Note:** The current `ToolHandler` signature does not accept a `context.Context`, so the inbound trace context cannot be passed to handler code. Spans created inside a tool handler will not be automatically parented to the CLI's `execute_tool` span. A future version may add a context parameter.
499+
498500
Dependency: `go.opentelemetry.io/otel`
499501

500502
## User Input Requests

0 commit comments

Comments
 (0)