diff --git a/README.md b/README.md
index be9b4694b..afa5e9f14 100644
--- a/README.md
+++ b/README.md
@@ -109,6 +109,7 @@ Please use the [GitHub Issues](https://github.com/github/copilot-sdk/issues) pag
- **[Getting Started](./docs/getting-started.md)** – Tutorial to get up and running
- **[Authentication](./docs/auth/index.md)** – GitHub OAuth, BYOK, and more
+- **[OpenTelemetry Instrumentation](./docs/opentelemetry-instrumentation.md)** – Built-in tracing and metrics following GenAI semantic conventions
- **[Cookbook](https://github.com/github/awesome-copilot/blob/main/cookbook/copilot-sdk)** – Practical recipes for common tasks across all languages
- **[More Resources](https://github.com/github/awesome-copilot/blob/main/collections/copilot-sdk.md)** – Additional examples, tutorials, and community resources
diff --git a/docs/opentelemetry-instrumentation.md b/docs/opentelemetry-instrumentation.md
index f0e1b2556..5f8c1dfc9 100644
--- a/docs/opentelemetry-instrumentation.md
+++ b/docs/opentelemetry-instrumentation.md
@@ -1,570 +1,394 @@
-# OpenTelemetry Instrumentation for Copilot SDK
+# OpenTelemetry Instrumentation
-This guide shows how to add OpenTelemetry tracing to your Copilot SDK applications using GenAI semantic conventions.
+The Copilot SDK includes built-in OpenTelemetry instrumentation following the [OpenTelemetry Semantic Conventions for Generative AI systems (v1.40)](https://opentelemetry.io/docs/specs/semconv/gen-ai/). Telemetry is **opt-in** — enable it by providing a `TelemetryConfig` when creating a client. The SDK automatically creates spans, records metrics, and emits span events for agent invocations and tool executions.
-## Overview
+## Quick Start
-The Copilot SDK emits session events as your agent processes requests. You can instrument your application to convert these events into OpenTelemetry spans and attributes following the [OpenTelemetry GenAI Semantic Conventions v1.34.0](https://opentelemetry.io/docs/specs/semconv/gen-ai/).
+
+Node.js / TypeScript
-## Installation
+Install the OpenTelemetry SDK packages (the `@opentelemetry/api` peer dependency is included with the Copilot SDK):
```bash
-pip install opentelemetry-sdk opentelemetry-api
+npm install @opentelemetry/sdk-trace-node @opentelemetry/sdk-trace-base @opentelemetry/sdk-metrics
```
-For exporting to observability backends:
+```typescript
+import { CopilotClient } from "@github/copilot-sdk";
+import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
+import { SimpleSpanProcessor, ConsoleSpanExporter } from "@opentelemetry/sdk-trace-base";
-```bash
-# Console output
-pip install opentelemetry-sdk
+// 1. Set up OpenTelemetry (your exporter of choice)
+const provider = new NodeTracerProvider();
+provider.addSpanProcessor(new SimpleSpanProcessor(new ConsoleSpanExporter()));
+provider.register();
-# Azure Monitor
-pip install azure-monitor-opentelemetry
+// 2. Enable built-in telemetry on the client
+const client = new CopilotClient({
+ telemetry: {}, // defaults are fine — or customize below
+});
+await client.start();
-# OTLP (Jaeger, Prometheus, etc.)
-pip install opentelemetry-exporter-otlp
+// 3. Use the SDK as usual — spans and metrics are emitted automatically
+const session = await client.createSession({ model: "gpt-5" });
+const response = await session.sendAndWait({ prompt: "Hello!" });
+
+await session.destroy();
+await client.stop();
```
-## Basic Setup
+
+
+
+Python
-### 1. Initialize OpenTelemetry
+Install the OpenTelemetry SDK packages:
+
+```bash
+pip install opentelemetry-sdk opentelemetry-api
+```
```python
+import asyncio
+from copilot import CopilotClient
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, ConsoleSpanExporter
-# Setup tracer provider
-tracer_provider = TracerProvider()
-trace.set_tracer_provider(tracer_provider)
+# 1. Set up OpenTelemetry
+provider = TracerProvider()
+provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
+trace.set_tracer_provider(provider)
+
+# 2. Enable built-in telemetry on the client
+client = CopilotClient({"telemetry": {}})
+await client.start()
-# Add exporter (console example)
-span_exporter = ConsoleSpanExporter()
-tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
+# 3. Use the SDK as usual
+session = await client.create_session({"model": "gpt-5"})
+response = await session.send_and_wait({"prompt": "Hello!"})
-# Get a tracer
-tracer = trace.get_tracer(__name__)
+await session.destroy()
+await client.stop()
```
-### 2. Create Spans Around Agent Operations
+
-```python
-from copilot import CopilotClient, PermissionHandler
-from copilot.generated.session_events import SessionEventType
-from opentelemetry import trace, context
-from opentelemetry.trace import SpanKind
+
+Go
-# Initialize client and start the CLI server
-client = CopilotClient()
-await client.start()
+Install the OpenTelemetry SDK packages:
-tracer = trace.get_tracer(__name__)
+```bash
+go get go.opentelemetry.io/otel
+go get go.opentelemetry.io/otel/sdk/trace
+go get go.opentelemetry.io/otel/exporters/stdout/stdouttrace
+```
-# Create a span for the agent invocation
-span_attrs = {
- "gen_ai.operation.name": "invoke_agent",
- "gen_ai.provider.name": "github.copilot",
- "gen_ai.agent.name": "my-agent",
- "gen_ai.request.model": "gpt-5",
-}
+```go
+package main
-span = tracer.start_span(
- name="invoke_agent my-agent",
- kind=SpanKind.CLIENT,
- attributes=span_attrs
-)
-token = context.attach(trace.set_span_in_context(span))
+import (
+ "context"
+ "log"
-try:
- # Create a session (model is set here, not on the client)
- session = await client.create_session({
- "model": "gpt-5",
- "on_permission_request": PermissionHandler.approve_all,
- })
+ copilot "github.com/github/copilot-sdk/go"
+ "go.opentelemetry.io/otel"
+ sdktrace "go.opentelemetry.io/otel/sdk/trace"
+ "go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
+)
- # Subscribe to events via callback
- def handle_event(event):
- if event.type == SessionEventType.ASSISTANT_USAGE:
- if event.data.model:
- span.set_attribute("gen_ai.response.model", event.data.model)
+func main() {
+ // 1. Set up OpenTelemetry
+ exporter, _ := stdouttrace.New()
+ tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter))
+ otel.SetTracerProvider(tp)
+ defer tp.Shutdown(context.Background())
- unsubscribe = session.on(handle_event)
+ // 2. Enable built-in telemetry on the client
+ client := copilot.NewClient(&copilot.ClientOptions{
+ Telemetry: &copilot.TelemetryConfig{},
+ })
+ if err := client.Start(context.Background()); err != nil {
+ log.Fatal(err)
+ }
+ defer client.Stop()
- # Send a message (returns a message ID)
- await session.send({"prompt": "Hello, world!"})
+ // 3. Use the SDK as usual
+ session, _ := client.CreateSession(context.Background(), &copilot.SessionConfig{
+ Model: "gpt-5",
+ })
+ defer session.Destroy()
- # Or send and wait for the session to become idle
- response = await session.send_and_wait({"prompt": "Hello, world!"})
-finally:
- context.detach(token)
- span.end()
- await client.stop()
+ session.SendAndWait(context.Background(), copilot.MessageOptions{
+ Prompt: "Hello!",
+ })
+}
```
-## Copilot SDK Event to GenAI Attribute Mapping
+
-The Copilot SDK emits `SessionEventType` events during agent execution. Subscribe to these events using `session.on(handler)`, which returns an unsubscribe function. Here's how to map these events to GenAI semantic convention attributes:
+
+.NET
-### Core Session Events
+Install the OpenTelemetry SDK packages:
-| SessionEventType | GenAI Attributes | Description |
-|------------------|------------------|-------------|
-| `SESSION_START` | - | Session initialization (mark span start) |
-| `SESSION_IDLE` | - | Session completed (mark span end) |
-| `SESSION_ERROR` | `error.type`, `error.message` | Error occurred |
+```bash
+dotnet add package OpenTelemetry
+dotnet add package OpenTelemetry.Exporter.Console
+```
-### Assistant Events
+
+```csharp
+using GitHub.Copilot.SDK;
+using OpenTelemetry;
+using OpenTelemetry.Trace;
+
+// 1. Set up OpenTelemetry — add the SDK's ActivitySource
+using var tracerProvider = Sdk.CreateTracerProviderBuilder()
+ .AddSource("github.copilot.sdk") // matches the default source name
+ .AddConsoleExporter()
+ .Build();
+
+// 2. Enable built-in telemetry on the client
+await using var client = new CopilotClient(new CopilotClientOptions
+{
+ Telemetry = new TelemetryConfig()
+});
+await client.StartAsync();
+
+// 3. Use the SDK as usual
+await using var session = await client.CreateSessionAsync(new SessionConfig
+{
+ Model = "gpt-5"
+});
+await session.SendAndWaitAsync(new MessageOptions { Prompt = "Hello!" });
+```
-| SessionEventType | GenAI Attributes | Description |
-|------------------|------------------|-------------|
-| `ASSISTANT_TURN_START` | - | Assistant begins processing |
-| `ASSISTANT_TURN_END` | - | Assistant finished processing |
-| `ASSISTANT_MESSAGE` | `gen_ai.output.messages` (event) | Final assistant message with complete content |
-| `ASSISTANT_MESSAGE_DELTA` | - | Streaming message chunk (optional to trace) |
-| `ASSISTANT_USAGE` | `gen_ai.usage.input_tokens`
`gen_ai.usage.output_tokens`
`gen_ai.response.model` | Token usage and model information |
-| `ASSISTANT_REASONING` | - | Reasoning content (optional to trace) |
-| `ASSISTANT_INTENT` | - | Assistant's understood intent |
+
-### Tool Execution Events
+## Configuration
-| SessionEventType | GenAI Attributes / Span | Description |
-|------------------|-------------------------|-------------|
-| `TOOL_EXECUTION_START` | Create child span:
- `gen_ai.tool.name`
- `gen_ai.tool.call.id`
- `gen_ai.operation.name`: `execute_tool`
- `gen_ai.tool.call.arguments` (opt-in) | Tool execution begins |
-| `TOOL_EXECUTION_COMPLETE` | On child span:
- `gen_ai.tool.call.result` (opt-in)
- `error.type` (if failed)
End child span | Tool execution finished |
-| `TOOL_EXECUTION_PARTIAL_RESULT` | - | Streaming tool result |
+All languages accept the same two options:
-### Model and Context Events
+| Option | Default | Description |
+|--------|---------|-------------|
+| `enableSensitiveData` | `false` | Include potentially sensitive data (message content, tool arguments/results, system instructions) in telemetry. Falls back to the `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` environment variable when not set. |
+| `sourceName` | `"github.copilot.sdk"` | Name used for the tracer and meter. Use this to distinguish multiple SDK instances or match your OpenTelemetry pipeline filters. |
-| SessionEventType | GenAI Attributes | Description |
-|------------------|------------------|-------------|
-| `SESSION_MODEL_CHANGE` | `gen_ai.request.model` | Model changed during session |
-| `SESSION_CONTEXT_CHANGED` | - | Context window modified |
-| `SESSION_TRUNCATION` | - | Context truncated |
+> **Language-specific option casing:**
+> Node.js uses `enableSensitiveData` / `sourceName` (camelCase).
+> Python uses `enable_sensitive_data` / `source_name` (snake_case).
+> Go uses `EnableSensitiveData` / `SourceName` (PascalCase).
+> .NET uses `EnableSensitiveData` / `SourceName` (PascalCase).
-## Detailed Event Mapping Examples
+### Enabling Sensitive Data
-### ASSISTANT_USAGE Event
+By default, message content, tool arguments, tool results, and system instructions are **not** included in telemetry to protect potentially sensitive data. To include them:
-When you receive an `ASSISTANT_USAGE` event, extract token usage:
+**Option 1 — Per-client configuration:**
-```python
-from copilot.generated.session_events import SessionEventType
-
-def handle_usage(event):
- if event.type == SessionEventType.ASSISTANT_USAGE:
- data = event.data
- if data.model:
- span.set_attribute("gen_ai.response.model", data.model)
- if data.input_tokens is not None:
- span.set_attribute("gen_ai.usage.input_tokens", int(data.input_tokens))
- if data.output_tokens is not None:
- span.set_attribute("gen_ai.usage.output_tokens", int(data.output_tokens))
-
-unsubscribe = session.on(handle_usage)
-await session.send({"prompt": "Hello"})
+
+```typescript
+// Node.js
+const client = new CopilotClient({
+ telemetry: { enableSensitiveData: true },
+});
```
-**Event Data Structure:**
```python
-@dataclass
-class Usage:
- input_tokens: float
- output_tokens: float
- cache_read_tokens: float
- cache_write_tokens: float
+# Python
+client = CopilotClient({"telemetry": {"enable_sensitive_data": True}})
```
-**Maps to GenAI Attributes:**
-- `input_tokens` → `gen_ai.usage.input_tokens`
-- `output_tokens` → `gen_ai.usage.output_tokens`
-- Response model → `gen_ai.response.model`
+
+```go
+// Go
+client := copilot.NewClient(&copilot.ClientOptions{
+ Telemetry: &copilot.TelemetryConfig{
+ EnableSensitiveData: copilot.Bool(true),
+ },
+})
+```
-### TOOL_EXECUTION_START / COMPLETE Events
+
+```csharp
+// .NET
+var client = new CopilotClient(new CopilotClientOptions
+{
+ Telemetry = new TelemetryConfig { EnableSensitiveData = true }
+});
+```
-Create child spans for each tool execution:
+**Option 2 — Environment variable (applies to all clients):**
-```python
-from opentelemetry.trace import SpanKind
-import json
-
-# Dictionary to track active tool spans
-tool_spans = {}
-
-def handle_tool_events(event):
- data = event.data
-
- if event.type == SessionEventType.TOOL_EXECUTION_START and data:
- call_id = data.tool_call_id or str(uuid.uuid4())
- tool_name = data.tool_name or "unknown"
-
- tool_attrs = {
- "gen_ai.tool.name": tool_name,
- "gen_ai.operation.name": "execute_tool",
- }
-
- if call_id:
- tool_attrs["gen_ai.tool.call.id"] = call_id
-
- # Optional: include tool arguments (may contain sensitive data)
- if data.arguments is not None:
- try:
- tool_attrs["gen_ai.tool.call.arguments"] = json.dumps(data.arguments)
- except Exception:
- tool_attrs["gen_ai.tool.call.arguments"] = str(data.arguments)
-
- tool_span = tracer.start_span(
- name=f"execute_tool {tool_name}",
- kind=SpanKind.CLIENT,
- attributes=tool_attrs
- )
- tool_token = context.attach(trace.set_span_in_context(tool_span))
- tool_spans[call_id] = (tool_span, tool_token)
-
- elif event.type == SessionEventType.TOOL_EXECUTION_COMPLETE and data:
- call_id = data.tool_call_id
- entry = tool_spans.pop(call_id, None) if call_id else None
-
- if entry:
- tool_span, tool_token = entry
-
- # Optional: include tool result (may contain sensitive data)
- if data.result is not None:
- try:
- result_str = json.dumps(data.result)
- except Exception:
- result_str = str(data.result)
- # Truncate to 512 chars to avoid huge spans
- tool_span.set_attribute("gen_ai.tool.call.result", result_str[:512])
-
- # Mark as error if tool failed
- if hasattr(data, "success") and data.success is False:
- tool_span.set_attribute("error.type", "tool_error")
-
- context.detach(tool_token)
- tool_span.end()
-
-unsubscribe = session.on(handle_tool_events)
-await session.send({"prompt": "What's the weather?"})
+```bash
+export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true
```
-**Tool Event Data:**
-- `tool_call_id` → `gen_ai.tool.call.id`
-- `tool_name` → `gen_ai.tool.name`
-- `arguments` → `gen_ai.tool.call.arguments` (opt-in)
-- `result` → `gen_ai.tool.call.result` (opt-in)
-
-### ASSISTANT_MESSAGE Event
+## Agent Attribution
-Capture the final message as a span event:
+You can associate sessions with a named agent for telemetry attribution using `agentName` and `agentDescription` on the session config. When set, the `invoke_agent` span includes `gen_ai.agent.name` and `gen_ai.agent.description` attributes.
-```python
-def handle_message(event):
- if event.type == SessionEventType.ASSISTANT_MESSAGE and event.data:
- if event.data.content:
- # Add as a span event (opt-in for content recording)
- span.add_event(
- "gen_ai.output.messages",
- attributes={
- "gen_ai.event.content": json.dumps({
- "role": "assistant",
- "content": event.data.content
- })
- }
- )
-
-unsubscribe = session.on(handle_message)
-await session.send({"prompt": "Tell me a joke"})
+
+```typescript
+// Node.js
+const session = await client.createSession({
+ model: "gpt-5",
+ agentName: "weather-bot",
+ agentDescription: "An agent that provides weather forecasts",
+});
```
-## Complete Example
-
+
```python
-import asyncio
-import json
-import uuid
-from copilot import CopilotClient, PermissionHandler
-from copilot.generated.session_events import SessionEventType
-from opentelemetry import trace, context
-from opentelemetry.trace import SpanKind
-from opentelemetry.sdk.trace import TracerProvider
-from opentelemetry.sdk.trace.export import SimpleSpanProcessor, ConsoleSpanExporter
+# Python
+session = await client.create_session({
+ "model": "gpt-5",
+ "agent_name": "weather-bot",
+ "agent_description": "An agent that provides weather forecasts",
+})
+```
-# Setup OpenTelemetry
-tracer_provider = TracerProvider()
-trace.set_tracer_provider(tracer_provider)
-tracer_provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
-tracer = trace.get_tracer(__name__)
-
-async def invoke_agent(prompt: str):
- """Invoke agent with full OpenTelemetry instrumentation."""
-
- # Create main span
- span_attrs = {
- "gen_ai.operation.name": "invoke_agent",
- "gen_ai.provider.name": "github.copilot",
- "gen_ai.agent.name": "example-agent",
- "gen_ai.request.model": "gpt-5",
- }
+
+```go
+// Go
+session, _ := client.CreateSession(ctx, &copilot.SessionConfig{
+ Model: "gpt-5",
+ AgentName: "weather-bot",
+ AgentDescription: "An agent that provides weather forecasts",
+})
+```
- span = tracer.start_span(
- name="invoke_agent example-agent",
- kind=SpanKind.CLIENT,
- attributes=span_attrs
- )
- token = context.attach(trace.set_span_in_context(span))
- tool_spans = {}
-
- try:
- client = CopilotClient()
- await client.start()
-
- session = await client.create_session({
- "model": "gpt-5",
- "on_permission_request": PermissionHandler.approve_all,
- })
-
- # Subscribe to events via callback
- def handle_event(event):
- data = event.data
-
- # Handle usage events
- if event.type == SessionEventType.ASSISTANT_USAGE and data:
- if data.model:
- span.set_attribute("gen_ai.response.model", data.model)
- if data.input_tokens is not None:
- span.set_attribute("gen_ai.usage.input_tokens", int(data.input_tokens))
- if data.output_tokens is not None:
- span.set_attribute("gen_ai.usage.output_tokens", int(data.output_tokens))
-
- # Handle tool execution
- elif event.type == SessionEventType.TOOL_EXECUTION_START and data:
- call_id = data.tool_call_id or str(uuid.uuid4())
- tool_name = data.tool_name or "unknown"
-
- tool_attrs = {
- "gen_ai.tool.name": tool_name,
- "gen_ai.operation.name": "execute_tool",
- "gen_ai.tool.call.id": call_id,
- }
-
- tool_span = tracer.start_span(
- name=f"execute_tool {tool_name}",
- kind=SpanKind.CLIENT,
- attributes=tool_attrs
- )
- tool_token = context.attach(trace.set_span_in_context(tool_span))
- tool_spans[call_id] = (tool_span, tool_token)
-
- elif event.type == SessionEventType.TOOL_EXECUTION_COMPLETE and data:
- call_id = data.tool_call_id
- entry = tool_spans.pop(call_id, None) if call_id else None
- if entry:
- tool_span, tool_token = entry
- context.detach(tool_token)
- tool_span.end()
-
- # Capture final message
- elif event.type == SessionEventType.ASSISTANT_MESSAGE and data:
- if data.content:
- print(f"Assistant: {data.content}")
-
- unsubscribe = session.on(handle_event)
-
- # Send message and wait for completion
- response = await session.send_and_wait({"prompt": prompt})
-
- span.set_attribute("gen_ai.response.finish_reasons", ["stop"])
- unsubscribe()
-
- except Exception as e:
- span.set_attribute("error.type", type(e).__name__)
- raise
- finally:
- # Clean up any unclosed tool spans
- for call_id, (tool_span, tool_token) in tool_spans.items():
- tool_span.set_attribute("error.type", "stream_aborted")
- context.detach(tool_token)
- tool_span.end()
-
- context.detach(token)
- span.end()
- await client.stop()
-
-# Run
-asyncio.run(invoke_agent("What's 2+2?"))
+
+```csharp
+// .NET
+var session = await client.CreateSessionAsync(new SessionConfig
+{
+ Model = "gpt-5",
+ AgentName = "weather-bot",
+ AgentDescription = "An agent that provides weather forecasts",
+});
```
-## Required Span Attributes
+## Emitted Telemetry
-According to OpenTelemetry GenAI semantic conventions, these attributes are **required** for agent invocation spans:
+### Spans
-| Attribute | Description | Example |
-|-----------|-------------|---------|
-| `gen_ai.operation.name` | Operation type | `invoke_agent`, `chat`, `execute_tool` |
-| `gen_ai.provider.name` | Provider identifier | `github.copilot` |
-| `gen_ai.request.model` | Model used for request | `gpt-5`, `gpt-4.1` |
+The SDK automatically creates the following spans:
-## Recommended Span Attributes
+#### `invoke_agent` (Client span)
-These attributes are **recommended** for better observability:
+Created on the first `send` / `sendAndWait` call after the session becomes idle and reused across subsequent `send` / `sendAndWait` calls in the same turn. Ends when a turn-ending event is emitted (e.g., `session.idle` or `session.error`). Named `invoke_agent {model}` when a model is known, or just `invoke_agent`.
-| Attribute | Description |
-|-----------|-------------|
-| `gen_ai.agent.id` | Unique agent identifier |
-| `gen_ai.agent.name` | Human-readable agent name |
-| `gen_ai.response.model` | Actual model used in response |
-| `gen_ai.usage.input_tokens` | Input tokens consumed |
-| `gen_ai.usage.output_tokens` | Output tokens generated |
-| `gen_ai.response.finish_reasons` | Completion reasons (e.g., `["stop"]`) |
+| Attribute | Description | Condition |
+|-----------|-------------|-----------|
+| `gen_ai.operation.name` | `"invoke_agent"` | Always |
+| `gen_ai.provider.name` | Provider name (e.g., `"github"`, `"openai"`, `"azure.ai.openai"`, `"anthropic"`) | Always |
+| `gen_ai.agent.id` | Session ID | Always |
+| `gen_ai.conversation.id` | Session ID | Always |
+| `gen_ai.request.model` | Requested model name | When model is set |
+| `gen_ai.response.model` | Actual model used (from usage event) | When reported |
+| `gen_ai.agent.name` | Agent name | When `agentName` is set |
+| `gen_ai.agent.description` | Agent description | When `agentDescription` is set |
+| `gen_ai.usage.input_tokens` | Input token count | When reported |
+| `gen_ai.usage.output_tokens` | Output token count | When reported |
+| `gen_ai.response.finish_reasons` | `["stop"]` or `["error"]` | At span end |
+| `server.address` | Provider host | When using custom provider |
+| `server.port` | Provider port | When using custom provider |
+| `error.type` | Error type name | On error |
+| `gen_ai.input.messages` | JSON input messages | When `enableSensitiveData` is true |
+| `gen_ai.output.messages` | JSON output messages | When `enableSensitiveData` is true |
+| `gen_ai.system_instructions` | System message content | When `enableSensitiveData` is true |
+| `gen_ai.tool.definitions` | JSON tool definitions | Always (non-sensitive) |
-## Content Recording
+#### `execute_tool` (Internal span)
-Recording message content and tool arguments/results is **optional** and should be opt-in since it may contain sensitive data.
+Created as a child of `invoke_agent` for each custom tool call. Named `execute_tool {toolName}`.
-### Environment Variable Control
+| Attribute | Description | Condition |
+|-----------|-------------|-----------|
+| `gen_ai.operation.name` | `"execute_tool"` | Always |
+| `gen_ai.tool.name` | Tool name | Always |
+| `gen_ai.tool.call.id` | Unique call ID | Always |
+| `gen_ai.tool.type` | `"function"` | Always |
+| `gen_ai.tool.description` | Tool description | When available |
+| `gen_ai.tool.call.arguments` | JSON arguments | When `enableSensitiveData` is true |
+| `gen_ai.tool.call.result` | JSON result | When `enableSensitiveData` is true |
+| `error.type` | Error type name | On error |
-```bash
-# Enable content recording
-export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true
-```
+### Metrics
-### Checking at Runtime
+The SDK records the following metrics (all using the configured `sourceName` as the meter name):
-
-```python
-import os
-
-def should_record_content():
- return os.getenv("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "false").lower() == "true"
+| Metric | Type | Unit | Description |
+|--------|------|------|-------------|
+| `gen_ai.client.operation.duration` | Histogram (float) | `s` | Duration of `invoke_agent` and `execute_tool` operations |
+| `gen_ai.client.token.usage` | Histogram (int) | `{token}` | Token usage per operation, with `gen_ai.token.type` attribute (`"input"` or `"output"`) |
-# Only add content if enabled
-if should_record_content() and event.data.content:
- span.add_event("gen_ai.output.messages", ...)
-```
+## Exporter Setup
-## MCP (Model Context Protocol) Tool Conventions
+The SDK uses the standard OpenTelemetry API — configure any exporter compatible with your language's OpenTelemetry SDK.
-For MCP-based tools, add these additional attributes following the [OpenTelemetry MCP semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/):
+### OTLP (Jaeger, Grafana, etc.)
-```python
-tool_attrs = {
- # Required
- "mcp.method.name": "tools/call",
-
- # Recommended
- "mcp.server.name": data.mcp_server_name,
- "mcp.session.id": session.session_id,
-
- # GenAI attributes
- "gen_ai.tool.name": data.mcp_tool_name,
- "gen_ai.operation.name": "execute_tool",
- "network.transport": "pipe", # Copilot SDK uses stdio
-}
-```
-
-## Span Naming Conventions
-
-Follow these patterns for span names:
-
-| Operation | Span Name Pattern | Example |
-|-----------|-------------------|---------|
-| Agent invocation | `invoke_agent {agent_name}` | `invoke_agent weather-bot` |
-| Chat | `chat` | `chat` |
-| Tool execution | `execute_tool {tool_name}` | `execute_tool fetch_weather` |
-| MCP tool | `tools/call {tool_name}` | `tools/call read_file` |
+```bash
+# Node.js
+npm install @opentelemetry/exporter-trace-otlp-http
-## Metrics
+# Python
+pip install opentelemetry-exporter-otlp
-You can also export metrics for token usage and operation duration:
+# Go
+go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp
-```python
-from opentelemetry import metrics
-from opentelemetry.sdk.metrics import MeterProvider
-from opentelemetry.sdk.metrics.export import ConsoleMetricExporter, PeriodicExportingMetricReader
-
-# Setup metrics
-reader = PeriodicExportingMetricReader(ConsoleMetricExporter())
-provider = MeterProvider(metric_readers=[reader])
-metrics.set_meter_provider(provider)
-
-meter = metrics.get_meter(__name__)
-
-# Create metrics
-operation_duration = meter.create_histogram(
- name="gen_ai.client.operation.duration",
- description="Duration of GenAI operations",
- unit="ms"
-)
+# .NET
+dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
+```
-token_usage = meter.create_counter(
- name="gen_ai.client.token.usage",
- description="Token usage count"
-)
+### Azure Monitor
-# Record metrics
-operation_duration.record(123.45, attributes={
- "gen_ai.operation.name": "invoke_agent",
- "gen_ai.request.model": "gpt-5",
-})
+
+```bash
+# Python
+pip install azure-monitor-opentelemetry
-token_usage.add(150, attributes={
- "gen_ai.token.type": "input",
- "gen_ai.operation.name": "invoke_agent",
-})
+# .NET
+dotnet add package Azure.Monitor.OpenTelemetry.Exporter
```
-## Azure Monitor Integration
-
-For production observability with Azure Monitor:
-
+
```python
+# Python — Azure Monitor
from azure.monitor.opentelemetry import configure_azure_monitor
-
-# Enable Azure Monitor
-connection_string = "InstrumentationKey=..."
-configure_azure_monitor(connection_string=connection_string)
-
-# Your instrumented code here
+configure_azure_monitor(connection_string="InstrumentationKey=...")
```
-View traces in the Azure Portal under your Application Insights resource → Tracing.
-
-## Best Practices
-
-1. **Always close spans**: Use try/finally blocks to ensure spans are ended even on errors
-2. **Set error attributes**: On exceptions, set `error.type` and optionally `error.message`
-3. **Use child spans for tools**: Create separate spans for each tool execution
-4. **Opt-in for content**: Only record message content and tool arguments when explicitly enabled
-5. **Truncate large values**: Limit tool results and arguments to reasonable sizes (e.g., 512 chars)
-6. **Set finish reasons**: Always set `gen_ai.response.finish_reasons` when the operation completes successfully
-7. **Include model info**: Capture both request and response model names
+
+```csharp
+// .NET — Azure Monitor
+using var tracerProvider = Sdk.CreateTracerProviderBuilder()
+ .AddSource("github.copilot.sdk")
+ .AddAzureMonitorTraceExporter(o => o.ConnectionString = "InstrumentationKey=...")
+ .Build();
+```
## Troubleshooting
### No spans appearing
-1. Verify tracer provider is set: `trace.set_tracer_provider(provider)`
-2. Add a span processor: `provider.add_span_processor(SimpleSpanProcessor(exporter))`
-3. Ensure spans are ended: Check for missing `span.end()` calls
-
-### Tool spans not showing as children
-
-Make sure to attach the tool span to the parent context:
-
-```python
-tool_token = context.attach(trace.set_span_in_context(tool_span))
-```
+1. Verify the OpenTelemetry provider is registered before creating the `CopilotClient`.
+2. Ensure your exporter's source/activity filter includes the SDK's source name (default: `"github.copilot.sdk"`). For .NET, this means calling `.AddSource("github.copilot.sdk")` on the tracer provider builder.
+3. Confirm `telemetry` is set on the client options — when omitted, no telemetry is emitted.
-### Context warnings in async code
+### Missing message content or tool arguments
-You may see "Failed to detach context" warnings in async streaming code. These are expected and don't affect tracing correctness.
+Sensitive attributes are gated behind `enableSensitiveData`. Set it to `true` in the `TelemetryConfig` or set the `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true` environment variable.
## References
- [OpenTelemetry GenAI Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/)
- [OpenTelemetry MCP Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/)
-- [OpenTelemetry Python SDK](https://opentelemetry.io/docs/instrumentation/python/)
-- [GenAI Semantic Conventions v1.34.0](https://opentelemetry.io/schemas/1.34.0)
- [Copilot SDK Documentation](https://github.com/github/copilot-sdk)
diff --git a/dotnet/README.md b/dotnet/README.md
index e71be8eb0..33110fe2d 100644
--- a/dotnet/README.md
+++ b/dotnet/README.md
@@ -79,6 +79,7 @@ new CopilotClient(CopilotClientOptions? options = null)
- `Logger` - `ILogger` instance for SDK logging
- `GitHubToken` - GitHub token for authentication. When provided, takes priority over other auth methods.
- `UseLoggedInUser` - Whether to use logged-in user for authentication (default: true, but false when `GitHubToken` is provided). Cannot be used with `CliUrl`.
+- `Telemetry` - OpenTelemetry instrumentation configuration (`TelemetryConfig`). When provided, enables automatic tracing and metrics following [GenAI semantic conventions](../docs/opentelemetry-instrumentation.md).
#### Methods
@@ -110,6 +111,8 @@ Create a new conversation session.
- `Provider` - Custom API provider configuration (BYOK)
- `Streaming` - Enable streaming of response chunks (default: false)
- `InfiniteSessions` - Configure automatic context compaction (see below)
+- `AgentName` - Agent name for telemetry attribution.
+- `AgentDescription` - Agent description for telemetry attribution.
- `OnUserInputRequest` - Handler for user input requests from the agent (enables ask_user tool). See [User Input Requests](#user-input-requests) section.
- `Hooks` - Hook handlers for session lifecycle events. See [Session Hooks](#session-hooks) section.
diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs
index cf6c5a29d..b13a34086 100644
--- a/dotnet/src/Client.cs
+++ b/dotnet/src/Client.cs
@@ -56,6 +56,7 @@ public partial class CopilotClient : IDisposable, IAsyncDisposable
private readonly ConcurrentDictionary _sessions = new();
private readonly CopilotClientOptions _options;
private readonly ILogger _logger;
+ private readonly CopilotTelemetry? _telemetry;
private Task? _connectionTask;
private bool _disposed;
private readonly int? _optionsPort;
@@ -123,6 +124,9 @@ public CopilotClient(CopilotClientOptions? options = null)
}
_logger = _options.Logger ?? NullLogger.Instance;
+ _telemetry = _options.Telemetry is { } telemetryConfig ?
+ new CopilotTelemetry(telemetryConfig) :
+ null;
// Parse CliUrl if provided
if (!string.IsNullOrEmpty(_options.CliUrl))
@@ -381,33 +385,13 @@ public async Task CreateSessionAsync(SessionConfig config, Cance
config.Hooks.OnSessionEnd != null ||
config.Hooks.OnErrorOccurred != null);
- var request = new CreateSessionRequest(
- config.Model,
- config.SessionId,
- config.ClientName,
- config.ReasoningEffort,
- config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
- config.SystemMessage,
- config.AvailableTools,
- config.ExcludedTools,
- config.Provider,
- (bool?)true,
- config.OnUserInputRequest != null ? true : null,
- hasHooks ? true : null,
- config.WorkingDirectory,
- config.Streaming is true ? true : null,
- config.McpServers,
- "direct",
- config.CustomAgents,
- config.ConfigDir,
- config.SkillDirectories,
- config.DisabledSkills,
- config.InfiniteSessions);
-
- var response = await InvokeRpcAsync(
- connection.Rpc, "session.create", [request], cancellationToken);
-
- var session = new CopilotSession(response.SessionId, connection.Rpc, response.WorkspacePath);
+ var sessionId = config.SessionId ?? Guid.NewGuid().ToString();
+
+ // Create and register the session before issuing the RPC so that
+ // events emitted by the CLI (e.g. session.start) are not dropped.
+ var session = new CopilotSession(sessionId, connection.Rpc, _telemetry, workspacePath: null,
+ config.Model, config.Provider, config.SystemMessage, config.Tools, config.Streaming,
+ config.AgentName, config.AgentDescription);
session.RegisterTools(config.Tools ?? []);
session.RegisterPermissionHandler(config.OnPermissionRequest);
if (config.OnUserInputRequest != null)
@@ -418,10 +402,42 @@ public async Task CreateSessionAsync(SessionConfig config, Cance
{
session.RegisterHooks(config.Hooks);
}
+ _sessions[sessionId] = session;
- if (!_sessions.TryAdd(response.SessionId, session))
+ try
+ {
+ var request = new CreateSessionRequest(
+ config.Model,
+ sessionId,
+ config.ClientName,
+ config.ReasoningEffort,
+ config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
+ config.SystemMessage,
+ config.AvailableTools,
+ config.ExcludedTools,
+ config.Provider,
+ (bool?)true,
+ config.OnUserInputRequest != null ? true : null,
+ hasHooks ? true : null,
+ config.WorkingDirectory,
+ config.Streaming is true ? true : null,
+ config.McpServers,
+ "direct",
+ config.CustomAgents,
+ config.ConfigDir,
+ config.SkillDirectories,
+ config.DisabledSkills,
+ config.InfiniteSessions);
+
+ var response = await InvokeRpcAsync(
+ connection.Rpc, "session.create", [request], cancellationToken);
+
+ session.WorkspacePath = response.WorkspacePath;
+ }
+ catch
{
- throw new InvalidOperationException($"Session {response.SessionId} already exists");
+ _sessions.TryRemove(sessionId, out _);
+ throw;
}
return session;
@@ -472,34 +488,11 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes
config.Hooks.OnSessionEnd != null ||
config.Hooks.OnErrorOccurred != null);
- var request = new ResumeSessionRequest(
- sessionId,
- config.ClientName,
- config.Model,
- config.ReasoningEffort,
- config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
- config.SystemMessage,
- config.AvailableTools,
- config.ExcludedTools,
- config.Provider,
- (bool?)true,
- config.OnUserInputRequest != null ? true : null,
- hasHooks ? true : null,
- config.WorkingDirectory,
- config.ConfigDir,
- config.DisableResume is true ? true : null,
- config.Streaming is true ? true : null,
- config.McpServers,
- "direct",
- config.CustomAgents,
- config.SkillDirectories,
- config.DisabledSkills,
- config.InfiniteSessions);
-
- var response = await InvokeRpcAsync(
- connection.Rpc, "session.resume", [request], cancellationToken);
-
- var session = new CopilotSession(response.SessionId, connection.Rpc, response.WorkspacePath);
+ // Create and register the session before issuing the RPC so that
+ // events emitted by the CLI (e.g. session.start) are not dropped.
+ var session = new CopilotSession(sessionId, connection.Rpc, _telemetry, workspacePath: null,
+ config.Model, config.Provider, config.SystemMessage, config.Tools, config.Streaming,
+ config.AgentName, config.AgentDescription);
session.RegisterTools(config.Tools ?? []);
session.RegisterPermissionHandler(config.OnPermissionRequest);
if (config.OnUserInputRequest != null)
@@ -510,9 +503,45 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes
{
session.RegisterHooks(config.Hooks);
}
+ _sessions[sessionId] = session;
+
+ try
+ {
+ var request = new ResumeSessionRequest(
+ sessionId,
+ config.ClientName,
+ config.Model,
+ config.ReasoningEffort,
+ config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
+ config.SystemMessage,
+ config.AvailableTools,
+ config.ExcludedTools,
+ config.Provider,
+ (bool?)true,
+ config.OnUserInputRequest != null ? true : null,
+ hasHooks ? true : null,
+ config.WorkingDirectory,
+ config.ConfigDir,
+ config.DisableResume is true ? true : null,
+ config.Streaming is true ? true : null,
+ config.McpServers,
+ "direct",
+ config.CustomAgents,
+ config.SkillDirectories,
+ config.DisabledSkills,
+ config.InfiniteSessions);
+
+ var response = await InvokeRpcAsync(
+ connection.Rpc, "session.resume", [request], cancellationToken);
+
+ session.WorkspacePath = response.WorkspacePath;
+ }
+ catch
+ {
+ _sessions.TryRemove(sessionId, out _);
+ throw;
+ }
- // Replace any existing session entry to ensure new config (like permission handler) is used
- _sessions[response.SessionId] = session;
return session;
}
@@ -1183,6 +1212,7 @@ public async ValueTask DisposeAsync()
if (_disposed) return;
_disposed = true;
await ForceStopAsync();
+ _telemetry?.Dispose();
}
private class RpcHandler(CopilotClient client)
@@ -1239,6 +1269,14 @@ public async Task OnToolCall(string sessionId,
});
}
+ using var activity = client._telemetry?.StartExecuteToolActivity(
+ toolName, toolCallId, tool.Description, arguments, session.GetTelemetryToolCallParentContext(toolCallId));
+ var telemetry = client._telemetry;
+ Stopwatch? stopwatch = telemetry is { OperationDurationHistogram.Enabled: true } ?
+ Stopwatch.StartNew() :
+ null;
+ Exception? operationError = null;
+
try
{
var invocation = new ToolInvocation
@@ -1292,10 +1330,13 @@ public async Task OnToolCall(string sessionId,
? je.GetString()!
: JsonSerializer.Serialize(result, tool.JsonSerializerOptions.GetTypeInfo(typeof(object))),
};
+ client._telemetry?.SetExecuteToolResult(activity, result);
return new ToolCallResponse(toolResultObject);
}
catch (Exception ex)
{
+ operationError = ex;
+ CopilotTelemetry.RecordError(activity, ex);
return new ToolCallResponse(new()
{
// TODO: We should offer some way to control whether or not to expose detailed exception information to the LLM.
@@ -1305,6 +1346,21 @@ public async Task OnToolCall(string sessionId,
Error = ex.Message
});
}
+ finally
+ {
+ if (stopwatch is not null && telemetry is not null)
+ {
+ telemetry.RecordOperationDuration(
+ stopwatch.Elapsed.TotalSeconds,
+ requestModel: null,
+ responseModel: null,
+ providerName: session.TelemetryProviderName,
+ serverAddress: session.TelemetryServerAddress,
+ serverPort: session.TelemetryServerPort,
+ error: operationError,
+ operationName: OpenTelemetryConsts.GenAI.OperationNames.ExecuteTool);
+ }
+ }
}
public async Task OnPermissionRequest(string sessionId, JsonElement permissionRequest)
diff --git a/dotnet/src/CopilotTelemetry.cs b/dotnet/src/CopilotTelemetry.cs
new file mode 100644
index 000000000..46e9b0a84
--- /dev/null
+++ b/dotnet/src/CopilotTelemetry.cs
@@ -0,0 +1,1740 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ *--------------------------------------------------------------------------------------------*/
+
+using Microsoft.Extensions.AI;
+using System.Diagnostics;
+using System.Diagnostics.Metrics;
+using System.Globalization;
+using System.Reflection;
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+
+namespace GitHub.Copilot.SDK;
+
+///
+/// Provides OpenTelemetry instrumentation for the Copilot SDK, implementing
+/// the Semantic Conventions for Generative AI systems.
+///
+///
+///
+/// This class provides an implementation of the Semantic Conventions for Generative AI systems,
+/// defined at .
+/// The specification is still experimental and subject to change; as such, the telemetry output
+/// by this instrumentation is also subject to change.
+///
+///
+/// Telemetry is emitted using for traces and
+/// for metrics. No dependency on OpenTelemetry
+/// libraries is required. To collect the telemetry, configure an
+/// or use the OpenTelemetry SDK with the appropriate source name (default "github.copilot.sdk").
+///
+///
+internal sealed class CopilotTelemetry : IDisposable
+{
+ private static readonly JsonWriterOptions s_jsonWriterOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
+
+ private static readonly string? s_sdkVersion =
+ typeof(CopilotTelemetry).Assembly.GetCustomAttribute()?.InformationalVersion;
+
+ internal readonly ActivitySource ActivitySource;
+ private readonly Meter _meter;
+
+ internal readonly Histogram OperationDurationHistogram;
+ internal readonly Histogram TokenUsageHistogram;
+ internal readonly Histogram TimeToFirstChunkHistogram;
+ internal readonly Histogram TimePerOutputChunkHistogram;
+
+ ///
+ /// Gets or sets whether potentially sensitive data should be included in telemetry.
+ ///
+ public bool EnableSensitiveData { get; }
+
+ public CopilotTelemetry(TelemetryConfig? config)
+ {
+ string sourceName = config?.SourceName ?? OpenTelemetryConsts.DefaultSourceName;
+
+ EnableSensitiveData = config?.EnableSensitiveData ??
+ string.Equals(
+ Environment.GetEnvironmentVariable(OpenTelemetryConsts.CaptureMessageContentEnvVar),
+ "true",
+ StringComparison.OrdinalIgnoreCase);
+
+ ActivitySource = new ActivitySource(sourceName, s_sdkVersion);
+ _meter = new Meter(sourceName, s_sdkVersion);
+
+ OperationDurationHistogram = _meter.CreateHistogram(
+ OpenTelemetryConsts.GenAI.Client.OperationDuration.Name,
+ OpenTelemetryConsts.SecondsUnit,
+ OpenTelemetryConsts.GenAI.Client.OperationDuration.Description,
+ advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries });
+
+ TokenUsageHistogram = _meter.CreateHistogram(
+ OpenTelemetryConsts.GenAI.Client.TokenUsage.Name,
+ OpenTelemetryConsts.TokensUnit,
+ OpenTelemetryConsts.GenAI.Client.TokenUsage.Description,
+ advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TokenUsage.ExplicitBucketBoundaries });
+
+ TimeToFirstChunkHistogram = _meter.CreateHistogram(
+ OpenTelemetryConsts.GenAI.Client.TimeToFirstChunk.Name,
+ OpenTelemetryConsts.SecondsUnit,
+ OpenTelemetryConsts.GenAI.Client.TimeToFirstChunk.Description,
+ advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TimeToFirstChunk.ExplicitBucketBoundaries });
+
+ TimePerOutputChunkHistogram = _meter.CreateHistogram(
+ OpenTelemetryConsts.GenAI.Client.TimePerOutputChunk.Name,
+ OpenTelemetryConsts.SecondsUnit,
+ OpenTelemetryConsts.GenAI.Client.TimePerOutputChunk.Description,
+ advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TimePerOutputChunk.ExplicitBucketBoundaries });
+ }
+
+ /// Starts an invoke_agent activity for a session turn.
+ public Activity? StartInvokeAgentActivity(
+ string sessionId,
+ string? model,
+ string providerName,
+ string? serverAddress,
+ int? serverPort,
+ string? agentName = null,
+ string? agentDescription = null,
+ ActivityContext parentContext = default)
+ {
+ if (!ActivitySource.HasListeners())
+ {
+ return null;
+ }
+
+ ActivityTagsCollection tags = new()
+ {
+ { OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.OperationNames.InvokeAgent },
+ { OpenTelemetryConsts.GenAI.Provider.Name, providerName },
+ { OpenTelemetryConsts.GenAI.Agent.Id, sessionId },
+ { OpenTelemetryConsts.GenAI.Conversation.Id, sessionId },
+ };
+
+ AddIfNotEmpty(tags, OpenTelemetryConsts.GenAI.Request.Model, model);
+ AddIfNotEmpty(tags, OpenTelemetryConsts.GenAI.Agent.Name, agentName);
+ AddIfNotEmpty(tags, OpenTelemetryConsts.GenAI.Agent.Description, agentDescription);
+
+ if (!string.IsNullOrWhiteSpace(serverAddress))
+ {
+ tags.Add(OpenTelemetryConsts.Server.Address, serverAddress);
+ AddIfNotNull(tags, OpenTelemetryConsts.Server.Port, serverPort);
+ }
+
+ string displayName = string.IsNullOrWhiteSpace(agentName)
+ ? OpenTelemetryConsts.GenAI.OperationNames.InvokeAgent
+ : $"{OpenTelemetryConsts.GenAI.OperationNames.InvokeAgent} {agentName}";
+
+ return ActivitySource.StartActivity(displayName, ActivityKind.Client, parentContext, tags);
+ }
+
+ /// Starts a chat activity for an individual LLM turn within an invoke_agent span.
+ public Activity? StartChatActivity(
+ string? model,
+ string providerName,
+ string? serverAddress,
+ int? serverPort,
+ ActivityContext parentContext,
+ string? conversationId = null)
+ {
+ if (!ActivitySource.HasListeners())
+ {
+ return null;
+ }
+
+ ActivityTagsCollection tags = new()
+ {
+ { OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.OperationNames.Chat },
+ { OpenTelemetryConsts.GenAI.Provider.Name, providerName },
+ };
+
+ AddIfNotEmpty(tags, OpenTelemetryConsts.GenAI.Request.Model, model);
+ AddIfNotEmpty(tags, OpenTelemetryConsts.GenAI.Conversation.Id, conversationId);
+
+ if (!string.IsNullOrWhiteSpace(serverAddress))
+ {
+ tags.Add(OpenTelemetryConsts.Server.Address, serverAddress);
+ AddIfNotNull(tags, OpenTelemetryConsts.Server.Port, serverPort);
+ }
+
+ string displayName = string.IsNullOrWhiteSpace(model)
+ ? OpenTelemetryConsts.GenAI.OperationNames.Chat
+ : $"{OpenTelemetryConsts.GenAI.OperationNames.Chat} {model}";
+
+ return ActivitySource.StartActivity(displayName, ActivityKind.Client, parentContext, tags);
+ }
+
+ /// Starts an execute_tool activity for a tool call.
+ public Activity? StartExecuteToolActivity(string toolName, string toolCallId, string? description, object? arguments, ActivityContext parentContext = default)
+ {
+ if (!ActivitySource.HasListeners())
+ {
+ return null;
+ }
+
+ ActivityTagsCollection tags = new()
+ {
+ { OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.OperationNames.ExecuteTool },
+ { OpenTelemetryConsts.GenAI.Tool.Name, toolName },
+ { OpenTelemetryConsts.GenAI.Tool.CallId, toolCallId },
+ { OpenTelemetryConsts.GenAI.Tool.Type, "function" },
+ };
+
+ AddIfNotEmpty(tags, OpenTelemetryConsts.GenAI.Tool.Description, description);
+
+ if (arguments is not null && EnableSensitiveData)
+ {
+ tags.Add(OpenTelemetryConsts.GenAI.Tool.CallArguments, SerializeTagValue(arguments));
+ }
+
+ string displayName = $"{OpenTelemetryConsts.GenAI.OperationNames.ExecuteTool} {toolName}";
+
+ return ActivitySource.StartActivity(displayName, ActivityKind.Internal, parentContext, tags);
+ }
+
+ /// Records token usage metrics at turn completion (so error.type can be included).
+ public void RecordTokenUsageMetrics(
+ int? inputTokens,
+ int? outputTokens,
+ string? requestModel,
+ string? responseModel,
+ string providerName,
+ string? serverAddress,
+ int? serverPort,
+ Exception? error,
+ string operationName)
+ {
+ if (TokenUsageHistogram.Enabled)
+ {
+ if (inputTokens is int inputCount)
+ {
+ TagList tags = CreateMetricTags(operationName, requestModel, responseModel, providerName, serverAddress, serverPort, error);
+ tags.Add(OpenTelemetryConsts.GenAI.Token.Type, OpenTelemetryConsts.TokenTypeInput);
+ TokenUsageHistogram.Record(inputCount, tags);
+ }
+
+ if (outputTokens is int outputCount)
+ {
+ TagList tags = CreateMetricTags(operationName, requestModel, responseModel, providerName, serverAddress, serverPort, error);
+ tags.Add(OpenTelemetryConsts.GenAI.Token.Type, OpenTelemetryConsts.TokenTypeOutput);
+ TokenUsageHistogram.Record(outputCount, tags);
+ }
+ }
+ }
+
+ /// Records operation duration metric.
+ public void RecordOperationDuration(
+ double durationSeconds,
+ string? requestModel,
+ string? responseModel,
+ string providerName,
+ string? serverAddress,
+ int? serverPort,
+ Exception? error,
+ string operationName)
+ {
+ if (OperationDurationHistogram.Enabled)
+ {
+ OperationDurationHistogram.Record(
+ durationSeconds,
+ CreateMetricTags(operationName, requestModel, responseModel, providerName, serverAddress, serverPort, error));
+ }
+ }
+
+ public void RecordTimeToFirstChunk(
+ double durationSeconds,
+ string? requestModel,
+ string? responseModel,
+ string providerName,
+ string? serverAddress,
+ int? serverPort)
+ {
+ if (TimeToFirstChunkHistogram.Enabled)
+ {
+ TimeToFirstChunkHistogram.Record(
+ durationSeconds,
+ CreateMetricTags(
+ OpenTelemetryConsts.GenAI.OperationNames.Chat,
+ requestModel,
+ responseModel,
+ providerName,
+ serverAddress,
+ serverPort));
+ }
+ }
+
+ public void RecordTimePerOutputChunk(
+ double durationSeconds,
+ string? requestModel,
+ string? responseModel,
+ string providerName,
+ string? serverAddress,
+ int? serverPort)
+ {
+ if (TimePerOutputChunkHistogram.Enabled)
+ {
+ TimePerOutputChunkHistogram.Record(
+ durationSeconds,
+ CreateMetricTags(
+ OpenTelemetryConsts.GenAI.OperationNames.Chat,
+ requestModel,
+ responseModel,
+ providerName,
+ serverAddress,
+ serverPort));
+ }
+ }
+
+ public void SetExecuteToolResult(Activity? activity, object? result)
+ {
+ if (result is not null &&
+ EnableSensitiveData &&
+ activity is { IsAllDataRequested: true })
+ {
+ activity.SetTag(OpenTelemetryConsts.GenAI.Tool.CallResult, SerializeTagValue(result));
+ }
+ }
+
+ /// Records an error on an activity.
+ public static void RecordError(Activity? activity, Exception error)
+ {
+ activity?
+ .SetTag(OpenTelemetryConsts.Error.Type, error.GetType().Name)
+ .SetStatus(ActivityStatusCode.Error, error.Message);
+ }
+
+ ///
+ /// Normalizes a provider type string to its OpenTelemetry semantic convention name.
+ /// Only the providers supported by BYOK are mapped; all others default to "github".
+ ///
+ private static string NormalizeProviderName(string? providerType)
+ {
+ return providerType?.Trim().ToLowerInvariant() switch
+ {
+ "anthropic" => "anthropic",
+ "azure" => "azure.ai.openai",
+ "openai" => "openai",
+ _ => OpenTelemetryConsts.DefaultProviderName,
+ };
+ }
+
+ private static (string? Address, int? Port) ParseServerAddress(string? baseUrl)
+ {
+ if (!string.IsNullOrWhiteSpace(baseUrl) &&
+ Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri) &&
+ !string.IsNullOrWhiteSpace(uri.Host))
+ {
+ return (uri.Host, uri.Port > 0 ? uri.Port : null);
+ }
+
+ return (null, null);
+ }
+
+ private static void AddIfNotEmpty(ActivityTagsCollection tags, string key, string? value)
+ {
+ if (!string.IsNullOrWhiteSpace(value))
+ {
+ tags.Add(key, value);
+ }
+ }
+
+ private static void AddIfNotNull(ActivityTagsCollection tags, string key, int? value)
+ {
+ if (value is int v)
+ {
+ tags.Add(key, v);
+ }
+ }
+
+ private static void AddAsInt64IfNotZero(ActivityTagsCollection tags, string key, double value)
+ {
+ if (value != 0)
+ {
+ tags.Add(key, (long)value);
+ }
+ }
+
+ private static void AddAsInt64IfNotNull(ActivityTagsCollection tags, string key, double? value)
+ {
+ if (value is double d)
+ {
+ tags.Add(key, (long)d);
+ }
+ }
+
+ private static TagList CreateMetricTags(
+ string operationName,
+ string? requestModel,
+ string? responseModel,
+ string providerName,
+ string? serverAddress,
+ int? serverPort,
+ Exception? error = null)
+ {
+ TagList tags = default;
+ tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, operationName);
+ tags.Add(OpenTelemetryConsts.GenAI.Provider.Name, providerName);
+
+ if (!string.IsNullOrWhiteSpace(requestModel))
+ {
+ tags.Add(OpenTelemetryConsts.GenAI.Request.Model, requestModel);
+ }
+
+ if (!string.IsNullOrWhiteSpace(responseModel))
+ {
+ tags.Add(OpenTelemetryConsts.GenAI.Response.Model, responseModel);
+ }
+
+ if (!string.IsNullOrWhiteSpace(serverAddress))
+ {
+ tags.Add(OpenTelemetryConsts.Server.Address, serverAddress);
+ if (serverPort is int port)
+ {
+ tags.Add(OpenTelemetryConsts.Server.Port, port);
+ }
+ }
+
+ if (error is not null)
+ {
+ tags.Add(OpenTelemetryConsts.Error.Type, error.GetType().Name);
+ }
+
+ return tags;
+ }
+
+ private static string SerializeTagValue(object value)
+ {
+ return value switch
+ {
+ JsonElement jsonElement => jsonElement.GetRawText(),
+ string text => text,
+ bool boolean => boolean ? "true" : "false",
+ float number => number.ToString("R", CultureInfo.InvariantCulture),
+ double number => number.ToString("R", CultureInfo.InvariantCulture),
+ IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
+ _ => value.ToString() ?? string.Empty,
+ };
+ }
+
+ public void Dispose()
+ {
+ ActivitySource.Dispose();
+ _meter.Dispose();
+ }
+
+ ///
+ /// Tracks telemetry state for a single session, managing the invoke_agent span
+ /// lifecycle across Send / DispatchEvent / turn-completion boundaries.
+ /// All public methods are thread-safe.
+ ///
+ public sealed class AgentTurnTracker
+ {
+ private readonly CopilotTelemetry _telemetry;
+ private readonly string _sessionId;
+ private readonly object _lock = new();
+
+ private readonly string? _requestModel;
+ private readonly string? _agentName;
+ private readonly string? _agentDescription;
+ private readonly string? _systemInstructionsJson;
+ private readonly string? _toolDefinitionsJson;
+ private readonly bool _isStreaming;
+
+ // Per-invoke_agent mutable state; guarded by _lock.
+ private Activity? _agentActivity;
+ private long _agentTimestamp;
+ private List? _agentInputMessages;
+ private List? _agentOutputMessages;
+ private Dictionary? _activeSubagents;
+
+ // Pending tool call parent contexts; guarded by _lock.
+ // Tool execute_tool spans are created in OnToolCall (not from ToolExecutionStartEvent)
+ // because OnToolCall is where the AIFunction actually runs — creating the Activity
+ // there makes it Activity.Current during execution so child spans parent correctly,
+ // and the span measures actual SDK-side tool execution time.
+ // ToolExecutionStartEvent carries ParentToolCallId (which identifies the owning
+ // subagent), so we stash the correct parent context here for OnToolCall to consume.
+ private Dictionary? _pendingToolParents;
+
+ // Tracks tool call IDs that originated from MCP server tools, mapping to
+ // the MCP server name, so that ToolExecutionCompleteEvent can emit the
+ // correct message type with the right server_tool_call_response discriminator.
+ private Dictionary? _serverToolCallIds;
+
+ // Agent-level accumulated usage; guarded by _lock.
+ // Tracks totals across all chat turns for the invoke_agent span.
+ private string? _agentResponseModel;
+ private string? _agentResponseId;
+ private int _agentTotalInputTokens;
+ private int _agentTotalOutputTokens;
+ private int _agentTotalCacheReadTokens;
+ private int _agentTotalCacheCreationTokens;
+ private double _agentTotalCost;
+ private double _agentTotalAiu;
+
+ // Per-chat-turn mutable state; guarded by _lock.
+ // Reset on each AssistantTurnStartEvent.
+ private Activity? _turnActivity;
+ private long _turnTimestamp;
+ private bool _firstOutputChunkRecorded;
+ private TimeSpan _lastOutputChunkElapsed;
+ private string? _responseModel;
+ private string? _responseId;
+ private int _inputTokens;
+ private int _outputTokens;
+ private int _cacheReadTokens;
+ private int _cacheCreationTokens;
+ private List? _inputMessages;
+ private List? _outputMessages;
+
+ // Copilot-specific per-turn attributes from AssistantUsageData.
+ private double? _turnCost;
+ private double? _turnServerDuration;
+ private string? _turnInitiator;
+ private double? _turnAiu;
+ private string? _turnId;
+ private string? _turnInteractionId;
+
+ internal AgentTurnTracker(
+ CopilotTelemetry telemetry,
+ string sessionId,
+ string? model,
+ ProviderConfig? provider,
+ SystemMessageConfig? systemMessage,
+ ICollection? tools,
+ bool streaming,
+ string? agentName = null,
+ string? agentDescription = null)
+ {
+ _telemetry = telemetry;
+ _sessionId = sessionId;
+ _requestModel = model;
+ _agentName = agentName;
+ _agentDescription = agentDescription;
+ ProviderName = NormalizeProviderName(provider?.Type);
+ (ServerAddress, ServerPort) = ParseServerAddress(provider?.BaseUrl);
+ _systemInstructionsJson = BuildSystemInstructionsJson(systemMessage);
+ _toolDefinitionsJson = BuildToolDefinitionsJson(tools);
+ _isStreaming = streaming;
+ }
+
+ internal string ProviderName { get; }
+
+ internal string? ServerAddress { get; }
+
+ internal int? ServerPort { get; }
+
+ /// Gets the of the current invoke_agent activity, if any.
+ internal ActivityContext GetActivityContext()
+ {
+ lock (_lock)
+ {
+ return _agentActivity?.Context ?? default;
+ }
+ }
+
+ ///
+ /// Gets the parent for a tool call, which may differ
+ /// from the root invoke_agent when a subagent initiated the tool call.
+ /// Consumes the stored context (one-time use).
+ ///
+ internal ActivityContext GetToolCallParentContext(string toolCallId)
+ {
+ lock (_lock)
+ {
+ return _pendingToolParents is not null && _pendingToolParents.Remove(toolCallId, out var ctx)
+ ? ctx
+ : _agentActivity?.Context ?? default;
+ }
+ }
+
+ ///
+ /// Closes any active spans with an error status. Called when the session is disposed
+ /// while a turn may still be in progress, ensuring spans are not orphaned.
+ ///
+ internal void CompleteOnDispose()
+ {
+ lock (_lock)
+ {
+ if (_agentActivity is not null)
+ {
+ var disposeError = new ObjectDisposedException("Session disposed while agent turn was in progress");
+ CompleteChatTurn(disposeError);
+ CompleteAgentTurn(disposeError);
+ }
+ }
+ }
+
+ ///
+ /// Processes a dispatched session event, enriching the current span and
+ /// completing the turn on idle/error events.
+ ///
+ internal void ProcessEvent(SessionEvent sessionEvent)
+ {
+ lock (_lock)
+ {
+ // A UserMessageEvent starts a new invoke_agent span (if not already
+ // active) and records the user prompt.
+ if (sessionEvent is UserMessageEvent userMsg)
+ {
+ var prompt = userMsg.Data?.Content;
+ EnsureAgentSpan();
+
+ if (!string.IsNullOrWhiteSpace(prompt))
+ {
+ var msg = new OtelMsg("user", [new("text", Content: prompt)]);
+ _agentInputMessages?.Add(msg);
+ (_inputMessages ??= []).Add(msg);
+ }
+
+ return;
+ }
+
+ // Route subagent events by ParentToolCallId.
+ var parentToolCallId = GetParentToolCallId(sessionEvent);
+ if (!string.IsNullOrEmpty(parentToolCallId))
+ {
+ if (_activeSubagents?.TryGetValue(parentToolCallId, out var subagentState) is true)
+ {
+ ProcessSubagentEvent(subagentState, sessionEvent);
+ }
+
+ return;
+ }
+
+ // Handle subagent lifecycle events.
+ switch (sessionEvent)
+ {
+ case SubagentStartedEvent started:
+ BeginSubagent(started);
+ return;
+
+ case SubagentCompletedEvent completed when completed.Data is not null:
+ CompleteSubagent(completed.Data.ToolCallId, error: null);
+ return;
+
+ case SubagentFailedEvent failed when failed.Data is not null:
+ CompleteSubagent(failed.Data.ToolCallId,
+ new InvalidOperationException($"Subagent '{failed.Data.AgentName}' failed: {failed.Data.Error}"));
+ return;
+ }
+
+ // Record chunk timing for main agent events during a turn.
+ RecordOutputChunkMetric();
+
+ // Per-turn event processing (writes to the chat child span).
+ if (_turnActivity is not null)
+ {
+ switch (sessionEvent)
+ {
+ case AssistantMessageEvent messageEvent:
+ {
+ List parts = [];
+ if (!string.IsNullOrWhiteSpace(messageEvent.Data?.ReasoningText))
+ {
+ parts.Add(new("reasoning", Content: messageEvent.Data.ReasoningText));
+ }
+
+ if (!string.IsNullOrWhiteSpace(messageEvent.Data?.Content))
+ {
+ parts.Add(new("text", Content: messageEvent.Data.Content));
+ }
+
+ if (parts.Count > 0)
+ {
+ _outputMessages?.Add(new("assistant", parts));
+ }
+
+ break;
+ }
+
+ case AssistantUsageEvent usageEvent:
+ _responseModel = usageEvent.Data.Model;
+
+ if (!string.IsNullOrWhiteSpace(usageEvent.Data.ApiCallId))
+ {
+ _responseId = usageEvent.Data.ApiCallId;
+ }
+ else if (!string.IsNullOrWhiteSpace(usageEvent.Data.ProviderCallId))
+ {
+ _responseId = usageEvent.Data.ProviderCallId;
+ }
+
+ _inputTokens += usageEvent.Data.InputTokens is double inTok ? (int)inTok : 0;
+ _outputTokens += usageEvent.Data.OutputTokens is double outTok ? (int)outTok : 0;
+ _cacheReadTokens += usageEvent.Data.CacheReadTokens is double cacheRead ? (int)cacheRead : 0;
+ _cacheCreationTokens += usageEvent.Data.CacheWriteTokens is double cacheWrite ? (int)cacheWrite : 0;
+
+ // Copilot-specific vendor attributes
+ if (usageEvent.Data.Cost is double cost)
+ {
+ _turnCost = (_turnCost ?? 0) + cost;
+ }
+
+ if (usageEvent.Data.Duration is double dur)
+ {
+ _turnServerDuration = (_turnServerDuration ?? 0) + dur;
+ }
+
+ if (!string.IsNullOrWhiteSpace(usageEvent.Data.Initiator))
+ {
+ _turnInitiator = usageEvent.Data.Initiator;
+ }
+
+ if (usageEvent.Data.CopilotUsage is { } copilotUsage)
+ {
+ _turnAiu = (_turnAiu ?? 0) + copilotUsage.TotalNanoAiu;
+ }
+ break;
+
+ case SessionModelChangeEvent modelChangeEvent:
+ _responseModel = modelChangeEvent.Data.NewModel;
+ break;
+
+ case ToolExecutionStartEvent { Data: { } startData }:
+ {
+ var isServerTool = startData.McpServerName is not null;
+ if (isServerTool && startData.ToolCallId is not null)
+ {
+ (_serverToolCallIds ??= [])[startData.ToolCallId] = startData.McpServerName!;
+ }
+
+ _outputMessages?.Add(new("assistant",
+ [
+ new(isServerTool ? "server_tool_call" : "tool_call",
+ Id: startData.ToolCallId,
+ Name: startData.ToolName,
+ Arguments: startData.Arguments,
+ McpServerName: startData.McpServerName)
+ ]));
+
+ // For main agent tool calls, parent is the root invoke_agent.
+ if (_agentActivity is not null && startData.ToolCallId is not null)
+ {
+ _pendingToolParents ??= [];
+ _pendingToolParents[startData.ToolCallId] = _agentActivity.Context;
+ }
+
+ break;
+ }
+
+ case ToolExecutionCompleteEvent { Data: { } toolData }:
+ {
+ string? serverName = null;
+ var isServerTool = _serverToolCallIds is not null && _serverToolCallIds.Remove(toolData.ToolCallId, out serverName);
+
+ _inputMessages?.Add(new("tool",
+ [
+ new(isServerTool ? "server_tool_call_response" : "tool_call_response",
+ Id: toolData.ToolCallId,
+ Response: toolData.Result?.Content ?? toolData.Error?.Message,
+ McpServerName: serverName)
+ ]));
+
+ break;
+ }
+ }
+ }
+
+ // Copilot-specific lifecycle events emitted as span events on the
+ // current activity (chat turn if active, otherwise invoke_agent).
+ {
+ var target = _turnActivity ?? _agentActivity;
+ if (target is not null)
+ {
+ switch (sessionEvent)
+ {
+ case SessionTruncationEvent { Data: { } trunc }:
+ {
+ ActivityTagsCollection truncTags = [];
+
+ AddAsInt64IfNotZero(truncTags, OpenTelemetryConsts.GenAI.CopilotEvent.TokenLimit, trunc.TokenLimit);
+ AddAsInt64IfNotZero(truncTags, OpenTelemetryConsts.GenAI.CopilotEvent.PreTokens, trunc.PreTruncationTokensInMessages);
+ AddAsInt64IfNotZero(truncTags, OpenTelemetryConsts.GenAI.CopilotEvent.PostTokens, trunc.PostTruncationTokensInMessages);
+ AddAsInt64IfNotZero(truncTags, OpenTelemetryConsts.GenAI.CopilotEvent.PreMessages, trunc.PreTruncationMessagesLength);
+ AddAsInt64IfNotZero(truncTags, OpenTelemetryConsts.GenAI.CopilotEvent.PostMessages, trunc.PostTruncationMessagesLength);
+ AddAsInt64IfNotZero(truncTags, OpenTelemetryConsts.GenAI.CopilotEvent.TokensRemoved, trunc.TokensRemovedDuringTruncation);
+ AddAsInt64IfNotZero(truncTags, OpenTelemetryConsts.GenAI.CopilotEvent.MessagesRemoved, trunc.MessagesRemovedDuringTruncation);
+ if (trunc.PerformedBy is not null)
+ {
+ truncTags.Add(OpenTelemetryConsts.GenAI.CopilotEvent.PerformedBy, trunc.PerformedBy);
+ }
+
+ target.AddEvent(new(OpenTelemetryConsts.GenAI.CopilotEvent.SessionTruncation, tags: truncTags));
+ break;
+ }
+
+ case SessionCompactionStartEvent:
+ target.AddEvent(new(OpenTelemetryConsts.GenAI.CopilotEvent.SessionCompactionStart));
+ break;
+
+ case SessionCompactionCompleteEvent { Data: { } compaction }:
+ {
+ ActivityTagsCollection tags = new()
+ {
+ { OpenTelemetryConsts.GenAI.CopilotEvent.Success, compaction.Success },
+ };
+
+ if (compaction.Error is not null && _telemetry.EnableSensitiveData)
+ {
+ tags.Add(OpenTelemetryConsts.GenAI.CopilotEvent.Message, compaction.Error);
+ }
+
+ AddAsInt64IfNotNull(tags, OpenTelemetryConsts.GenAI.CopilotEvent.PreTokens, compaction.PreCompactionTokens);
+ AddAsInt64IfNotNull(tags, OpenTelemetryConsts.GenAI.CopilotEvent.PostTokens, compaction.PostCompactionTokens);
+ AddAsInt64IfNotNull(tags, OpenTelemetryConsts.GenAI.CopilotEvent.TokensRemoved, compaction.TokensRemoved);
+ AddAsInt64IfNotNull(tags, OpenTelemetryConsts.GenAI.CopilotEvent.MessagesRemoved, compaction.MessagesRemoved);
+
+ target.AddEvent(new(OpenTelemetryConsts.GenAI.CopilotEvent.SessionCompactionComplete, tags: tags));
+ break;
+ }
+
+ case SkillInvokedEvent { Data: { } skill }:
+ {
+ ActivityTagsCollection tags = new()
+ {
+ { OpenTelemetryConsts.GenAI.CopilotEvent.SkillName, skill.Name },
+ { OpenTelemetryConsts.GenAI.CopilotEvent.SkillPath, skill.Path },
+ };
+
+ if (skill.PluginName is not null)
+ {
+ tags.Add(OpenTelemetryConsts.GenAI.CopilotEvent.SkillPluginName, skill.PluginName);
+ }
+
+ if (skill.PluginVersion is not null)
+ {
+ tags.Add(OpenTelemetryConsts.GenAI.CopilotEvent.SkillPluginVersion, skill.PluginVersion);
+ }
+
+ if (_telemetry.EnableSensitiveData)
+ {
+ tags.Add(OpenTelemetryConsts.GenAI.CopilotEvent.SkillContent, skill.Content);
+ }
+
+ target.AddEvent(new(OpenTelemetryConsts.GenAI.CopilotEvent.SkillInvoked, tags: tags));
+ break;
+ }
+ }
+ }
+ }
+
+ // Lifecycle events that start/end turns or the overall agent operation.
+ switch (sessionEvent)
+ {
+ case AssistantTurnStartEvent turnStartEvent:
+ BeginChatTurn();
+ if (turnStartEvent.Data is { } turnStartData)
+ {
+ _turnId = turnStartData.TurnId;
+ _turnInteractionId = turnStartData.InteractionId;
+ }
+ break;
+
+ case AssistantTurnEndEvent:
+ CompleteChatTurn(error: null);
+ break;
+
+ case SessionIdleEvent:
+ CompleteChatTurn(error: null);
+ CompleteAgentTurn(error: null);
+ break;
+
+ case SessionErrorEvent errorEvent:
+ var ex = new InvalidOperationException($"Session error: {errorEvent.Data?.Message ?? "unknown error"}");
+ CompleteChatTurn(ex);
+ CompleteAgentTurn(ex);
+ break;
+ }
+ }
+ }
+
+ ///
+ /// Ensures the invoke_agent span exists, creating it on demand if needed.
+ /// This is called from both the user.message handler and BeginChatTurn
+ /// so that RPC-initiated turns (no user.message) still get an agent span.
+ /// Caller must hold .
+ ///
+ private void EnsureAgentSpan()
+ {
+ Debug.Assert(Monitor.IsEntered(_lock));
+ if (_agentActivity is null)
+ {
+ _agentActivity = _telemetry.StartInvokeAgentActivity(
+ _sessionId,
+ _requestModel,
+ ProviderName,
+ ServerAddress,
+ ServerPort,
+ _agentName,
+ _agentDescription);
+ _agentTimestamp = Stopwatch.GetTimestamp();
+ _agentInputMessages = [];
+ }
+ }
+
+ ///
+ /// Starts a new chat child span for an LLM turn.
+ /// Caller must hold .
+ ///
+ private void BeginChatTurn()
+ {
+ Debug.Assert(Monitor.IsEntered(_lock));
+
+ // If there's already an active turn, complete it first (shouldn't normally happen).
+ CompleteChatTurn(error: null);
+
+ // Ensure the parent agent span exists — covers RPC-initiated turns
+ // where no user.message event preceded the assistant.turn_start.
+ EnsureAgentSpan();
+
+ _responseModel = null;
+ _responseId = null;
+ _inputTokens = 0;
+ _outputTokens = 0;
+ _cacheReadTokens = 0;
+ _cacheCreationTokens = 0;
+ _firstOutputChunkRecorded = false;
+ _lastOutputChunkElapsed = TimeSpan.Zero;
+ _inputMessages ??= [];
+ _outputMessages = [];
+ _turnCost = null;
+ _turnServerDuration = null;
+ _turnInitiator = null;
+ _turnAiu = null;
+ _turnId = null;
+ _turnInteractionId = null;
+
+ var parentContext = _agentActivity?.Context ?? default;
+ _turnActivity = _telemetry.StartChatActivity(
+ _requestModel,
+ ProviderName,
+ ServerAddress,
+ ServerPort,
+ parentContext,
+ _sessionId);
+
+ _turnTimestamp = Stopwatch.GetTimestamp();
+ }
+
+ ///
+ /// Completes the current chat child span with per-turn attributes and metrics.
+ /// Caller must hold .
+ ///
+ private void CompleteChatTurn(Exception? error)
+ {
+ Debug.Assert(Monitor.IsEntered(_lock));
+
+ var activity = GetAndReset(ref _turnActivity);
+ if (activity is null)
+ {
+ return;
+ }
+
+ var timestamp = GetAndReset(ref _turnTimestamp);
+ var inputMessages = GetAndReset(ref _inputMessages);
+ var outputMessages = GetAndReset(ref _outputMessages);
+ var responseModel = GetAndReset(ref _responseModel);
+ var responseId = GetAndReset(ref _responseId);
+ var inputTokens = GetAndReset(ref _inputTokens);
+ var outputTokens = GetAndReset(ref _outputTokens);
+ var cacheReadTokens = GetAndReset(ref _cacheReadTokens);
+ var cacheCreationTokens = GetAndReset(ref _cacheCreationTokens);
+ var turnCost = GetAndReset(ref _turnCost);
+ var turnServerDuration = GetAndReset(ref _turnServerDuration);
+ var turnInitiator = GetAndReset(ref _turnInitiator);
+ var turnAiu = GetAndReset(ref _turnAiu);
+ var turnId = GetAndReset(ref _turnId);
+ var turnInteractionId = GetAndReset(ref _turnInteractionId);
+
+ if (error is not null)
+ {
+ RecordError(activity, error);
+ }
+
+ var finishReason = error is not null ? "error" : "stop";
+ activity.SetTag(OpenTelemetryConsts.GenAI.Response.FinishReasons, new[] { finishReason });
+
+ // Agent-level output = only the final turn's output (what the agent
+ // returns to the caller). Each turn overwrites; the last one wins.
+ if (outputMessages is { Count: > 0 })
+ {
+ _agentOutputMessages = [];
+ foreach (var msg in outputMessages)
+ {
+ _agentOutputMessages.Add(msg with { FinishReason = finishReason });
+ }
+ }
+
+ // Accumulate agent-level usage across turns.
+ if (responseModel is not null)
+ {
+ _agentResponseModel = responseModel;
+ }
+ if (responseId is not null)
+ {
+ _agentResponseId = responseId;
+ }
+ _agentTotalInputTokens += inputTokens;
+ _agentTotalOutputTokens += outputTokens;
+ _agentTotalCacheReadTokens += cacheReadTokens;
+ _agentTotalCacheCreationTokens += cacheCreationTokens;
+ if (turnCost is double c)
+ {
+ _agentTotalCost += c;
+ }
+ if (turnAiu is double a)
+ {
+ _agentTotalAiu += a;
+ }
+
+ // Set usage-related span attributes for this LLM turn
+ if (activity.IsAllDataRequested)
+ {
+ SetTagIfNotEmpty(activity, OpenTelemetryConsts.GenAI.Response.Model, responseModel);
+ SetTagIfNotEmpty(activity, OpenTelemetryConsts.GenAI.Response.Id, responseId);
+ SetTagIfPositive(activity, OpenTelemetryConsts.GenAI.Usage.InputTokens, inputTokens);
+ SetTagIfPositive(activity, OpenTelemetryConsts.GenAI.Usage.OutputTokens, outputTokens);
+ SetTagIfPositive(activity, OpenTelemetryConsts.GenAI.Usage.CacheReadInputTokens, cacheReadTokens);
+ SetTagIfPositive(activity, OpenTelemetryConsts.GenAI.Usage.CacheCreationInputTokens, cacheCreationTokens);
+ SetTagIfNotNull(activity, OpenTelemetryConsts.GenAI.Copilot.Cost, turnCost);
+ SetTagIfNotNull(activity, OpenTelemetryConsts.GenAI.Copilot.ServerDuration, turnServerDuration);
+ SetTagIfNotEmpty(activity, OpenTelemetryConsts.GenAI.Copilot.Initiator, turnInitiator);
+ SetTagIfNotNull(activity, OpenTelemetryConsts.GenAI.Copilot.Aiu, turnAiu);
+ SetTagIfNotEmpty(activity, OpenTelemetryConsts.GenAI.Copilot.TurnId, turnId);
+ SetTagIfNotEmpty(activity, OpenTelemetryConsts.GenAI.Copilot.InteractionId, turnInteractionId);
+ }
+
+ // Set input/output message content as span attributes (sensitive)
+ if (_telemetry.EnableSensitiveData)
+ {
+ SetTagIfNotEmpty(activity, OpenTelemetryConsts.GenAI.Input.Messages, BuildMessagesJson(inputMessages));
+ SetTagIfNotEmpty(activity, OpenTelemetryConsts.GenAI.Output.Messages, BuildMessagesJson(outputMessages, finishReason: finishReason));
+ }
+
+ // Token usage metrics (per-turn)
+ _telemetry.RecordTokenUsageMetrics(
+ inputTokens > 0 ? inputTokens : null,
+ outputTokens > 0 ? outputTokens : null,
+ _requestModel,
+ responseModel,
+ ProviderName,
+ ServerAddress,
+ ServerPort,
+ error,
+ OpenTelemetryConsts.GenAI.OperationNames.Chat);
+
+ // Per-turn operation duration
+ if (_telemetry.OperationDurationHistogram.Enabled)
+ {
+ _telemetry.RecordOperationDuration(
+ Stopwatch.GetElapsedTime(timestamp).TotalSeconds,
+ _requestModel,
+ responseModel,
+ ProviderName,
+ ServerAddress,
+ ServerPort,
+ error: error,
+ operationName: OpenTelemetryConsts.GenAI.OperationNames.Chat);
+ }
+
+ _firstOutputChunkRecorded = false;
+ _lastOutputChunkElapsed = TimeSpan.Zero;
+ activity.Dispose();
+ }
+
+ ///
+ /// Completes the invoke_agent span and records overall operation duration.
+ /// Caller must hold .
+ ///
+ private void CompleteAgentTurn(Exception? error)
+ {
+ Debug.Assert(Monitor.IsEntered(_lock));
+
+ var activity = GetAndReset(ref _agentActivity);
+ if (activity is null)
+ {
+ return;
+ }
+
+ var timestamp = GetAndReset(ref _agentTimestamp);
+ var agentInputMessages = GetAndReset(ref _agentInputMessages);
+ var agentOutputMessages = GetAndReset(ref _agentOutputMessages);
+
+ // Complete any remaining subagents before closing the parent.
+ if (_activeSubagents is { Count: > 0 })
+ {
+ foreach (var activeSubagent in _activeSubagents)
+ {
+ CompleteSubagent(activeSubagent.Key, error);
+ }
+ }
+
+ _activeSubagents = null;
+ _pendingToolParents = null;
+ _serverToolCallIds = null;
+
+ if (error is not null)
+ {
+ RecordError(activity, error);
+ }
+
+ var finishReason = error is not null ? "error" : "stop";
+ activity.SetTag(OpenTelemetryConsts.GenAI.Response.FinishReasons, new[] { finishReason });
+
+ // Set accumulated usage across all chat turns on the invoke_agent span.
+ var agentResponseModel = GetAndReset(ref _agentResponseModel);
+ var agentResponseId = GetAndReset(ref _agentResponseId);
+ var agentTotalInputTokens = GetAndReset(ref _agentTotalInputTokens);
+ var agentTotalOutputTokens = GetAndReset(ref _agentTotalOutputTokens);
+ var agentTotalCacheReadTokens = GetAndReset(ref _agentTotalCacheReadTokens);
+ var agentTotalCacheCreationTokens = GetAndReset(ref _agentTotalCacheCreationTokens);
+ var agentTotalCost = GetAndReset(ref _agentTotalCost);
+ var agentTotalAiu = GetAndReset(ref _agentTotalAiu);
+
+ if (activity.IsAllDataRequested)
+ {
+ SetTagIfNotEmpty(activity, OpenTelemetryConsts.GenAI.Response.Model, agentResponseModel);
+ SetTagIfNotEmpty(activity, OpenTelemetryConsts.GenAI.Response.Id, agentResponseId);
+ SetTagIfPositive(activity, OpenTelemetryConsts.GenAI.Usage.InputTokens, agentTotalInputTokens);
+ SetTagIfPositive(activity, OpenTelemetryConsts.GenAI.Usage.OutputTokens, agentTotalOutputTokens);
+ SetTagIfPositive(activity, OpenTelemetryConsts.GenAI.Usage.CacheReadInputTokens, agentTotalCacheReadTokens);
+ SetTagIfPositive(activity, OpenTelemetryConsts.GenAI.Usage.CacheCreationInputTokens, agentTotalCacheCreationTokens);
+ SetTagIfPositive(activity, OpenTelemetryConsts.GenAI.Copilot.Cost, agentTotalCost);
+ SetTagIfPositive(activity, OpenTelemetryConsts.GenAI.Copilot.Aiu, agentTotalAiu);
+ }
+
+ if (_telemetry.EnableSensitiveData)
+ {
+ SetTagIfNotEmpty(activity, OpenTelemetryConsts.GenAI.Input.Messages, BuildMessagesJson(agentInputMessages));
+ SetTagIfNotEmpty(activity, OpenTelemetryConsts.GenAI.Output.Messages, BuildMessagesJson(agentOutputMessages));
+ SetTagIfNotEmpty(activity, OpenTelemetryConsts.GenAI.SystemInstructions, _systemInstructionsJson);
+ }
+
+ SetTagIfNotEmpty(activity, OpenTelemetryConsts.GenAI.Tool.Definitions, _toolDefinitionsJson);
+
+ if (_telemetry.OperationDurationHistogram.Enabled)
+ {
+ _telemetry.RecordOperationDuration(
+ Stopwatch.GetElapsedTime(timestamp).TotalSeconds,
+ _requestModel,
+ agentResponseModel,
+ ProviderName,
+ ServerAddress,
+ ServerPort,
+ error: error,
+ operationName: OpenTelemetryConsts.GenAI.OperationNames.InvokeAgent);
+ }
+
+ activity.Dispose();
+ }
+
+ ///
+ /// Records streaming chunk timing metrics.
+ /// Caller must hold .
+ ///
+ private void RecordOutputChunkMetric()
+ {
+ Debug.Assert(Monitor.IsEntered(_lock));
+
+ if (!_isStreaming || _turnTimestamp == 0)
+ {
+ return;
+ }
+
+ var elapsed = Stopwatch.GetElapsedTime(_turnTimestamp);
+
+ if (!_firstOutputChunkRecorded)
+ {
+ _firstOutputChunkRecorded = true;
+ _lastOutputChunkElapsed = elapsed;
+ _telemetry.RecordTimeToFirstChunk(
+ elapsed.TotalSeconds,
+ _requestModel,
+ null, // response model not yet known during streaming
+ ProviderName,
+ ServerAddress,
+ ServerPort);
+ return;
+ }
+
+ var delta = elapsed - _lastOutputChunkElapsed;
+ _lastOutputChunkElapsed = elapsed;
+ _telemetry.RecordTimePerOutputChunk(
+ delta.TotalSeconds,
+ _requestModel,
+ null, // response model not yet known during streaming
+ ProviderName,
+ ServerAddress,
+ ServerPort);
+ }
+
+ ///
+ /// Extracts ParentToolCallId from events that carry it.
+ /// A non-null/non-empty value indicates the event belongs to a subagent.
+ ///
+ private static string? GetParentToolCallId(SessionEvent evt)
+ {
+ return evt switch
+ {
+ AssistantUsageEvent e => e.Data?.ParentToolCallId,
+ AssistantMessageEvent e => e.Data?.ParentToolCallId,
+ AssistantMessageDeltaEvent e => e.Data?.ParentToolCallId,
+ ToolExecutionStartEvent e => e.Data?.ParentToolCallId,
+ ToolExecutionCompleteEvent e => e.Data?.ParentToolCallId,
+ _ => null,
+ };
+ }
+
+ ///
+ /// Creates a nested invoke_agent + chat span pair for a subagent.
+ /// Caller must hold .
+ ///
+ private void BeginSubagent(SubagentStartedEvent started)
+ {
+ Debug.Assert(Monitor.IsEntered(_lock));
+
+ var data = started.Data;
+ if (data is null)
+ {
+ return;
+ }
+
+ var parentContext = _agentActivity?.Context ?? default;
+ var invokeActivity = _telemetry.StartInvokeAgentActivity(
+ _sessionId,
+ _requestModel,
+ ProviderName,
+ ServerAddress,
+ ServerPort,
+ agentName: data.AgentName,
+ agentDescription: data.AgentDescription,
+ parentContext: parentContext);
+
+ if (invokeActivity is null)
+ {
+ return;
+ }
+
+ var chatActivity = _telemetry.StartChatActivity(
+ _requestModel,
+ ProviderName,
+ ServerAddress,
+ ServerPort,
+ invokeActivity.Context,
+ _sessionId);
+
+ var state = new SubagentState
+ {
+ InvokeAgentActivity = invokeActivity,
+ InvokeAgentTimestamp = Stopwatch.GetTimestamp(),
+ ChatActivity = chatActivity,
+ AgentName = data.AgentName,
+ };
+
+ _activeSubagents ??= new(StringComparer.Ordinal);
+ _activeSubagents[data.ToolCallId] = state;
+ }
+
+ ///
+ /// Routes an event to its owning subagent's spans.
+ /// Caller must hold .
+ ///
+ private void ProcessSubagentEvent(SubagentState subagent, SessionEvent sessionEvent)
+ {
+ Debug.Assert(Monitor.IsEntered(_lock));
+
+ switch (sessionEvent)
+ {
+ case AssistantUsageEvent usageEvent:
+ subagent.ResponseModel = usageEvent.Data.Model;
+
+ // Update response model on chat span if the subagent is using
+ // a different model than what was set at span creation time.
+ if (!string.IsNullOrWhiteSpace(usageEvent.Data.Model))
+ {
+ subagent.ChatActivity?.SetTag(OpenTelemetryConsts.GenAI.Response.Model, usageEvent.Data.Model);
+ }
+
+ if (!string.IsNullOrWhiteSpace(usageEvent.Data.ApiCallId))
+ {
+ subagent.ResponseId = usageEvent.Data.ApiCallId;
+ }
+ else if (!string.IsNullOrWhiteSpace(usageEvent.Data.ProviderCallId))
+ {
+ subagent.ResponseId = usageEvent.Data.ProviderCallId;
+ }
+
+ subagent.InputTokens += usageEvent.Data.InputTokens is double inTok ? (int)inTok : 0;
+ subagent.OutputTokens += usageEvent.Data.OutputTokens is double outTok ? (int)outTok : 0;
+ subagent.CacheReadTokens += usageEvent.Data.CacheReadTokens is double cacheRead ? (int)cacheRead : 0;
+ subagent.CacheCreationTokens += usageEvent.Data.CacheWriteTokens is double cacheWrite ? (int)cacheWrite : 0;
+ break;
+
+ case AssistantMessageEvent messageEvent:
+ {
+ List parts = [];
+ if (!string.IsNullOrWhiteSpace(messageEvent.Data?.ReasoningText))
+ {
+ parts.Add(new("reasoning", Content: messageEvent.Data.ReasoningText));
+ }
+
+ if (!string.IsNullOrWhiteSpace(messageEvent.Data?.Content))
+ {
+ parts.Add(new("text", Content: messageEvent.Data.Content));
+ }
+
+ if (parts.Count > 0)
+ {
+ subagent.OutputMessages.Add(new("assistant", parts));
+ }
+
+ break;
+ }
+
+ case ToolExecutionStartEvent toolStartEvent:
+ {
+ if (toolStartEvent.Data is { } startData)
+ {
+ var isServerTool = startData.McpServerName is not null;
+ if (isServerTool && startData.ToolCallId is not null)
+ {
+ _serverToolCallIds ??= [];
+ _serverToolCallIds[startData.ToolCallId] = startData.McpServerName!;
+ }
+
+ subagent.OutputMessages.Add(new("assistant",
+ [
+ new(isServerTool ? "server_tool_call" : "tool_call",
+ Id: startData.ToolCallId,
+ Name: startData.ToolName,
+ Arguments: startData.Arguments,
+ McpServerName: startData.McpServerName)
+ ]));
+
+ // Store the parent context for OnToolCall to use.
+ // For subagent tool calls, parent is the subagent's invoke_agent.
+ if (subagent.InvokeAgentActivity is not null && startData.ToolCallId is not null)
+ {
+ _pendingToolParents ??= [];
+ _pendingToolParents[startData.ToolCallId] = subagent.InvokeAgentActivity.Context;
+ }
+ }
+
+ break;
+ }
+
+ case ToolExecutionCompleteEvent toolCompleteEvent:
+ {
+ if (toolCompleteEvent.Data is { } toolData)
+ {
+ var resultContent = toolData.Result?.Content ?? toolData.Error?.Message;
+ string? serverName = null;
+ var isServerTool = _serverToolCallIds is not null
+ && _serverToolCallIds.Remove(toolData.ToolCallId, out serverName);
+
+ subagent.InputMessages.Add(new("tool",
+ [
+ new(isServerTool ? "server_tool_call_response" : "tool_call_response",
+ Id: toolData.ToolCallId,
+ Response: resultContent,
+ McpServerName: serverName)
+ ]));
+ }
+
+ break;
+ }
+ }
+ }
+
+ ///
+ /// Completes a subagent's chat and invoke_agent spans.
+ /// Caller must hold .
+ ///
+ private void CompleteSubagent(string toolCallId, Exception? error)
+ {
+ Debug.Assert(Monitor.IsEntered(_lock));
+
+ if (_activeSubagents is null || !_activeSubagents.Remove(toolCallId, out var subagent))
+ {
+ return;
+ }
+
+ var finishReason = error is not null ? "error" : "stop";
+
+ // -- Complete the chat child span --
+ var chatActivity = subagent.ChatActivity;
+ if (chatActivity is not null)
+ {
+ if (error is not null)
+ {
+ RecordError(chatActivity, error);
+ }
+
+ chatActivity.SetTag(OpenTelemetryConsts.GenAI.Response.FinishReasons, new[] { finishReason });
+
+ if (chatActivity.IsAllDataRequested)
+ {
+ SetTagIfNotEmpty(chatActivity, OpenTelemetryConsts.GenAI.Response.Model, subagent.ResponseModel);
+ SetTagIfNotEmpty(chatActivity, OpenTelemetryConsts.GenAI.Response.Id, subagent.ResponseId);
+ SetTagIfPositive(chatActivity, OpenTelemetryConsts.GenAI.Usage.InputTokens, subagent.InputTokens);
+ SetTagIfPositive(chatActivity, OpenTelemetryConsts.GenAI.Usage.OutputTokens, subagent.OutputTokens);
+ SetTagIfPositive(chatActivity, OpenTelemetryConsts.GenAI.Usage.CacheReadInputTokens, subagent.CacheReadTokens);
+ SetTagIfPositive(chatActivity, OpenTelemetryConsts.GenAI.Usage.CacheCreationInputTokens, subagent.CacheCreationTokens);
+ }
+
+ if (_telemetry.EnableSensitiveData)
+ {
+ if (subagent.InputMessages.Count > 0)
+ {
+ SetTagIfNotEmpty(chatActivity, OpenTelemetryConsts.GenAI.Input.Messages, BuildMessagesJson(subagent.InputMessages));
+ }
+
+ if (subagent.OutputMessages.Count > 0)
+ {
+ SetTagIfNotEmpty(chatActivity, OpenTelemetryConsts.GenAI.Output.Messages, BuildMessagesJson(subagent.OutputMessages, finishReason: finishReason));
+ }
+ }
+
+ _telemetry.RecordTokenUsageMetrics(
+ subagent.InputTokens > 0 ? subagent.InputTokens : null,
+ subagent.OutputTokens > 0 ? subagent.OutputTokens : null,
+ subagent.ResponseModel ?? _requestModel,
+ subagent.ResponseModel,
+ ProviderName,
+ ServerAddress,
+ ServerPort,
+ error,
+ OpenTelemetryConsts.GenAI.OperationNames.Chat);
+
+ chatActivity.Dispose();
+ }
+
+ // -- Complete the invoke_agent span --
+ var invokeActivity = subagent.InvokeAgentActivity;
+ if (invokeActivity is not null)
+ {
+ if (error is not null)
+ {
+ RecordError(invokeActivity, error);
+ }
+
+ invokeActivity.SetTag(OpenTelemetryConsts.GenAI.Response.FinishReasons, new[] { finishReason });
+
+ if (invokeActivity.IsAllDataRequested)
+ {
+ SetTagIfNotEmpty(invokeActivity, OpenTelemetryConsts.GenAI.Response.Model, subagent.ResponseModel);
+ SetTagIfNotEmpty(invokeActivity, OpenTelemetryConsts.GenAI.Response.Id, subagent.ResponseId);
+ SetTagIfPositive(invokeActivity, OpenTelemetryConsts.GenAI.Usage.InputTokens, subagent.InputTokens);
+ SetTagIfPositive(invokeActivity, OpenTelemetryConsts.GenAI.Usage.OutputTokens, subagent.OutputTokens);
+ SetTagIfPositive(invokeActivity, OpenTelemetryConsts.GenAI.Usage.CacheReadInputTokens, subagent.CacheReadTokens);
+ SetTagIfPositive(invokeActivity, OpenTelemetryConsts.GenAI.Usage.CacheCreationInputTokens, subagent.CacheCreationTokens);
+ }
+
+ if (_telemetry.EnableSensitiveData && subagent.OutputMessages.Count > 0)
+ {
+ SetTagIfNotEmpty(invokeActivity, OpenTelemetryConsts.GenAI.Output.Messages,
+ BuildMessagesJson(subagent.OutputMessages.Select(m => m with { FinishReason = finishReason }).ToList()));
+ }
+
+ if (_telemetry.OperationDurationHistogram.Enabled)
+ {
+ _telemetry.RecordOperationDuration(
+ Stopwatch.GetElapsedTime(subagent.InvokeAgentTimestamp).TotalSeconds,
+ subagent.ResponseModel ?? _requestModel,
+ subagent.ResponseModel,
+ ProviderName,
+ ServerAddress,
+ ServerPort,
+ error: error,
+ operationName: OpenTelemetryConsts.GenAI.OperationNames.InvokeAgent);
+ }
+
+ invokeActivity.Dispose();
+ }
+ }
+
+ /// Tracks mutable state for an active subagent's spans.
+ private sealed class SubagentState
+ {
+ public Activity? InvokeAgentActivity;
+ public long InvokeAgentTimestamp;
+ public Activity? ChatActivity;
+ public string? AgentName;
+ public string? ResponseModel;
+ public string? ResponseId;
+ public int InputTokens;
+ public int OutputTokens;
+ public int CacheReadTokens;
+ public int CacheCreationTokens;
+ public List InputMessages = [];
+ public List OutputMessages = [];
+ }
+
+ private static string? BuildMessagesJson(List? messages, string? finishReason = null)
+ {
+ if (messages is not { Count: > 0 })
+ {
+ return null;
+ }
+
+ using var stream = new MemoryStream();
+ using (var writer = new Utf8JsonWriter(stream, s_jsonWriterOptions))
+ {
+ writer.WriteStartArray();
+ foreach (var message in messages)
+ {
+ if (message.Parts.Count == 0)
+ {
+ continue;
+ }
+
+ writer.WriteStartObject();
+ writer.WriteString("role", message.Role);
+ writer.WritePropertyName("parts");
+ writer.WriteStartArray();
+ foreach (var part in message.Parts)
+ {
+ writer.WriteStartObject();
+ writer.WriteString("type", part.Type);
+
+ switch (part.Type)
+ {
+ case "server_tool_call":
+ if (part.Id is not null)
+ {
+ writer.WriteString("id", part.Id);
+ }
+
+ if (part.Name is not null)
+ {
+ writer.WriteString("name", part.Name);
+ }
+
+ // Spec requires a nested server_tool_call object with a type discriminator.
+ // MCP tools use type "mcp" with a server_name field per the MEAI convention.
+ writer.WritePropertyName("server_tool_call");
+ writer.WriteStartObject();
+ writer.WriteString("type", "mcp");
+ if (part.McpServerName is not null)
+ {
+ writer.WriteString("server_name", part.McpServerName);
+ }
+ if (part.Arguments is not null)
+ {
+ writer.WritePropertyName("arguments");
+ WriteJsonValue(writer, part.Arguments);
+ }
+
+ writer.WriteEndObject();
+ break;
+
+ case "server_tool_call_response":
+ if (part.Id is not null)
+ {
+ writer.WriteString("id", part.Id);
+ }
+
+ // Spec requires a nested server_tool_call_response object with a type discriminator.
+ writer.WritePropertyName("server_tool_call_response");
+ writer.WriteStartObject();
+ writer.WriteString("type", "mcp");
+ if (part.McpServerName is not null)
+ {
+ writer.WriteString("server_name", part.McpServerName);
+ }
+ if (part.Response is not null)
+ {
+ writer.WritePropertyName("response");
+ WriteJsonValue(writer, part.Response);
+ }
+
+ writer.WriteEndObject();
+ break;
+
+ default:
+ if (part.Content is not null)
+ {
+ writer.WriteString("content", part.Content);
+ }
+
+ if (part.Id is not null)
+ {
+ writer.WriteString("id", part.Id);
+ }
+
+ if (part.Name is not null)
+ {
+ writer.WriteString("name", part.Name);
+ }
+
+ if (part.Arguments is not null)
+ {
+ writer.WritePropertyName("arguments");
+ WriteJsonValue(writer, part.Arguments);
+ }
+
+ if (part.Response is not null)
+ {
+ writer.WritePropertyName("response");
+ WriteJsonValue(writer, part.Response);
+ }
+
+ break;
+ }
+
+ writer.WriteEndObject();
+ }
+
+ writer.WriteEndArray();
+ var effectiveFinishReason = message.FinishReason ?? finishReason;
+ if (effectiveFinishReason is not null)
+ {
+ writer.WriteString("finish_reason", effectiveFinishReason);
+ }
+
+ writer.WriteEndObject();
+ }
+
+ writer.WriteEndArray();
+ writer.Flush();
+ }
+
+ return MemoryStreamToUtf8String(stream);
+ }
+
+ private static void WriteJsonValue(Utf8JsonWriter writer, object value)
+ {
+ switch (value)
+ {
+ case JsonElement jsonElement:
+ jsonElement.WriteTo(writer);
+ break;
+ case string text:
+ writer.WriteStringValue(text);
+ break;
+ default:
+ writer.WriteStringValue(value.ToString());
+ break;
+ }
+ }
+
+ private static T? GetAndReset(ref T? field)
+ {
+ var value = field;
+ field = default;
+ return value;
+ }
+
+ private static void SetTagIfNotEmpty(Activity activity, string key, string? value)
+ {
+ if (!string.IsNullOrWhiteSpace(value))
+ {
+ activity.SetTag(key, value);
+ }
+ }
+
+ private static void SetTagIfPositive(Activity activity, string key, int value)
+ {
+ if (value > 0)
+ {
+ activity.SetTag(key, value);
+ }
+ }
+
+ private static void SetTagIfPositive(Activity activity, string key, double value)
+ {
+ if (value > 0)
+ {
+ activity.SetTag(key, value);
+ }
+ }
+
+ private static void SetTagIfNotNull(Activity activity, string key, T? value) where T : struct
+ {
+ if (value.HasValue)
+ {
+ activity.SetTag(key, value.Value);
+ }
+ }
+
+ private sealed record OtelMsg(
+ string Role,
+ List Parts,
+ string? FinishReason = null);
+
+ private sealed record OtelPart(
+ string Type,
+ string? Content = null,
+ string? Id = null,
+ string? Name = null,
+ object? Arguments = null,
+ object? Response = null,
+ string? McpServerName = null);
+
+ internal static string? BuildSystemInstructionsJson(SystemMessageConfig? systemMessage)
+ {
+ if (string.IsNullOrWhiteSpace(systemMessage?.Content))
+ {
+ return null;
+ }
+
+ using var stream = new MemoryStream();
+ using (var writer = new Utf8JsonWriter(stream, s_jsonWriterOptions))
+ {
+ writer.WriteStartArray();
+ writer.WriteStartObject();
+ writer.WriteString("type", "text");
+ writer.WriteString("content", systemMessage.Content);
+ writer.WriteEndObject();
+ writer.WriteEndArray();
+ writer.Flush();
+ }
+
+ return MemoryStreamToUtf8String(stream);
+ }
+
+ internal static string? BuildToolDefinitionsJson(ICollection? tools)
+ {
+ if (tools is not { Count: > 0 })
+ {
+ return null;
+ }
+
+ using var stream = new MemoryStream();
+ using (var writer = new Utf8JsonWriter(stream, s_jsonWriterOptions))
+ {
+ writer.WriteStartArray();
+ foreach (var tool in tools)
+ {
+ writer.WriteStartObject();
+ writer.WriteString("type", "function");
+ writer.WriteString("name", tool.Name);
+
+ if (!string.IsNullOrWhiteSpace(tool.Description))
+ {
+ writer.WriteString("description", tool.Description);
+ }
+
+ if (tool.JsonSchema.ValueKind is not JsonValueKind.Undefined and not JsonValueKind.Null)
+ {
+ writer.WritePropertyName("parameters");
+ tool.JsonSchema.WriteTo(writer);
+ }
+
+ writer.WriteEndObject();
+ }
+
+ writer.WriteEndArray();
+ writer.Flush();
+ }
+
+ return MemoryStreamToUtf8String(stream);
+ }
+
+ private static string MemoryStreamToUtf8String(MemoryStream stream) =>
+ Encoding.UTF8.GetString(stream.GetBuffer(), 0, (int)stream.Length);
+ }
+}
diff --git a/dotnet/src/OpenTelemetryConsts.cs b/dotnet/src/OpenTelemetryConsts.cs
new file mode 100644
index 000000000..d2b684361
--- /dev/null
+++ b/dotnet/src/OpenTelemetryConsts.cs
@@ -0,0 +1,191 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ *--------------------------------------------------------------------------------------------*/
+
+namespace GitHub.Copilot.SDK;
+
+///
+/// String constants for OpenTelemetry Semantic Conventions for Generative AI systems.
+///
+///
+/// Based on the Semantic Conventions for Generative AI systems v1.40,
+/// defined at .
+/// The specification is still experimental and subject to change.
+///
+internal static class OpenTelemetryConsts
+{
+ public const string DefaultSourceName = "github.copilot.sdk";
+ public const string DefaultProviderName = "github";
+ public const string CaptureMessageContentEnvVar = "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT";
+ public const string SecondsUnit = "s";
+ public const string TokensUnit = "{token}";
+
+ public const string TokenTypeInput = "input";
+ public const string TokenTypeOutput = "output";
+
+ public static class Error
+ {
+ public const string Type = "error.type";
+ }
+
+ public static class Server
+ {
+ public const string Address = "server.address";
+ public const string Port = "server.port";
+ }
+
+ public static class GenAI
+ {
+ public static class OperationNames
+ {
+ public const string Chat = "chat";
+ public const string InvokeAgent = "invoke_agent";
+ public const string ExecuteTool = "execute_tool";
+ }
+
+ public static class Operation
+ {
+ public const string Name = "gen_ai.operation.name";
+ }
+
+ public static class Provider
+ {
+ public const string Name = "gen_ai.provider.name";
+ }
+
+ public static class Agent
+ {
+ public const string Id = "gen_ai.agent.id";
+ public const string Name = "gen_ai.agent.name";
+ public const string Description = "gen_ai.agent.description";
+ }
+
+ public static class Conversation
+ {
+ public const string Id = "gen_ai.conversation.id";
+ }
+
+ public static class Request
+ {
+ public const string Model = "gen_ai.request.model";
+ }
+
+ public static class Response
+ {
+ public const string Id = "gen_ai.response.id";
+ public const string Model = "gen_ai.response.model";
+ public const string FinishReasons = "gen_ai.response.finish_reasons";
+ }
+
+ public static class Usage
+ {
+ public const string InputTokens = "gen_ai.usage.input_tokens";
+ public const string OutputTokens = "gen_ai.usage.output_tokens";
+ public const string CacheReadInputTokens = "gen_ai.usage.cache_read.input_tokens";
+ public const string CacheCreationInputTokens = "gen_ai.usage.cache_creation.input_tokens";
+ }
+
+ public static class Token
+ {
+ public const string Type = "gen_ai.token.type";
+ }
+
+ public static class Input
+ {
+ public const string Messages = "gen_ai.input.messages";
+ }
+
+ public static class Output
+ {
+ public const string Messages = "gen_ai.output.messages";
+ }
+
+ public const string SystemInstructions = "gen_ai.system_instructions";
+
+ public static class Tool
+ {
+ public const string Definitions = "gen_ai.tool.definitions";
+ public const string CallId = "gen_ai.tool.call.id";
+ public const string CallArguments = "gen_ai.tool.call.arguments";
+ public const string CallResult = "gen_ai.tool.call.result";
+ public const string Name = "gen_ai.tool.name";
+ public const string Description = "gen_ai.tool.description";
+ public const string Type = "gen_ai.tool.type";
+ }
+
+ public static class Client
+ {
+ public static class TokenUsage
+ {
+ public const string Name = "gen_ai.client.token.usage";
+ public const string Description = "Number of input and output tokens used.";
+ public static readonly int[] ExplicitBucketBoundaries =
+ [1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864];
+ }
+
+ public static class OperationDuration
+ {
+ public const string Name = "gen_ai.client.operation.duration";
+ public const string Description = "GenAI operation duration.";
+ public static readonly double[] ExplicitBucketBoundaries =
+ [0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28, 2.56, 5.12, 10.24, 20.48, 40.96, 81.92];
+ }
+
+ public static class TimeToFirstChunk
+ {
+ public const string Name = "gen_ai.client.operation.time_to_first_chunk";
+ public const string Description = "Time to receive the first chunk from a streaming response.";
+ public static double[] ExplicitBucketBoundaries =>
+ OperationDuration.ExplicitBucketBoundaries;
+ }
+
+ public static class TimePerOutputChunk
+ {
+ public const string Name = "gen_ai.client.operation.time_per_output_chunk";
+ public const string Description = "Time elapsed between streamed output chunks after the first chunk.";
+ public static double[] ExplicitBucketBoundaries =>
+ OperationDuration.ExplicitBucketBoundaries;
+ }
+ }
+
+ // Vendor-prefixed span event names for Copilot-specific lifecycle events.
+ // These follow the {vendor}.{domain}.{event} convention.
+ public static class CopilotEvent
+ {
+ public const string SessionTruncation = "github.copilot.session.truncation";
+ public const string SessionCompactionStart = "github.copilot.session.compaction_start";
+ public const string SessionCompactionComplete = "github.copilot.session.compaction_complete";
+ public const string SkillInvoked = "github.copilot.skill.invoked";
+
+ // Attribute keys for custom events (vendor-prefixed).
+ public const string Message = "github.copilot.message";
+ public const string TokenLimit = "github.copilot.token_limit";
+ public const string PreTokens = "github.copilot.pre_tokens";
+ public const string PostTokens = "github.copilot.post_tokens";
+ public const string PreMessages = "github.copilot.pre_messages";
+ public const string PostMessages = "github.copilot.post_messages";
+ public const string TokensRemoved = "github.copilot.tokens_removed";
+ public const string MessagesRemoved = "github.copilot.messages_removed";
+ public const string PerformedBy = "github.copilot.performed_by";
+ public const string Success = "github.copilot.success";
+ public const string SkillName = "github.copilot.skill.name";
+ public const string SkillPath = "github.copilot.skill.path";
+ public const string SkillContent = "github.copilot.skill.content";
+ public const string SkillPluginName = "github.copilot.skill.plugin_name";
+ public const string SkillPluginVersion = "github.copilot.skill.plugin_version";
+ }
+
+ // Vendor-prefixed span attributes for Copilot-specific data on standardized spans.
+ public static class Copilot
+ {
+ // High-value: on chat spans (from AssistantUsageData)
+ public const string Cost = "github.copilot.cost";
+ public const string ServerDuration = "github.copilot.server_duration";
+ public const string Initiator = "github.copilot.initiator";
+ public const string Aiu = "github.copilot.aiu";
+
+ public const string TurnId = "github.copilot.turn_id";
+ public const string InteractionId = "github.copilot.interaction_id";
+ }
+ }
+}
diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs
index 8bbee6071..83f85bc57 100644
--- a/dotnet/src/Session.cs
+++ b/dotnet/src/Session.cs
@@ -4,6 +4,7 @@
using Microsoft.Extensions.AI;
using StreamJsonRpc;
+using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
@@ -53,6 +54,7 @@ public partial class CopilotSession : IAsyncDisposable
private event SessionEventHandler? _eventHandlers;
private readonly Dictionary _toolHandlers = new();
private readonly JsonRpc _rpc;
+ private readonly CopilotTelemetry.AgentTurnTracker? _telemetryTracker;
private volatile PermissionRequestHandler? _permissionHandler;
private volatile UserInputHandler? _userInputHandler;
private SessionHooks? _hooks;
@@ -78,21 +80,46 @@ public partial class CopilotSession : IAsyncDisposable
/// The path to the workspace containing checkpoints/, plan.md, and files/ subdirectories,
/// or null if infinite sessions are disabled.
///
- public string? WorkspacePath { get; }
+ public string? WorkspacePath { get; internal set; }
+
+ internal string TelemetryProviderName => _telemetryTracker?.ProviderName ?? OpenTelemetryConsts.DefaultProviderName;
+ internal string? TelemetryServerAddress => _telemetryTracker?.ServerAddress;
+ internal int? TelemetryServerPort => _telemetryTracker?.ServerPort;
+ internal ActivityContext TelemetryActivityContext => _telemetryTracker?.GetActivityContext() ?? default;
+ internal ActivityContext GetTelemetryToolCallParentContext(string toolCallId) =>
+ _telemetryTracker?.GetToolCallParentContext(toolCallId) ?? default;
///
/// Initializes a new instance of the class.
///
/// The unique identifier for this session.
/// The JSON-RPC connection to the Copilot CLI.
+ /// The telemetry instance for this session, or null if telemetry is disabled.
/// The workspace path if infinite sessions are enabled.
+ /// The request model for telemetry.
+ /// The provider configuration for telemetry.
+ /// The system message configuration for telemetry.
+ /// The tool definitions for telemetry.
+ /// Whether streaming is enabled, for telemetry.
///
/// This constructor is internal. Use to create sessions.
///
- internal CopilotSession(string sessionId, JsonRpc rpc, string? workspacePath = null)
+ internal CopilotSession(
+ string sessionId,
+ JsonRpc rpc,
+ CopilotTelemetry? telemetry = null,
+ string? workspacePath = null,
+ string? model = null,
+ ProviderConfig? provider = null,
+ SystemMessageConfig? systemMessage = null,
+ ICollection? tools = null,
+ bool streaming = false,
+ string? agentName = null,
+ string? agentDescription = null)
{
SessionId = sessionId;
_rpc = rpc;
+ _telemetryTracker = telemetry is not null ? new CopilotTelemetry.AgentTurnTracker(telemetry, sessionId, model, provider, systemMessage, tools, streaming, agentName, agentDescription) : null;
WorkspacePath = workspacePath;
}
@@ -212,6 +239,7 @@ void Handler(SessionEvent evt)
else
tcs.TrySetException(new TimeoutException($"SendAndWaitAsync timed out after {effectiveTimeout}"));
});
+
return await tcs.Task;
}
@@ -262,6 +290,8 @@ public IDisposable On(SessionEventHandler handler)
///
internal void DispatchEvent(SessionEvent sessionEvent)
{
+ _telemetryTracker?.ProcessEvent(sessionEvent);
+
// Reading the field once gives us a snapshot; delegates are immutable.
_eventHandlers?.Invoke(sessionEvent);
}
@@ -571,6 +601,7 @@ await InvokeRpcAsync