Skip to content
88 changes: 88 additions & 0 deletions dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,94 @@ var session = await client.CreateSessionAsync(new SessionConfig
});
```

## Observability

The SDK supports OpenTelemetry-based distributed tracing and metrics. Telemetry is disabled by default.

### Enabling Telemetry

Enable telemetry using one of these methods:

**Option 1: AppContext switch (recommended)**
```csharp
AppContext.SetSwitch("GitHub.Copilot.EnableOpenTelemetry", true);
```

**Option 2: Environment variable**
```bash
export GITHUB_COPILOT_ENABLE_OPEN_TELEMETRY=true
```

### Configuring OpenTelemetry

Configure your `TracerProvider` and `MeterProvider` to listen to the SDK:

```csharp
using OpenTelemetry;
using OpenTelemetry.Trace;
using OpenTelemetry.Metrics;

// Enable telemetry
AppContext.SetSwitch("GitHub.Copilot.EnableOpenTelemetry", true);

// Configure OpenTelemetry
services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddSource("GitHub.Copilot.SDK") // Traces
.AddConsoleExporter()) // Or your preferred exporter
.WithMetrics(metrics => metrics
.AddMeter("GitHub.Copilot.SDK") // Metrics
.AddConsoleExporter());
```

### Spans

The SDK emits the following spans following [GenAI Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/):

| Span Name | Description | Key Attributes |
|-----------|-------------|----------------|
| `copilot.session` | Root span for a session | `copilot.session.id`, `gen_ai.system`, `gen_ai.request.model` |
| `copilot.turn` | Assistant turn lifecycle | `copilot.turn.id` |
| `copilot.tool_execution` | Tool execution | `gen_ai.tool.name`, `gen_ai.tool.call_id`, `copilot.success` |
| `copilot.subagent` | Subagent execution | `copilot.subagent.name` |
| `copilot.hook` | Hook execution | `copilot.hook.type`, `copilot.hook.invocation_id` |
| `copilot.inference` | LLM inference call | `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `copilot.cost` |

### Metrics

The SDK emits the following metrics:

| Metric Name | Type | Description |
|-------------|------|-------------|
| `copilot.tokens.input` | Counter | Number of input tokens used |
| `copilot.tokens.output` | Counter | Number of output tokens generated |
| `copilot.cost.total` | Counter | Total cost of operations |
| `copilot.tool_executions` | Counter | Number of tool executions |
| `copilot.errors` | Counter | Number of errors |
| `copilot.duration` | Histogram | Duration of operations in milliseconds |

### Example Trace

```
copilot.session (root)
├── gen_ai.system = "github-copilot"
├── gen_ai.request.model = "gpt-4o"
├── copilot.session.id = "abc123"
├── copilot.turn
│ ├── copilot.turn.id = "turn-1"
│ │
│ ├── copilot.tool_execution
│ │ ├── gen_ai.tool.name = "file_edit"
│ │ ├── gen_ai.tool.call_id = "call-xyz"
│ │ └── copilot.success = true
│ │
│ └── copilot.inference
│ ├── gen_ai.usage.input_tokens = 500
│ ├── gen_ai.usage.output_tokens = 200
│ └── copilot.cost = 0.003
```

## Error Handling

