Repository navigation
Expand file tree
/
Copy pathtelemetry_e2e_test.go
More file actions
350 lines (321 loc) · 10.7 KB
/
Copy pathtelemetry_e2e_test.go
File metadata and controls
350 lines (321 loc) · 10.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
package e2e
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
copilot "github.com/github/copilot-sdk/go"
"github.com/github/copilot-sdk/go/internal/e2e/testharness"
)
// Mirrors dotnet/test/TelemetryExportTests.cs (snapshot category "telemetry").
func TestTelemetryE2E(t *testing.T) {
testharness.SkipIfInProcess(t, "telemetry configuration is not honored in-process")
t.Run("should export file telemetry for sdk interactions", func(t *testing.T) {
ctx := testharness.NewTestContext(t)
ctx.ConfigureForTest(t)
telemetryPath := filepath.Join(ctx.WorkDir, fmt.Sprintf("telemetry-%s.jsonl", randomHex(t)))
const marker = "copilot-sdk-telemetry-e2e"
const sourceName = "go-sdk-telemetry-e2e"
const toolName = "echo_telemetry_marker"
prompt := fmt.Sprintf("Use the %s tool with value '%s', then respond with TELEMETRY_E2E_DONE.", toolName, marker)
client := ctx.NewClient(func(opts *copilot.ClientOptions) {
opts.Telemetry = &copilot.TelemetryConfig{
FilePath: telemetryPath,
ExporterType: "file",
SourceName: sourceName,
CaptureContent: copilot.Bool(true),
}
})
t.Cleanup(func() { client.ForceStop() })
type EchoParams struct {
Value string `json:"value" jsonschema:"Marker value to echo"`
}
echoTool := copilot.DefineTool(toolName, "Echoes a marker string for telemetry validation.",
func(params EchoParams, inv copilot.ToolInvocation) (string, error) {
return params.Value, nil
})
session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{
Tools: []copilot.Tool{echoTool},
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
})
if err != nil {
t.Fatalf("CreateSession failed: %v", err)
}
sessionID := session.SessionID
if _, err := session.Send(t.Context(), copilot.MessageOptions{Prompt: prompt}); err != nil {
t.Fatalf("Send failed: %v", err)
}
final, err := testharness.GetFinalAssistantMessage(t.Context(), session)
if err != nil {
t.Fatalf("Failed to wait for final assistant message: %v", err)
}
assistant, ok := final.Data.(*copilot.AssistantMessageData)
if !ok {
t.Fatalf("Expected AssistantMessageData, got %T", final.Data)
}
if !strings.Contains(assistant.Content, "TELEMETRY_E2E_DONE") {
t.Errorf("Expected response to contain 'TELEMETRY_E2E_DONE', got %q", assistant.Content)
}
session.Disconnect()
if err := client.Stop(); err != nil {
t.Logf("Stop returned: %v", err)
}
entries, err := readTelemetryEntries(t, telemetryPath)
if err != nil {
t.Fatalf("readTelemetryEntries failed: %v", err)
}
var spans []map[string]any
for _, e := range entries {
if telemetryType(e) == "span" {
spans = append(spans, e)
}
}
if len(spans) == 0 {
t.Fatalf("Expected at least one span entry; got %d entries", len(entries))
}
for _, span := range spans {
if got := instrumentationScopeName(span); got != sourceName {
t.Errorf("Expected instrumentationScope.name=%q, got %q", sourceName, got)
}
if statusCode(span) == 2 {
t.Errorf("Span has error status: %v", span)
}
}
invokeAgent := findSpanWithOperation(spans, "invoke_agent")
if invokeAgent == nil {
t.Fatal("Expected an invoke_agent span")
}
if got := stringAttr(invokeAgent, "gen_ai.conversation.id"); got != sessionID {
t.Errorf("Expected gen_ai.conversation.id=%q, got %q", sessionID, got)
}
if !isRootSpan(invokeAgent) {
t.Errorf("invoke_agent should be a root span, got parentSpanId=%q", stringProp(invokeAgent, "parentSpanId"))
}
invokeAgentSpanID := stringProp(invokeAgent, "spanId")
if invokeAgentSpanID == "" {
t.Fatal("invoke_agent span has empty spanId")
}
invokeAgentTraceID := stringProp(invokeAgent, "traceId")
if invokeAgentTraceID == "" {
t.Fatal("invoke_agent span has empty traceId")
}
var chatSpans []map[string]any
for _, span := range spans {
if stringAttr(span, "gen_ai.operation.name") == "chat" {
chatSpans = append(chatSpans, span)
}
}
if len(chatSpans) == 0 {
t.Fatal("Expected at least one chat span")
}
for _, chat := range chatSpans {
if got := stringProp(chat, "parentSpanId"); got != invokeAgentSpanID {
t.Errorf("Expected chat span parentSpanId=%q, got %q", invokeAgentSpanID, got)
}
if got := stringProp(chat, "traceId"); got != invokeAgentTraceID {
t.Errorf("Expected chat span traceId=%q, got %q", invokeAgentTraceID, got)
}
}
var sawPromptInput, sawDoneOutput bool
for _, chat := range chatSpans {
if strings.Contains(stringAttr(chat, "gen_ai.input.messages"), prompt) {
sawPromptInput = true
}
if strings.Contains(stringAttr(chat, "gen_ai.output.messages"), "TELEMETRY_E2E_DONE") {
sawDoneOutput = true
}
}
if !sawPromptInput {
t.Errorf("Expected at least one chat span input.messages containing the prompt")
}
if !sawDoneOutput {
t.Errorf("Expected at least one chat span output.messages containing 'TELEMETRY_E2E_DONE'")
}
toolSpan := findSpanWithOperation(spans, "execute_tool")
if toolSpan == nil {
t.Fatal("Expected an execute_tool span")
}
if got := stringProp(toolSpan, "parentSpanId"); got != invokeAgentSpanID {
t.Errorf("Expected execute_tool parentSpanId=%q, got %q", invokeAgentSpanID, got)
}
if got := stringProp(toolSpan, "traceId"); got != invokeAgentTraceID {
t.Errorf("Expected execute_tool traceId=%q, got %q", invokeAgentTraceID, got)
}
if got := stringAttr(toolSpan, "gen_ai.tool.name"); got != toolName {
t.Errorf("Expected gen_ai.tool.name=%q, got %q", toolName, got)
}
if got := stringAttr(toolSpan, "gen_ai.tool.call.id"); strings.TrimSpace(got) == "" {
t.Errorf("Expected non-empty gen_ai.tool.call.id, got %q", got)
}
expectedArgs := fmt.Sprintf("{\"value\":\"%s\"}", marker)
if got := stringAttr(toolSpan, "gen_ai.tool.call.arguments"); got != expectedArgs {
t.Errorf("Expected gen_ai.tool.call.arguments=%q, got %q", expectedArgs, got)
}
if got := stringAttr(toolSpan, "gen_ai.tool.call.result"); got != marker {
t.Errorf("Expected gen_ai.tool.call.result=%q, got %q", marker, got)
}
})
}
func readTelemetryEntries(t *testing.T, path string) ([]map[string]any, error) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var entries []map[string]any
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var entry map[string]any
if err := json.Unmarshal([]byte(line), &entry); err != nil {
return nil, fmt.Errorf("parse telemetry entry in %q: %w", path, err)
}
entries = append(entries, entry)
}
return entries, nil
}
func telemetryType(e map[string]any) string { return stringProp(e, "type") }
func stringProp(e map[string]any, name string) string {
v, ok := e[name]
if !ok {
return ""
}
switch x := v.(type) {
case string:
return x
case float64, bool:
raw, _ := json.Marshal(x)
return string(raw)
default:
raw, _ := json.Marshal(x)
return string(raw)
}
}
func stringAttr(e map[string]any, name string) string {
attrs, ok := e["attributes"].(map[string]any)
if !ok {
return ""
}
v, ok := attrs[name]
if !ok {
return ""
}
switch x := v.(type) {
case string:
return x
default:
raw, _ := json.Marshal(x)
return string(raw)
}
}
func instrumentationScopeName(e map[string]any) string {
scope, ok := e["instrumentationScope"].(map[string]any)
if !ok {
return ""
}
if name, ok := scope["name"].(string); ok {
return name
}
return ""
}
func statusCode(e map[string]any) int {
status, ok := e["status"].(map[string]any)
if !ok {
return 0
}
switch v := status["code"].(type) {
case float64:
return int(v)
case int:
return v
}
return 0
}
func isRootSpan(e map[string]any) bool {
parent := stringProp(e, "parentSpanId")
return parent == "" || parent == "0000000000000000"
}
func findSpanWithOperation(spans []map[string]any, op string) map[string]any {
for _, span := range spans {
if stringAttr(span, "gen_ai.operation.name") == op {
return span
}
}
return nil
}
// ---------------------------------------------------------------------------
// Unit-style tests mirroring dotnet/test/TelemetryTests.cs.
// These exercise the TelemetryConfig / ClientOptions struct shape only.
// ---------------------------------------------------------------------------
// TestTelemetryConfigUnit covers the dataclass-equivalent unit tests.
//
// CopilotClientOptions_Clone_CopiesTelemetry from the C# baseline has no Go
// equivalent (ClientOptions has no Clone() method).
//
// TelemetryHelpers_Restores_W3C_Trace_Context lives in the copilot package
// (helpers are unexported), so it is tested in go/telemetry_test.go and is
// intentionally not duplicated here.
func TestTelemetryConfigUnit(t *testing.T) {
t.Run("default values are zero", func(t *testing.T) {
// Mirrors: TelemetryConfig_DefaultValues_AreNull
var cfg copilot.TelemetryConfig
if cfg.OTLPEndpoint != "" {
t.Errorf("Expected empty OTLPEndpoint, got %q", cfg.OTLPEndpoint)
}
if cfg.OTLPProtocol != "" {
t.Errorf("Expected empty OTLPProtocol, got %q", cfg.OTLPProtocol)
}
if cfg.FilePath != "" {
t.Errorf("Expected empty FilePath, got %q", cfg.FilePath)
}
if cfg.ExporterType != "" {
t.Errorf("Expected empty ExporterType, got %q", cfg.ExporterType)
}
if cfg.SourceName != "" {
t.Errorf("Expected empty SourceName, got %q", cfg.SourceName)
}
if cfg.CaptureContent != nil {
t.Errorf("Expected nil CaptureContent, got %v", cfg.CaptureContent)
}
})
t.Run("can set all properties", func(t *testing.T) {
// Mirrors: TelemetryConfig_CanSetAllProperties
cfg := copilot.TelemetryConfig{
OTLPEndpoint: "http://localhost:4318",
OTLPProtocol: "http/protobuf",
FilePath: "/tmp/traces.json",
ExporterType: "otlp-http",
SourceName: "my-app",
CaptureContent: copilot.Bool(true),
}
if cfg.OTLPEndpoint != "http://localhost:4318" {
t.Errorf("OTLPEndpoint mismatch: %q", cfg.OTLPEndpoint)
}
if cfg.OTLPProtocol != "http/protobuf" {
t.Errorf("OTLPProtocol mismatch: %q", cfg.OTLPProtocol)
}
if cfg.FilePath != "/tmp/traces.json" {
t.Errorf("FilePath mismatch: %q", cfg.FilePath)
}
if cfg.ExporterType != "otlp-http" {
t.Errorf("ExporterType mismatch: %q", cfg.ExporterType)
}
if cfg.SourceName != "my-app" {
t.Errorf("SourceName mismatch: %q", cfg.SourceName)
}
if cfg.CaptureContent == nil || *cfg.CaptureContent != true {
t.Errorf("CaptureContent mismatch: %v", cfg.CaptureContent)
}
})
t.Run("client options telemetry defaults to nil", func(t *testing.T) {
// Mirrors: CopilotClientOptions_Telemetry_DefaultsToNull
opts := copilot.ClientOptions{}
if opts.Telemetry != nil {
t.Errorf("Expected ClientOptions.Telemetry to be nil by default, got %v", opts.Telemetry)
}
})
}