Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- Fixed GitHub Copilot local session transcripts returning "Failed to load session transcript". Copilot lists sessions from `copilot_chat.session.start` logs, but conversation content is on GenAI spans; transcripts now reconstruct user, assistant, and tool messages from those spans instead of 404ing when no log events map to roles.

## [0.5.0] - 2026-06-18

### Added
Expand Down
59 changes: 59 additions & 0 deletions backend/internal/handlers/query_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -643,3 +643,62 @@ func TestGetBreakdownValues(t *testing.T) {
})
}
}

func TestGetSessionTranscript_CopilotListedSessionReturnsTranscript(t *testing.T) {
h, cleanup := setupTestHandlers(t)
defer cleanup()

ctx := context.Background()
now := time.Now().UTC().Truncate(time.Millisecond)
sessionID := "copilot-conv-1"

if err := h.store.InsertLogs(ctx, []api.LogRecord{{
Timestamp: now,
ServiceName: "copilot-chat",
Body: "copilot_chat.session.start",
LogAttributes: map[string]string{
"event.name": "copilot_chat.session.start",
"session.id": sessionID,
},
}}); err != nil {
t.Fatalf("failed to insert copilot session log: %v", err)
}
if err := h.store.InsertSpans(ctx, []api.Span{{
Timestamp: now.Add(time.Second),
TraceID: "trace-copilot",
SpanID: "span-agent",
ServiceName: "copilot-chat",
SpanName: "invoke_agent copilot",
StatusCode: "OK",
SpanAttributes: map[string]string{
"gen_ai.operation.name": "invoke_agent",
"gen_ai.conversation.id": sessionID,
"gen_ai.input.messages": `[{"role":"user","parts":[{"type":"text","content":"hello from copilot"}]}]`,
},
}}); err != nil {
t.Fatalf("failed to insert copilot spans: %v", err)
}

req := httptest.NewRequest(http.MethodGet, "/api/sessions/"+sessionID+"/transcript", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("sessionId", sessionID)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))

rec := httptest.NewRecorder()
h.GetSessionTranscript(rec, req)

if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}

var resp api.TranscriptResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.SessionID != sessionID {
t.Fatalf("expected session %q, got %q", sessionID, resp.SessionID)
}
if len(resp.Messages) == 0 || resp.Messages[0].Role != "user" {
t.Fatalf("expected reconstructed user message, got %+v", resp.Messages)
}
}
4 changes: 4 additions & 0 deletions backend/internal/otlp/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package otlp
import (
"encoding/json"
"strconv"
"strings"
"time"

"github.com/tobilg/ai-observer/internal/api"
Expand Down Expand Up @@ -35,6 +36,9 @@ func ConvertLogs(req *collogspb.ExportLogsServiceRequest) LogConversionResult {

for _, lr := range sl.GetLogRecords() {
logAttrs := convertAttributes(lr.GetAttributes())
if eventName := strings.TrimSpace(lr.GetEventName()); eventName != "" && logAttrs["event.name"] == "" {
logAttrs["event.name"] = eventName
}

// Resolve timestamp with fallbacks before creating log record
timestamp := nanosToTime(lr.GetTimeUnixNano())
Expand Down
47 changes: 47 additions & 0 deletions backend/internal/otlp/logs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -384,3 +384,50 @@ func TestConvertLogs_CodexSSEEvent_NonResponseCompleted_NoMetrics(t *testing.T)
t.Errorf("Expected 0 derived metrics for chunk event, got %d", len(result.DerivedMetrics))
}
}

func TestConvertLogs_PreservesProtoEventName(t *testing.T) {
payload := `{
"resourceLogs": [{
"resource": {
"attributes": [
{"key": "service.name", "value": {"stringValue": "copilot-chat"}}
]
},
"scopeLogs": [{
"scope": {"name": "copilot-chat"},
"logRecords": [
{
"timeUnixNano": "1703500000000000000",
"severityNumber": 9,
"eventName": "copilot_chat.session.start",
"body": {"stringValue": "copilot_chat.session.start"},
"attributes": [
{"key": "session.id", "value": {"stringValue": "conv-123"}}
]
}
]
}]
}]
}`

decoder, err := GetDecoder("application/json")
if err != nil {
t.Fatalf("Failed to get decoder: %v", err)
}

req, err := decoder.DecodeLogs(strings.NewReader(payload))
if err != nil {
t.Fatalf("Failed to decode logs: %v", err)
}

result := ConvertLogs(req)
if len(result.Logs) != 1 {
t.Fatalf("Expected 1 log record, got %d", len(result.Logs))
}
if got := result.Logs[0].LogAttributes["event.name"]; got != "copilot_chat.session.start" {
t.Fatalf("expected event.name from proto EventName, got %q", got)
}
if got := result.Logs[0].LogAttributes["session.id"]; got != "conv-123" {
t.Fatalf("expected session.id conv-123, got %q", got)
}
}
Loading
Loading