```csharp
Expand Down
5 changes: 5 additions & 0 deletions dotnet/src/GitHub.Copilot.SDK.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,14 @@
<None Include="../README.md" Pack="true" PackagePath="/" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="GitHub.Copilot.SDK.Test" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" Version="10.1.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageReference Include="OpenTelemetry.Api" Version="1.11.2" />
<PackageReference Include="StreamJsonRpc" Version="2.24.84" PrivateAssets="compile" />
<PackageReference Include="System.Text.Json" Version="10.0.1" />
</ItemGroup>
Expand Down
7 changes: 7 additions & 0 deletions dotnet/src/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

using GitHub.Copilot.SDK.Telemetry;
using Microsoft.Extensions.AI;
using StreamJsonRpc;
using System.Text.Json;
Expand Down Expand Up @@ -48,6 +49,7 @@ public partial class CopilotSession : IAsyncDisposable
private readonly JsonRpc _rpc;
private PermissionHandler? _permissionHandler;
private readonly SemaphoreSlim _permissionHandlerLock = new(1, 1);
private readonly SessionTelemetryTracker _telemetryTracker;

/// <summary>
/// Gets the unique identifier for this session.
Expand Down Expand Up @@ -78,6 +80,7 @@ internal CopilotSession(string sessionId, JsonRpc rpc, string? workspacePath = n
SessionId = sessionId;
_rpc = rpc;
WorkspacePath = workspacePath;
_telemetryTracker = new SessionTelemetryTracker(sessionId);
}

/// <summary>
Expand Down Expand Up @@ -237,6 +240,9 @@ public IDisposable On(SessionEventHandler handler)
/// </remarks>
internal void DispatchEvent(SessionEvent sessionEvent)
{
// Record telemetry for the event
_telemetryTracker.ProcessEvent(sessionEvent);

foreach (var handler in _eventHandlers.ToArray())
{
// We allow handler exceptions to propagate so they are not lost
Expand Down Expand Up @@ -421,6 +427,7 @@ await _rpc.InvokeWithCancellationAsync<object>(

_eventHandlers.Clear();
_toolHandlers.Clear();
_telemetryTracker.Dispose();

await _permissionHandlerLock.WaitAsync();
try
Expand Down
234 changes: 234 additions & 0 deletions dotnet/src/Telemetry/CopilotTelemetry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

using System.Diagnostics;
using System.Diagnostics.Metrics;

namespace GitHub.Copilot.SDK.Telemetry;

/// <summary>
/// Provides OpenTelemetry instrumentation for the GitHub Copilot SDK.
/// </summary>
/// <remarks>
/// <para>
/// Telemetry is disabled by default. Enable it using one of these methods:
/// </para>
/// <list type="bullet">
/// <item>Set the AppContext switch: <c>AppContext.SetSwitch("GitHub.Copilot.EnableOpenTelemetry", true)</c></item>
/// <item>Set the environment variable: <c>GITHUB_COPILOT_ENABLE_OPEN_TELEMETRY=true</c></item>
/// </list>
/// <para>
/// Then configure your TracerProvider and MeterProvider to listen:
/// </para>
/// <code>
/// services.AddOpenTelemetry()
/// .WithTracing(tracing => tracing.AddSource("GitHub.Copilot.SDK"))
/// .WithMetrics(metrics => metrics.AddMeter("GitHub.Copilot.SDK"));
/// </code>
/// </remarks>
public static class CopilotTelemetry
{
private static readonly Lazy<bool> s_isEnabled = new(DetermineIfEnabled);

/// <summary>
/// Gets the ActivitySource for creating spans.
/// </summary>
internal static ActivitySource ActivitySource { get; } = new(
OpenTelemetryConstants.ActivitySourceName,
typeof(CopilotTelemetry).Assembly.GetName().Version?.ToString() ?? "1.0.0");

/// <summary>
/// Gets the Meter for recording metrics.
/// </summary>
internal static Meter Meter { get; } = new(
OpenTelemetryConstants.MeterName,
typeof(CopilotTelemetry).Assembly.GetName().Version?.ToString() ?? "1.0.0");

// Metrics instruments
internal static Counter<long> TokensInputCounter { get; } = Meter.CreateCounter<long>(
OpenTelemetryConstants.MetricTokensInput,
unit: "{token}",
description: "Number of input tokens used");

internal static Counter<long> TokensOutputCounter { get; } = Meter.CreateCounter<long>(
OpenTelemetryConstants.MetricTokensOutput,
unit: "{token}",
description: "Number of output tokens generated");

internal static Counter<double> CostCounter { get; } = Meter.CreateCounter<double>(
OpenTelemetryConstants.MetricCostTotal,
unit: "{dollar}",
description: "Total cost of operations");

internal static Counter<long> ToolExecutionsCounter { get; } = Meter.CreateCounter<long>(
OpenTelemetryConstants.MetricToolExecutions,
unit: "{execution}",
description: "Number of tool executions");

internal static Counter<long> ErrorsCounter { get; } = Meter.CreateCounter<long>(
OpenTelemetryConstants.MetricErrors,
unit: "{error}",
description: "Number of errors");

internal static Histogram<double> DurationHistogram { get; } = Meter.CreateHistogram<double>(
OpenTelemetryConstants.MetricDuration,
unit: "ms",
description: "Duration of operations in milliseconds");

/// <summary>
/// Gets a value indicating whether telemetry is enabled.
/// </summary>
public static bool IsEnabled => s_isEnabled.Value;

private static bool DetermineIfEnabled()
{
// Check AppContext switch first
if (AppContext.TryGetSwitch(OpenTelemetryConstants.EnableTelemetrySwitch, out var isEnabled))
{
return isEnabled;
}

// Fall back to environment variable
var envValue = Environment.GetEnvironmentVariable(OpenTelemetryConstants.EnableTelemetryEnvVar);
return string.Equals(envValue, "true", StringComparison.OrdinalIgnoreCase) ||
string.Equals(envValue, "1", StringComparison.Ordinal);
}

/// <summary>
/// Starts an activity (span) if telemetry is enabled and there are listeners.
/// </summary>
internal static Activity? StartActivity(string name, ActivityKind kind = ActivityKind.Internal)
{
if (!IsEnabled)
{
return null;
}

return ActivitySource.StartActivity(name, kind);
}

/// <summary>
/// Sets common GenAI attributes on an activity.
/// </summary>
internal static void SetGenAiAttributes(Activity? activity, string? model = null)
{
if (activity is null) return;

activity.SetTag(OpenTelemetryConstants.GenAiSystem, "github-copilot");

if (!string.IsNullOrEmpty(model))
{
activity.SetTag(OpenTelemetryConstants.GenAiRequestModel, model);
}
}

/// <summary>
/// Records token usage metrics.
/// </summary>
internal static void RecordTokenUsage(
long? inputTokens,
long? outputTokens,
double? cost,
string? model,
string? sessionId)
{
if (!IsEnabled) return;

var tags = new TagList
{
{ OpenTelemetryConstants.GenAiSystem, "github-copilot" }
};

if (!string.IsNullOrEmpty(model))
{
tags.Add(OpenTelemetryConstants.GenAiRequestModel, model);
}

if (!string.IsNullOrEmpty(sessionId))
{
tags.Add(OpenTelemetryConstants.CopilotSessionId, sessionId);
}

if (inputTokens.HasValue)
{
TokensInputCounter.Add(inputTokens.Value, tags);
}

if (outputTokens.HasValue)
{
TokensOutputCounter.Add(outputTokens.Value, tags);
}

if (cost.HasValue)
{
CostCounter.Add(cost.Value, tags);
}
}

/// <summary>
/// Records a tool execution metric.
/// </summary>
internal static void RecordToolExecution(string toolName, bool success, string? sessionId)
{
if (!IsEnabled) return;

var tags = new TagList
{
{ OpenTelemetryConstants.GenAiToolName, toolName },
{ OpenTelemetryConstants.CopilotSuccess, success }
};

if (!string.IsNullOrEmpty(sessionId))
{
tags.Add(OpenTelemetryConstants.CopilotSessionId, sessionId);
}

ToolExecutionsCounter.Add(1, tags);

if (!success)
{
ErrorsCounter.Add(1, tags);
}
}

/// <summary>
/// Records an error metric.
/// </summary>
internal static void RecordError(string errorType, string? sessionId)
{
if (!IsEnabled) return;

var tags = new TagList
{
{ "error.type", errorType }
};

if (!string.IsNullOrEmpty(sessionId))
{
tags.Add(OpenTelemetryConstants.CopilotSessionId, sessionId);
}

ErrorsCounter.Add(1, tags);
}

/// <summary>
/// Records duration metric.
/// </summary>
internal static void RecordDuration(double durationMs, string operationType, string? sessionId)
{
if (!IsEnabled) return;

var tags = new TagList
{
{ OpenTelemetryConstants.GenAiOperationName, operationType }
};

if (!string.IsNullOrEmpty(sessionId))
{
tags.Add(OpenTelemetryConstants.CopilotSessionId, sessionId);
}

DurationHistogram.Record(durationMs, tags);
}
}
Loading