Refactor recordingmetadata service to be able to support different session types (#64260)

* Refactor recordingmetadata service to be able to support different session types

* Do not capture thumbnails on resize, correctly set thumbnail time/interval

* Fix inactivity offsets, improve the test

* Improve the session join event test

* Update ProcessSessionRecording in test

* Fix test

* Satisfy sloglint

* Fix tests
This commit is contained in:
Ryan Clark
2026-03-17 16:53:14 +00:00
committed by GitHub
parent 059961951d
commit da0503c4d8
16 changed files with 992 additions and 728 deletions
@@ -346,7 +346,7 @@ func TestSessionCompleter(t *testing.T) {
metadataProvider := recordingmetadata.NewProvider()
recorderMetadata := &fakeRecordingMetadata{}
recorderMetadata.On("ProcessSessionRecording", mock.Anything, sessionID, mock.Anything).
recorderMetadata.On("ProcessSessionRecording", mock.Anything, sessionID, mock.Anything, mock.Anything).
Return(nil).Once()
metadataProvider.SetService(recorderMetadata)
@@ -387,8 +387,8 @@ type fakeRecordingMetadata struct {
mock.Mock
}
func (f *fakeRecordingMetadata) ProcessSessionRecording(ctx context.Context, sessionID session.ID, duration time.Duration) error {
args := f.Called(ctx, sessionID, duration)
func (f *fakeRecordingMetadata) ProcessSessionRecording(ctx context.Context, sessionID session.ID, sessionType recordingmetadata.SessionType, duration time.Duration) error {
args := f.Called(ctx, sessionID, sessionType, duration)
return args.Error(0)
}
+1 -1
View File
@@ -76,6 +76,6 @@ type noopRecordingMetadata struct{}
// ProcessSessionRecording is a no-op implementation of the
// [Service.ProcessSessionRecording] method.
func (n noopRecordingMetadata) ProcessSessionRecording(ctx context.Context, sessionID session.ID, duration time.Duration) error {
func (n noopRecordingMetadata) ProcessSessionRecording(ctx context.Context, sessionID session.ID, sessionType SessionType, duration time.Duration) error {
return nil
}
@@ -0,0 +1,121 @@
/**
* Teleport
* Copyright (C) 2026 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package recordingmetadatav1
import (
"context"
"io"
"log/slog"
"math"
"time"
"google.golang.org/protobuf/encoding/protodelim"
"google.golang.org/protobuf/types/known/durationpb"
pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/recordingmetadata/v1"
apievents "github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/lib/auth/recordingmetadata"
)
// recordingProcessor consumes audit events from a session recording and produces metadata and a representative
// thumbnail. Implementations are specific to a session type (e.g. TTY for now, desktop and database in the future).
type recordingProcessor interface {
// handleEvent processes a single audit event and updates the processor's internal state. Returning an error aborts
// metadata generation for the recording.
handleEvent(event apievents.AuditEvent) error
// collect finalizes the metadata and returns the session recording metadata along with the chosen thumbnail.
// It should be called after all events have been processed.
collect() (*pb.SessionRecordingMetadata, *pb.SessionRecordingThumbnail)
}
func newRecordingProcessor(writer io.WriteCloser, logger *slog.Logger, sessionType recordingmetadata.SessionType, duration time.Duration) recordingProcessor {
base := baseRecordingProcessor{
metadata: &pb.SessionRecordingMetadata{},
writer: writer,
logger: logger,
thumbnailInterval: calculateThumbnailInterval(duration, maxThumbnails),
thumbnailTime: getRandomThumbnailTime(duration),
}
if sessionType == recordingmetadata.SessionTypeTTY {
return newTTYRecordingProcessor(base)
}
return &noopRecordingProcessor{}
}
// baseRecordingProcessor holds the shared state and helpers used by all session-type-specific processors, such as
// the writer for streaming thumbnail frames and the logic for selecting the best representative thumbnail.
type baseRecordingProcessor struct {
metadata *pb.SessionRecordingMetadata
thumbnailGenerator thumbnailGenerator
lastEvent apievents.AuditEvent
lastActivityTime time.Time
startTime time.Time
thumbnailInterval time.Duration
thumbnailTime time.Duration
lastThumbnailTime time.Time
thumbnail *pb.SessionRecordingThumbnail
writer io.WriteCloser
logger *slog.Logger
}
func (b *baseRecordingProcessor) captureThumbnailIfNeeded(eventTime time.Time) {
if eventTime.Sub(b.lastThumbnailTime) < b.thumbnailInterval {
return
}
b.lastThumbnailTime = eventTime
thumbnail := b.thumbnailGenerator.produceThumbnail()
thumbnail.StartOffset = durationpb.New(eventTime.Sub(b.startTime))
thumbnail.EndOffset = durationpb.New(eventTime.Add(b.thumbnailInterval).Add(-1 * time.Millisecond).Sub(b.startTime))
if _, err := protodelim.MarshalTo(b.writer, thumbnail); err != nil {
// log the error but continue processing other thumbnails and the session metadata (metadata is more important)
b.logger.WarnContext(context.Background(), "Failed to marshal thumbnail entry", "error", err)
}
if b.thumbnail == nil {
b.thumbnail = thumbnail
return
}
previousDiff := math.Abs(float64(b.thumbnailTime - b.thumbnail.StartOffset.AsDuration()))
diff := math.Abs(float64(b.thumbnailTime - eventTime.Sub(b.startTime)))
if diff < previousDiff {
// this thumbnail is closer to the ideal thumbnail time, use it instead
b.thumbnail = thumbnail
}
}
type noopRecordingProcessor struct{}
func (n *noopRecordingProcessor) handleEvent(event apievents.AuditEvent) error {
return nil
}
func (n *noopRecordingProcessor) collect() (*pb.SessionRecordingMetadata, *pb.SessionRecordingThumbnail) {
return nil, nil
}
@@ -24,27 +24,22 @@ import (
"errors"
"io"
"log/slog"
"math"
"math/rand/v2"
"sync"
"time"
"github.com/gravitational/trace"
"github.com/hinshun/vt10x"
"golang.org/x/sync/semaphore"
"google.golang.org/protobuf/encoding/protodelim"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/gravitational/teleport"
pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/recordingmetadata/v1"
apievents "github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/lib/auth/recordingencryption"
"github.com/gravitational/teleport/lib/auth/recordingmetadata"
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/player"
"github.com/gravitational/teleport/lib/session"
"github.com/gravitational/teleport/lib/terminal"
)
// UploadHandler uploads session recording metadata and thumbnails.
@@ -111,7 +106,13 @@ func NewRecordingMetadataService(cfg RecordingMetadataServiceConfig) (*Recording
// ProcessSessionRecording processes the session recording associated with the provided session ID.
// It streams session events, generates metadata, and uploads thumbnails and metadata.
func (s *RecordingMetadataService) ProcessSessionRecording(ctx context.Context, sessionID session.ID, duration time.Duration) error {
func (s *RecordingMetadataService) ProcessSessionRecording(ctx context.Context, sessionID session.ID, sessionType recordingmetadata.SessionType, duration time.Duration) error {
if sessionType != recordingmetadata.SessionTypeTTY {
// Currently only TTY sessions (SSH + Kubernetes) are supported, so avoid doing any unnecessary work if the session
// is a different type.
return nil
}
sessionsPendingMetric.Inc()
if err := s.concurrencyLimiter.Acquire(ctx, 1); err != nil {
@@ -128,32 +129,6 @@ func (s *RecordingMetadataService) ProcessSessionRecording(ctx context.Context,
ctx, cancel := context.WithCancel(ctx)
defer cancel()
evts, errors := s.streamer.StreamSessionEvents(ctx, sessionID, 0)
var startTime time.Time
var lastEvent apievents.AuditEvent
var lastActivityTime time.Time
var lastThumbnailTime time.Time
activeUsers := make(map[string]time.Duration)
vt := vt10x.New()
metadata := &pb.SessionRecordingMetadata{}
addInactivityEvent := func(start, end time.Time) {
inactivityStart := durationpb.New(start.Sub(startTime))
inactivityEnd := durationpb.New(end.Sub(startTime))
metadata.Events = append(metadata.Events, &pb.SessionRecordingEvent{
StartOffset: inactivityStart,
EndOffset: inactivityEnd,
Event: &pb.SessionRecordingEvent_Inactivity{
Inactivity: &pb.SessionRecordingInactivityEvent{},
},
})
}
// will either finish the upload or cancel it if exited early
var finish sync.Once
@@ -170,52 +145,8 @@ func (s *RecordingMetadataService) ProcessSessionRecording(ctx context.Context,
})
}()
interval := calculateThumbnailInterval(duration, maxThumbnails)
thumbnailTime := getRandomThumbnailTime(duration)
// the thumbnail to upload for the session
var recordingThumbnail *pb.SessionRecordingThumbnail
recordThumbnail := func(start time.Time) {
cols, rows := vt.Size()
cursor := vt.Cursor()
startOffset := start.Sub(startTime)
endOffset := start.Add(interval).Add(-1 * time.Millisecond).Sub(startTime)
thumbnail := &pb.SessionRecordingThumbnail{
Svg: terminal.VtToSvg(vt),
Cols: int32(cols),
Rows: int32(rows),
CursorX: int32(cursor.X),
CursorY: int32(cursor.Y),
CursorVisible: vt.CursorVisible(),
StartOffset: durationpb.New(startOffset),
EndOffset: durationpb.New(endOffset),
}
if _, err := protodelim.MarshalTo(w, thumbnail); err != nil {
// log the error but continue processing other thumbnails and the session metadata (metadata is more important)
s.logger.WarnContext(ctx, "Failed to marshal thumbnail entry",
"session_id", sessionID, "error", err)
}
if recordingThumbnail == nil {
recordingThumbnail = thumbnail
return
}
previousDiff := math.Abs(float64(thumbnailTime - recordingThumbnail.StartOffset.AsDuration()))
diff := math.Abs(float64(thumbnailTime - startOffset))
if diff < previousDiff {
// this thumbnail is closer to the ideal thumbnail time, use it instead
recordingThumbnail = thumbnail
}
}
var hasSeenPrintEvent bool
processor := newRecordingProcessor(w, s.logger.With("session_id", sessionID), sessionType, duration)
evts, errors := s.streamer.StreamSessionEvents(ctx, sessionID, 0)
loop:
for {
@@ -225,115 +156,8 @@ loop:
break loop
}
lastEvent = evt
switch e := evt.(type) {
case *apievents.DatabaseSessionStart, *apievents.WindowsDesktopSessionStart:
// Unsupported session recording types
return nil
case *apievents.Resize:
size, err := session.UnmarshalTerminalParams(e.TerminalSize)
if err != nil {
return trace.Wrap(err, "parsing terminal size %q for session %v", e.TerminalSize, sessionID)
}
// if we haven't seen a print event yet, update the starting size to the latest resize
// this handles cases where the initial terminal size is not 80x24 and is resized immediately
// before any output is printed
if !hasSeenPrintEvent {
metadata.StartCols = int32(size.W)
metadata.StartRows = int32(size.H)
}
metadata.Events = append(metadata.Events, &pb.SessionRecordingEvent{
StartOffset: durationpb.New(e.Time.Sub(startTime)),
Event: &pb.SessionRecordingEvent_Resize{
Resize: &pb.SessionRecordingResizeEvent{
Cols: int32(size.W),
Rows: int32(size.H),
},
},
})
vt.Resize(size.W, size.H)
case *apievents.SessionEnd:
if !lastActivityTime.IsZero() && e.Time.Sub(lastActivityTime) > inactivityThreshold {
addInactivityEvent(lastActivityTime, e.Time)
}
if e.Time.Sub(lastThumbnailTime) >= interval {
lastThumbnailTime = e.Time
recordThumbnail(e.Time)
}
case *apievents.SessionJoin:
activeUsers[e.User] = e.Time.Sub(startTime)
case *apievents.SessionLeave:
if joinTime, ok := activeUsers[e.User]; ok {
metadata.Events = append(metadata.Events, &pb.SessionRecordingEvent{
StartOffset: durationpb.New(joinTime),
EndOffset: durationpb.New(e.Time.Sub(startTime)),
Event: &pb.SessionRecordingEvent_Join{
Join: &pb.SessionRecordingJoinEvent{
User: e.User,
},
},
})
delete(activeUsers, e.User)
}
case *apievents.SessionPrint:
// mark that we've seen the first print event so we don't update the starting size anymore
if !hasSeenPrintEvent {
hasSeenPrintEvent = true
}
if !lastActivityTime.IsZero() && e.Time.Sub(lastActivityTime) > inactivityThreshold {
addInactivityEvent(lastActivityTime, e.Time)
}
if _, err := vt.Write(e.Data); err != nil {
return trace.Errorf("writing data to terminal: %w", err)
}
if e.Time.Sub(lastThumbnailTime) >= interval {
lastThumbnailTime = e.Time
recordThumbnail(e.Time)
}
lastActivityTime = e.Time
case *apievents.SessionStart:
lastActivityTime = e.Time
startTime = e.Time
size, err := session.UnmarshalTerminalParams(e.TerminalSize)
if err != nil {
return trace.Wrap(err, "parsing terminal size %q for session %v", e.TerminalSize, sessionID)
}
// store the initial terminal size, this is typically 80:24 and is resized immediately
metadata.StartCols = int32(size.W)
metadata.StartRows = int32(size.H)
metadata.ClusterName = e.ClusterName
metadata.User = e.User
switch e.Protocol {
case events.EventProtocolSSH:
metadata.ResourceName = e.ServerHostname
metadata.Type = pb.SessionRecordingType_SESSION_RECORDING_TYPE_SSH
case events.EventProtocolKube:
metadata.ResourceName = e.KubernetesCluster
metadata.Type = pb.SessionRecordingType_SESSION_RECORDING_TYPE_KUBERNETES
}
vt.Resize(size.W, size.H)
if err := processor.handleEvent(evt); err != nil {
return trace.Wrap(err)
}
case err := <-uploadErrs:
@@ -349,34 +173,18 @@ loop:
}
}
if lastEvent == nil {
metadata, thumbnail := processor.collect()
if metadata == nil {
return trace.NotFound("no events found for session %v", sessionID)
}
if recordingThumbnail != nil {
if err := s.uploadThumbnail(ctx, sessionID, recordingThumbnail); err != nil {
if thumbnail != nil {
if err := s.uploadThumbnail(ctx, sessionID, thumbnail); err != nil {
s.logger.WarnContext(ctx, "Failed to upload thumbnail",
"session_id", sessionID, "error", err)
}
}
// Finish off any remaining activity events
for user, userStartOffset := range activeUsers {
metadata.Events = append(metadata.Events, &pb.SessionRecordingEvent{
StartOffset: durationpb.New(userStartOffset),
EndOffset: durationpb.New(lastEvent.GetTime().Sub(startTime)),
Event: &pb.SessionRecordingEvent_Join{
Join: &pb.SessionRecordingJoinEvent{
User: user,
},
},
})
}
metadata.Duration = durationpb.New(lastEvent.GetTime().Sub(startTime))
metadata.StartTime = timestamppb.New(startTime)
metadata.EndTime = timestamppb.New(lastEvent.GetTime())
if _, err := protodelim.MarshalTo(w, metadata); err != nil {
return trace.Wrap(err)
}
@@ -19,8 +19,6 @@
package recordingmetadatav1
import (
"bufio"
"bytes"
"context"
"errors"
"io"
@@ -28,169 +26,14 @@ import (
"testing"
"time"
"github.com/gravitational/trace"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/recordingmetadata/v1"
apievents "github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/lib/auth/recordingmetadata"
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/session"
)
func TestProcessSessionRecording(t *testing.T) {
startTime := time.Now()
tests := []struct {
name string
events []apievents.AuditEvent
expectError bool
expectedThumbnails func(t *testing.T, thumbnailData []byte)
expectedMetadata func(t *testing.T, metadata *pb.SessionRecordingMetadata)
expectedFrames func(t *testing.T, frames []*pb.SessionRecordingThumbnail)
}{
{
name: "basic session with print events",
events: generateBasicSession(startTime),
expectedMetadata: func(t *testing.T, metadata *pb.SessionRecordingMetadata) {
require.NotNil(t, metadata)
require.NotNil(t, metadata.Duration)
require.Equal(t, int32(80), metadata.StartCols)
require.Equal(t, int32(24), metadata.StartRows)
},
expectedFrames: func(t *testing.T, frames []*pb.SessionRecordingThumbnail) {
require.NotEmpty(t, frames)
},
expectedThumbnails: func(t *testing.T, thumbnailData []byte) {
var thumbnail pb.SessionRecordingThumbnail
err := proto.Unmarshal(thumbnailData, &thumbnail)
require.NoError(t, err)
require.NotEmpty(t, thumbnail.Svg)
},
},
{
name: "basic session with print events with immediate resize after start",
events: generateBasicSessionWithImmediateResize(startTime),
expectedMetadata: func(t *testing.T, metadata *pb.SessionRecordingMetadata) {
require.NotNil(t, metadata)
require.NotNil(t, metadata.Duration)
require.Equal(t, int32(100), metadata.StartCols)
require.Equal(t, int32(30), metadata.StartRows)
},
expectedFrames: func(t *testing.T, frames []*pb.SessionRecordingThumbnail) {
require.NotEmpty(t, frames)
},
expectedThumbnails: func(t *testing.T, thumbnailData []byte) {
var thumbnail pb.SessionRecordingThumbnail
err := proto.Unmarshal(thumbnailData, &thumbnail)
require.NoError(t, err)
require.NotEmpty(t, thumbnail.Svg)
},
},
{
name: "session with resize events",
events: generateSessionWithResize(startTime),
expectedMetadata: func(t *testing.T, metadata *pb.SessionRecordingMetadata) {
require.NotNil(t, metadata)
var hasResize bool
for _, event := range metadata.Events {
if resize := event.GetResize(); resize != nil {
hasResize = true
require.Equal(t, int32(120), resize.Cols)
require.Equal(t, int32(40), resize.Rows)
}
}
require.True(t, hasResize, "expected resize event")
},
},
{
name: "session with inactivity periods",
events: generateSessionWithInactivity(startTime),
expectedMetadata: func(t *testing.T, metadata *pb.SessionRecordingMetadata) {
require.NotNil(t, metadata)
var hasInactivity bool
for _, event := range metadata.Events {
if event.GetInactivity() != nil {
hasInactivity = true
require.NotNil(t, event.StartOffset)
require.NotNil(t, event.EndOffset)
}
}
require.True(t, hasInactivity, "expected inactivity event")
},
},
{
name: "session with join and leave events",
events: generateSessionWithJoinLeave(startTime),
expectedMetadata: func(t *testing.T, metadata *pb.SessionRecordingMetadata) {
require.NotNil(t, metadata)
joinUsers := make(map[string]bool)
for _, event := range metadata.Events {
if join := event.GetJoin(); join != nil {
joinUsers[join.User] = true
}
}
require.True(t, joinUsers["alice"], "expected alice join event")
require.True(t, joinUsers["bob"], "expected bob join event")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sessionID := session.NewID()
streamer := &mockStreamer{
events: tt.events,
errorOnEvent: -1,
}
uploadHandler := newMockUploadHandler()
service, err := NewRecordingMetadataService(RecordingMetadataServiceConfig{
Streamer: streamer,
UploadHandler: uploadHandler,
})
require.NoError(t, err)
lastEventTime := tt.events[len(tt.events)-1].GetTime()
err = service.ProcessSessionRecording(t.Context(), sessionID, lastEventTime.Sub(startTime))
if tt.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
uploadHandler.mu.Lock()
metadataData, ok := uploadHandler.metadata[string(sessionID)]
uploadHandler.mu.Unlock()
require.True(t, ok, "metadata should be uploaded")
metadata, frames, err := unmarshalMetadata(metadataData)
require.NoError(t, err)
if tt.expectedMetadata != nil {
tt.expectedMetadata(t, metadata)
}
if tt.expectedFrames != nil {
tt.expectedFrames(t, frames)
}
uploadHandler.mu.Lock()
thumbnailData, ok := uploadHandler.thumbnails[string(sessionID)]
uploadHandler.mu.Unlock()
if ok && tt.expectedThumbnails != nil {
tt.expectedThumbnails(t, thumbnailData)
}
}
})
}
}
func TestProcessSessionRecording_StreamError(t *testing.T) {
sessionID := session.NewID()
@@ -205,7 +48,7 @@ func TestProcessSessionRecording_StreamError(t *testing.T) {
})
require.NoError(t, err)
err = service.ProcessSessionRecording(t.Context(), sessionID, 10*time.Second)
err = service.ProcessSessionRecording(t.Context(), sessionID, recordingmetadata.SessionTypeTTY, 10*time.Second)
require.Error(t, err)
require.Contains(t, err.Error(), "stream error")
}
@@ -227,7 +70,7 @@ func TestProcessSessionRecording_UploadError(t *testing.T) {
})
require.NoError(t, err)
err = service.ProcessSessionRecording(t.Context(), sessionID, 10*time.Second)
err = service.ProcessSessionRecording(t.Context(), sessionID, recordingmetadata.SessionTypeTTY, 10*time.Second)
require.Error(t, err)
require.Contains(t, err.Error(), "upload failed")
}
@@ -250,7 +93,7 @@ func TestProcessSessionRecording_ContextCancellation(t *testing.T) {
processDone := make(chan error, 1)
go func() {
processDone <- service.ProcessSessionRecording(ctx, sessionID, 10*time.Second)
processDone <- service.ProcessSessionRecording(ctx, sessionID, recordingmetadata.SessionTypeTTY, 10*time.Second)
}()
streamer.WaitUntilBlocking()
@@ -267,104 +110,9 @@ func TestProcessSessionRecording_ContextCancellation(t *testing.T) {
}
func TestProcessSessionRecording_UnsupportedSessionTypes(t *testing.T) {
startTime := time.Now()
sessionID := session.NewID()
tests := []struct {
name string
events []apievents.AuditEvent
}{
{
name: "database session",
events: []apievents.AuditEvent{
&apievents.DatabaseSessionStart{
Metadata: apievents.Metadata{
Type: "db.session.start",
Time: startTime,
},
},
},
},
{
name: "windows desktop session",
events: []apievents.AuditEvent{
&apievents.WindowsDesktopSessionStart{
Metadata: apievents.Metadata{
Type: "windows.desktop.session.start",
Time: startTime,
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
streamer := &mockStreamer{
events: tt.events,
errorOnEvent: -1,
}
uploadHandler := newMockUploadHandler()
service, err := NewRecordingMetadataService(RecordingMetadataServiceConfig{
Streamer: streamer,
UploadHandler: uploadHandler,
})
require.NoError(t, err)
err = service.ProcessSessionRecording(t.Context(), sessionID, 10*time.Second)
require.NoError(t, err)
uploadHandler.mu.Lock()
metadataLen := len(uploadHandler.metadata)
thumbnailsLen := len(uploadHandler.thumbnails)
uploadHandler.mu.Unlock()
require.Equal(t, 0, metadataLen, "metadata should be empty")
require.Equal(t, 0, thumbnailsLen, "thumbnails should be empty")
})
}
}
func TestProcessSessionRecording_MalformedResizeEvent(t *testing.T) {
startTime := time.Now()
sessionID := session.NewID()
events := []apievents.AuditEvent{
&apievents.SessionStart{
Metadata: apievents.Metadata{
Type: "session.start",
Time: startTime,
},
TerminalSize: "80:24",
},
&apievents.SessionPrint{
Metadata: apievents.Metadata{
Type: "print",
Time: startTime.Add(1 * time.Second),
},
Data: []byte("Hello\n"),
},
&apievents.Resize{
Metadata: apievents.Metadata{
Type: "resize",
Time: startTime.Add(2 * time.Second),
},
TerminalSize: "invalid:terminal:size",
},
&apievents.SessionEnd{
Metadata: apievents.Metadata{
Type: "session.end",
Time: startTime.Add(10 * time.Second),
},
StartTime: startTime,
EndTime: startTime.Add(10 * time.Second),
},
}
streamer := &mockStreamer{
events: events,
errorOnEvent: -1,
}
uploadHandler := newMockUploadHandler()
@@ -375,7 +123,43 @@ func TestProcessSessionRecording_MalformedResizeEvent(t *testing.T) {
})
require.NoError(t, err)
err = service.ProcessSessionRecording(t.Context(), sessionID, 10*time.Second)
err = service.ProcessSessionRecording(t.Context(), sessionID, recordingmetadata.SessionTypeUnspecified, 10*time.Second)
require.NoError(t, err)
uploadHandler.mu.Lock()
metadataLen := len(uploadHandler.metadata)
thumbnailsLen := len(uploadHandler.thumbnails)
uploadHandler.mu.Unlock()
require.Equal(t, 0, metadataLen, "metadata should be empty")
require.Equal(t, 0, thumbnailsLen, "thumbnails should be empty")
}
func TestProcessSessionRecording_MalformedResizeEvent(t *testing.T) {
startTime := time.Now()
sessionID := session.NewID()
evts := []apievents.AuditEvent{
sessionStartEvent(startTime, "80:24"),
sessionPrintEvent(startTime.Add(1*time.Second), "Hello\n"),
resizeEvent(startTime.Add(2*time.Second), "invalid:terminal:size"),
sessionEndEvent(startTime, startTime.Add(10*time.Second)),
}
streamer := &mockStreamer{
events: evts,
errorOnEvent: -1,
}
uploadHandler := newMockUploadHandler()
service, err := NewRecordingMetadataService(RecordingMetadataServiceConfig{
Streamer: streamer,
UploadHandler: uploadHandler,
})
require.NoError(t, err)
err = service.ProcessSessionRecording(t.Context(), sessionID, recordingmetadata.SessionTypeTTY, 10*time.Second)
require.Error(t, err)
require.Contains(t, err.Error(), "parsing terminal size")
@@ -390,40 +174,15 @@ func TestProcessSessionRecording_UploadFailsDuringProcessing(t *testing.T) {
startTime := time.Now()
sessionID := session.NewID()
events := []apievents.AuditEvent{
&apievents.SessionStart{
Metadata: apievents.Metadata{
Type: "session.start",
Time: startTime,
},
TerminalSize: "80:24",
},
&apievents.SessionPrint{
Metadata: apievents.Metadata{
Type: "print",
Time: startTime.Add(1 * time.Second),
},
Data: []byte("Hello\n"),
},
&apievents.SessionPrint{
Metadata: apievents.Metadata{
Type: "print",
Time: startTime.Add(2 * time.Second),
},
Data: []byte("World\n"),
},
&apievents.SessionEnd{
Metadata: apievents.Metadata{
Type: "session.end",
Time: startTime.Add(10 * time.Second),
},
StartTime: startTime,
EndTime: startTime.Add(10 * time.Second),
},
evts := []apievents.AuditEvent{
sessionStartEvent(startTime, "80:24"),
sessionPrintEvent(startTime.Add(1*time.Second), "Hello\n"),
sessionPrintEvent(startTime.Add(2*time.Second), "World\n"),
sessionEndEvent(startTime, startTime.Add(10*time.Second)),
}
streamer := &mockStreamer{
events: events,
events: evts,
errorOnEvent: -1,
}
@@ -437,12 +196,55 @@ func TestProcessSessionRecording_UploadFailsDuringProcessing(t *testing.T) {
})
require.NoError(t, err)
err = service.ProcessSessionRecording(t.Context(), sessionID, 10*time.Second)
err = service.ProcessSessionRecording(t.Context(), sessionID, recordingmetadata.SessionTypeTTY, 10*time.Second)
require.Error(t, err)
require.Contains(t, err.Error(), "simulated upload failure")
}
func sessionStartEvent(t time.Time, size string) *apievents.SessionStart {
return &apievents.SessionStart{
Metadata: apievents.Metadata{Type: "session.start", Time: t},
TerminalSize: size,
}
}
func sessionPrintEvent(t time.Time, data string) *apievents.SessionPrint {
return &apievents.SessionPrint{
Metadata: apievents.Metadata{Type: "print", Time: t},
Data: []byte(data),
}
}
func resizeEvent(t time.Time, size string) *apievents.Resize {
return &apievents.Resize{
Metadata: apievents.Metadata{Type: "resize", Time: t},
TerminalSize: size,
}
}
func sessionEndEvent(startTime time.Time, endTime time.Time) *apievents.SessionEnd {
return &apievents.SessionEnd{
Metadata: apievents.Metadata{Type: "session.end", Time: endTime},
StartTime: startTime,
EndTime: endTime,
}
}
func sessionJoinEvent(t time.Time, user string) *apievents.SessionJoin {
return &apievents.SessionJoin{
Metadata: apievents.Metadata{Type: "session.join", Time: t},
UserMetadata: apievents.UserMetadata{User: user},
}
}
func sessionLeaveEvent(t time.Time, user string) *apievents.SessionLeave {
return &apievents.SessionLeave{
Metadata: apievents.Metadata{Type: "session.leave", Time: t},
UserMetadata: apievents.UserMetadata{User: user},
}
}
// mockUploadHandlerFailAfterRead simulates an upload failure after reading some bytes
type mockUploadHandlerFailAfterRead struct {
failAfterBytes int
@@ -488,224 +290,6 @@ func (m *mockUploadHandlerFailAfterRead) UploadThumbnail(ctx context.Context, se
return "thumbnail/success", nil
}
func generateBasicSessionWithImmediateResize(startTime time.Time) []apievents.AuditEvent {
events := generateBasicSession(startTime)
// Insert a resize event immediately after session start
resizeEvent := &apievents.Resize{
Metadata: apievents.Metadata{
Type: "resize",
Time: startTime.Add(500 * time.Millisecond),
},
TerminalSize: "100:30",
}
events = append([]apievents.AuditEvent{events[0], resizeEvent}, events[1:]...)
return events
}
func generateBasicSession(startTime time.Time) []apievents.AuditEvent {
events := []apievents.AuditEvent{
&apievents.SessionStart{
Metadata: apievents.Metadata{
Type: "session.start",
Time: startTime,
},
TerminalSize: "80:24",
},
&apievents.SessionPrint{
Metadata: apievents.Metadata{
Type: "print",
Time: startTime.Add(1 * time.Second),
},
Data: []byte("Hello World\n"),
},
&apievents.SessionPrint{
Metadata: apievents.Metadata{
Type: "print",
Time: startTime.Add(2 * time.Second),
},
Data: []byte("$ ls -la\n"),
},
&apievents.SessionEnd{
Metadata: apievents.Metadata{
Type: "session.end",
Time: startTime.Add(10 * time.Second),
},
StartTime: startTime,
EndTime: startTime.Add(10 * time.Second),
},
}
return events
}
func generateSessionWithResize(startTime time.Time) []apievents.AuditEvent {
events := []apievents.AuditEvent{
&apievents.SessionStart{
Metadata: apievents.Metadata{
Type: "session.start",
Time: startTime,
},
TerminalSize: "80:24",
},
&apievents.SessionPrint{
Metadata: apievents.Metadata{
Type: "print",
Time: startTime.Add(1 * time.Second),
},
Data: []byte("Initial output\n"),
},
&apievents.Resize{
Metadata: apievents.Metadata{
Type: "resize",
Time: startTime.Add(2 * time.Second),
},
TerminalSize: "120:40",
},
&apievents.SessionPrint{
Metadata: apievents.Metadata{
Type: "print",
Time: startTime.Add(3 * time.Second),
},
Data: []byte("After resize\n"),
},
&apievents.SessionEnd{
Metadata: apievents.Metadata{
Type: "session.end",
Time: startTime.Add(10 * time.Second),
},
StartTime: startTime,
EndTime: startTime.Add(10 * time.Second),
},
}
return events
}
func generateSessionWithInactivity(startTime time.Time) []apievents.AuditEvent {
events := []apievents.AuditEvent{
&apievents.SessionStart{
Metadata: apievents.Metadata{
Type: "session.start",
Time: startTime,
},
TerminalSize: "80:24",
},
&apievents.SessionPrint{
Metadata: apievents.Metadata{
Type: "print",
Time: startTime.Add(1 * time.Second),
},
Data: []byte("Active\n"),
},
// 20 second gap
&apievents.SessionPrint{
Metadata: apievents.Metadata{
Type: "print",
Time: startTime.Add(21 * time.Second),
},
Data: []byte("After inactivity\n"),
},
&apievents.SessionEnd{
Metadata: apievents.Metadata{
Type: "session.end",
Time: startTime.Add(20 * time.Second),
},
StartTime: startTime,
EndTime: startTime.Add(20 * time.Second),
},
}
return events
}
func generateSessionWithJoinLeave(startTime time.Time) []apievents.AuditEvent {
events := []apievents.AuditEvent{
&apievents.SessionStart{
Metadata: apievents.Metadata{
Type: "session.start",
Time: startTime,
},
TerminalSize: "80:24",
},
&apievents.SessionJoin{
Metadata: apievents.Metadata{
Type: "session.join",
Time: startTime.Add(2 * time.Second),
},
UserMetadata: apievents.UserMetadata{
User: "alice",
},
},
&apievents.SessionPrint{
Metadata: apievents.Metadata{
Type: "print",
Time: startTime.Add(3 * time.Second),
},
Data: []byte("Alice joined\n"),
},
&apievents.SessionJoin{
Metadata: apievents.Metadata{
Type: "session.join",
Time: startTime.Add(4 * time.Second),
},
UserMetadata: apievents.UserMetadata{
User: "bob",
},
},
&apievents.SessionLeave{
Metadata: apievents.Metadata{
Type: "session.leave",
Time: startTime.Add(6 * time.Second),
},
UserMetadata: apievents.UserMetadata{
User: "alice",
},
},
&apievents.SessionEnd{
Metadata: apievents.Metadata{
Type: "session.end",
Time: startTime.Add(10 * time.Second),
},
StartTime: startTime,
EndTime: startTime.Add(10 * time.Second),
},
}
return events
}
func unmarshalMetadata(data []byte) (*pb.SessionRecordingMetadata, []*pb.SessionRecordingThumbnail, error) {
reader := bufio.NewReader(bytes.NewReader(data))
var metadata *pb.SessionRecordingMetadata
var frames []*pb.SessionRecordingThumbnail
for {
msgBytes, err := readDelimitedMessage(reader)
if err != nil {
if errors.Is(err, io.EOF) {
break
}
}
var m pb.SessionRecordingMetadata
if err := proto.Unmarshal(msgBytes, &m); err == nil {
metadata = &m
continue
}
var f pb.SessionRecordingThumbnail
if err := proto.Unmarshal(msgBytes, &f); err == nil {
frames = append(frames, &f)
continue
}
return nil, nil, trace.BadParameter("failed to parse message as metadata or thumbnail")
}
return metadata, frames, nil
}
// mockStreamer implements player.Streamer for testing
type mockStreamer struct {
events []apievents.AuditEvent
@@ -0,0 +1,35 @@
/**
* Teleport
* Copyright (C) 2026 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package recordingmetadatav1
import (
pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/recordingmetadata/v1"
apievents "github.com/gravitational/teleport/api/types/events"
)
// thumbnailGenerator receives events from a single session recording and uses
// them to produce the preview frames and final thumbnail for the session.
type thumbnailGenerator interface {
// handleEvent receives an audit event from a recording and updates
// the generator's internal state. If it returns an error then the
// metadata generation process is aborted for this recording.
handleEvent(event apievents.AuditEvent) error
// produceThumbnail creates a thumbnail using the current state of the generator.
produceThumbnail() *pb.SessionRecordingThumbnail
}
@@ -0,0 +1,220 @@
/**
* Teleport
* Copyright (C) 2026 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package recordingmetadatav1
import (
"time"
"github.com/gravitational/trace"
"google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/timestamppb"
pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/recordingmetadata/v1"
apievents "github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/session"
)
// ttyRecordingProcessor handles TTY-based sessions (SSH and Kubernetes). It tracks terminal state through a virtual
// terminal emulator to generate SVG thumbnails, and records resize events, user join/leave activity, and inactivity periods.
type ttyRecordingProcessor struct {
baseRecordingProcessor
activeUsers map[string]time.Duration
hasSeenPrintEvent bool
}
func newTTYRecordingProcessor(base baseRecordingProcessor) *ttyRecordingProcessor {
base.thumbnailGenerator = newTTYThumbnailGenerator()
return &ttyRecordingProcessor{
baseRecordingProcessor: base,
activeUsers: make(map[string]time.Duration),
}
}
func (t *ttyRecordingProcessor) handleEvent(evt apievents.AuditEvent) error {
t.lastEvent = evt
switch e := evt.(type) {
case *apievents.SessionStart:
return t.handleSessionStart(e)
case *apievents.Resize:
return t.handleResize(e)
case *apievents.SessionPrint:
return t.handleSessionPrint(e)
case *apievents.SessionJoin:
return t.handleSessionJoin(e)
case *apievents.SessionLeave:
return t.handleSessionLeave(e)
case *apievents.SessionEnd:
return t.handleSessionEnd(e)
}
return nil
}
func (t *ttyRecordingProcessor) handleSessionStart(evt *apievents.SessionStart) error {
t.lastActivityTime = evt.Time
t.startTime = evt.Time
size, err := session.UnmarshalTerminalParams(evt.TerminalSize)
if err != nil {
return trace.Wrap(err, "parsing terminal size %q", evt.TerminalSize)
}
// store the initial terminal size, this is typically 80:24 and is resized immediately
t.metadata.StartCols = int32(size.W)
t.metadata.StartRows = int32(size.H)
t.metadata.ClusterName = evt.ClusterName
t.metadata.User = evt.User
switch evt.Protocol {
case events.EventProtocolSSH:
t.metadata.ResourceName = evt.ServerHostname
t.metadata.Type = pb.SessionRecordingType_SESSION_RECORDING_TYPE_SSH
case events.EventProtocolKube:
t.metadata.ResourceName = evt.KubernetesCluster
t.metadata.Type = pb.SessionRecordingType_SESSION_RECORDING_TYPE_KUBERNETES
}
return t.thumbnailGenerator.handleEvent(evt)
}
func (t *ttyRecordingProcessor) handleResize(evt *apievents.Resize) error {
size, err := session.UnmarshalTerminalParams(evt.TerminalSize)
if err != nil {
return trace.Wrap(err, "parsing terminal size %q", evt.TerminalSize)
}
// if we haven't seen a print event yet, update the starting size to the latest resize
// this handles cases where the initial terminal size is not 80x24 and is resized immediately
// before any output is printed
if !t.hasSeenPrintEvent {
t.metadata.StartCols = int32(size.W)
t.metadata.StartRows = int32(size.H)
}
t.metadata.Events = append(t.metadata.Events, &pb.SessionRecordingEvent{
StartOffset: durationpb.New(evt.Time.Sub(t.startTime)),
Event: &pb.SessionRecordingEvent_Resize{
Resize: &pb.SessionRecordingResizeEvent{
Cols: int32(size.W),
Rows: int32(size.H),
},
},
})
return t.thumbnailGenerator.handleEvent(evt)
}
func (t *ttyRecordingProcessor) handleSessionPrint(evt *apievents.SessionPrint) error {
// mark that we've seen the first print event so we don't update the starting size anymore
if !t.hasSeenPrintEvent {
t.hasSeenPrintEvent = true
}
if !t.lastActivityTime.IsZero() && evt.Time.Sub(t.lastActivityTime) > inactivityThreshold {
t.addInactivityEvent(t.lastActivityTime, evt.Time)
}
t.lastActivityTime = evt.Time
if err := t.thumbnailGenerator.handleEvent(evt); err != nil {
return trace.Wrap(err)
}
t.captureThumbnailIfNeeded(evt.Time)
return nil
}
func (t *ttyRecordingProcessor) handleSessionJoin(evt *apievents.SessionJoin) error {
t.activeUsers[evt.User] = evt.Time.Sub(t.startTime)
return nil
}
func (t *ttyRecordingProcessor) handleSessionLeave(evt *apievents.SessionLeave) error {
if joinTime, ok := t.activeUsers[evt.User]; ok {
t.metadata.Events = append(t.metadata.Events, &pb.SessionRecordingEvent{
StartOffset: durationpb.New(joinTime),
EndOffset: durationpb.New(evt.Time.Sub(t.startTime)),
Event: &pb.SessionRecordingEvent_Join{
Join: &pb.SessionRecordingJoinEvent{
User: evt.User,
},
},
})
delete(t.activeUsers, evt.User)
}
return nil
}
func (t *ttyRecordingProcessor) handleSessionEnd(evt *apievents.SessionEnd) error {
if !t.lastActivityTime.IsZero() && evt.Time.Sub(t.lastActivityTime) > inactivityThreshold {
t.addInactivityEvent(t.lastActivityTime, evt.Time)
}
t.captureThumbnailIfNeeded(evt.Time)
return nil
}
func (t *ttyRecordingProcessor) addInactivityEvent(start, end time.Time) {
inactivityStart := durationpb.New(start.Sub(t.startTime))
inactivityEnd := durationpb.New(end.Sub(t.startTime))
t.metadata.Events = append(t.metadata.Events, &pb.SessionRecordingEvent{
StartOffset: inactivityStart,
EndOffset: inactivityEnd,
Event: &pb.SessionRecordingEvent_Inactivity{
Inactivity: &pb.SessionRecordingInactivityEvent{},
},
})
}
func (t *ttyRecordingProcessor) collect() (*pb.SessionRecordingMetadata, *pb.SessionRecordingThumbnail) {
// Finish off any remaining activity events
for user, userStartOffset := range t.activeUsers {
t.metadata.Events = append(t.metadata.Events, &pb.SessionRecordingEvent{
StartOffset: durationpb.New(userStartOffset),
EndOffset: durationpb.New(t.lastEvent.GetTime().Sub(t.startTime)),
Event: &pb.SessionRecordingEvent_Join{
Join: &pb.SessionRecordingJoinEvent{
User: user,
},
},
})
}
t.metadata.Duration = durationpb.New(t.lastEvent.GetTime().Sub(t.startTime))
t.metadata.StartTime = timestamppb.New(t.startTime)
t.metadata.EndTime = timestamppb.New(t.lastEvent.GetTime())
return t.metadata, t.thumbnail
}
@@ -0,0 +1,218 @@
/**
* Teleport
* Copyright (C) 2026 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package recordingmetadatav1
import (
"io"
"log/slog"
"testing"
"time"
"github.com/stretchr/testify/require"
pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/recordingmetadata/v1"
apievents "github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/lib/auth/recordingmetadata"
)
func newTestTTYProcessor(duration time.Duration) recordingProcessor {
return newRecordingProcessor(&nopCloser{io.Discard}, slog.Default(), recordingmetadata.SessionTypeTTY, duration)
}
func TestTTYRecordingProcessor(t *testing.T) {
startTime := time.Now()
tests := []struct {
name string
events []apievents.AuditEvent
expectedMetadata func(t *testing.T, metadata *pb.SessionRecordingMetadata)
expectedThumbnail func(t *testing.T, thumbnail *pb.SessionRecordingThumbnail)
}{
{
name: "basic session with print events",
events: generateBasicSession(startTime),
expectedMetadata: func(t *testing.T, metadata *pb.SessionRecordingMetadata) {
require.NotNil(t, metadata)
require.NotNil(t, metadata.Duration)
require.Equal(t, int32(80), metadata.StartCols)
require.Equal(t, int32(24), metadata.StartRows)
},
expectedThumbnail: func(t *testing.T, thumbnail *pb.SessionRecordingThumbnail) {
require.NotNil(t, thumbnail)
require.NotEmpty(t, thumbnail.Svg)
},
},
{
name: "immediate resize before print updates start size",
events: generateBasicSessionWithImmediateResize(startTime),
expectedMetadata: func(t *testing.T, metadata *pb.SessionRecordingMetadata) {
require.NotNil(t, metadata)
require.NotNil(t, metadata.Duration)
require.Equal(t, int32(100), metadata.StartCols)
require.Equal(t, int32(30), metadata.StartRows)
},
expectedThumbnail: func(t *testing.T, thumbnail *pb.SessionRecordingThumbnail) {
require.NotNil(t, thumbnail)
require.NotEmpty(t, thumbnail.Svg)
},
},
{
name: "resize events are recorded in metadata",
events: generateSessionWithResize(startTime),
expectedMetadata: func(t *testing.T, metadata *pb.SessionRecordingMetadata) {
require.NotNil(t, metadata)
var hasResize bool
for _, event := range metadata.Events {
if resize := event.GetResize(); resize != nil {
hasResize = true
require.Equal(t, int32(120), resize.Cols)
require.Equal(t, int32(40), resize.Rows)
}
}
require.True(t, hasResize, "expected resize event")
},
},
{
name: "inactivity periods are detected",
events: generateSessionWithInactivity(startTime),
expectedMetadata: func(t *testing.T, metadata *pb.SessionRecordingMetadata) {
require.NotNil(t, metadata)
var inactivityCount int
for _, event := range metadata.Events {
if event.GetInactivity() != nil {
inactivityCount++
require.Equal(t, 1*time.Second, event.StartOffset.AsDuration(), "inactivity should start at last activity time")
require.Equal(t, 21*time.Second, event.EndOffset.AsDuration(), "inactivity should end when activity resumes")
}
}
require.Equal(t, 1, inactivityCount, "expected exactly one inactivity event")
},
},
{
name: "join and leave events are tracked",
events: generateSessionWithJoinLeave(startTime),
expectedMetadata: func(t *testing.T, metadata *pb.SessionRecordingMetadata) {
require.NotNil(t, metadata)
joins := make(map[string]*pb.SessionRecordingEvent)
for _, event := range metadata.Events {
if join := event.GetJoin(); join != nil {
joins[join.User] = event
}
}
require.Len(t, joins, 2, "expected join events for alice and bob")
require.Equal(t, 2*time.Second, joins["alice"].StartOffset.AsDuration())
require.Equal(t, 6*time.Second, joins["alice"].EndOffset.AsDuration())
require.Equal(t, 4*time.Second, joins["bob"].StartOffset.AsDuration())
require.Equal(t, 10*time.Second, joins["bob"].EndOffset.AsDuration())
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lastEventTime := tt.events[len(tt.events)-1].GetTime()
duration := lastEventTime.Sub(startTime)
processor := newTestTTYProcessor(duration)
for _, evt := range tt.events {
require.NoError(t, processor.handleEvent(evt))
}
metadata, thumbnail := processor.collect()
if tt.expectedMetadata != nil {
tt.expectedMetadata(t, metadata)
}
if tt.expectedThumbnail != nil {
tt.expectedThumbnail(t, thumbnail)
}
})
}
}
func TestTTYRecordingProcessor_MalformedResizeEvent(t *testing.T) {
startTime := time.Now()
processor := newTestTTYProcessor(10 * time.Second)
require.NoError(t, processor.handleEvent(sessionStartEvent(startTime, "80:24")))
require.NoError(t, processor.handleEvent(sessionPrintEvent(startTime.Add(1*time.Second), "Hello\n")))
err := processor.handleEvent(resizeEvent(startTime.Add(2*time.Second), "invalid:terminal:size"))
require.Error(t, err)
require.Contains(t, err.Error(), "parsing terminal size")
}
func generateBasicSessionWithImmediateResize(startTime time.Time) []apievents.AuditEvent {
events := generateBasicSession(startTime)
// Insert a resize event immediately after session start
events = append([]apievents.AuditEvent{events[0], resizeEvent(startTime.Add(500*time.Millisecond), "100:30")}, events[1:]...)
return events
}
func generateBasicSession(startTime time.Time) []apievents.AuditEvent {
return []apievents.AuditEvent{
sessionStartEvent(startTime, "80:24"),
sessionPrintEvent(startTime.Add(1*time.Second), "Hello World\n"),
sessionPrintEvent(startTime.Add(2*time.Second), "$ ls -la\n"),
sessionEndEvent(startTime, startTime.Add(10*time.Second)),
}
}
func generateSessionWithResize(startTime time.Time) []apievents.AuditEvent {
return []apievents.AuditEvent{
sessionStartEvent(startTime, "80:24"),
sessionPrintEvent(startTime.Add(1*time.Second), "Initial output\n"),
resizeEvent(startTime.Add(2*time.Second), "120:40"),
sessionPrintEvent(startTime.Add(3*time.Second), "After resize\n"),
sessionEndEvent(startTime, startTime.Add(10*time.Second)),
}
}
func generateSessionWithInactivity(startTime time.Time) []apievents.AuditEvent {
return []apievents.AuditEvent{
sessionStartEvent(startTime, "80:24"),
sessionPrintEvent(startTime.Add(1*time.Second), "Active\n"),
// 20 second gap
sessionPrintEvent(startTime.Add(21*time.Second), "After inactivity\n"),
sessionEndEvent(startTime, startTime.Add(20*time.Second)),
}
}
func generateSessionWithJoinLeave(startTime time.Time) []apievents.AuditEvent {
return []apievents.AuditEvent{
sessionStartEvent(startTime, "80:24"),
sessionJoinEvent(startTime.Add(2*time.Second), "alice"),
sessionPrintEvent(startTime.Add(3*time.Second), "Alice joined\n"),
sessionJoinEvent(startTime.Add(4*time.Second), "bob"),
sessionLeaveEvent(startTime.Add(6*time.Second), "alice"),
sessionEndEvent(startTime, startTime.Add(10*time.Second)),
}
}
@@ -0,0 +1,97 @@
/**
* Teleport
* Copyright (C) 2026 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package recordingmetadatav1
import (
"github.com/gravitational/trace"
"github.com/hinshun/vt10x"
pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/recordingmetadata/v1"
apievents "github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/lib/session"
"github.com/gravitational/teleport/lib/terminal"
)
// ttyThumbnailGenerator is a thumbnail generator that produces thumbnails for a terminal session (i.e. SSH, kubernetes)
// by maintaining an internal virtual terminal that it updates as it processes events from the session recording.
type ttyThumbnailGenerator struct {
vt vt10x.Terminal
}
func newTTYThumbnailGenerator() *ttyThumbnailGenerator {
return &ttyThumbnailGenerator{
vt: vt10x.New(),
}
}
func (t *ttyThumbnailGenerator) handleEvent(event apievents.AuditEvent) error {
switch e := event.(type) {
case *apievents.SessionStart:
return t.handleSessionStart(e)
case *apievents.Resize:
return t.handleResize(e)
case *apievents.SessionPrint:
return t.handleSessionPrint(e)
}
return nil
}
func (t *ttyThumbnailGenerator) handleSessionStart(evt *apievents.SessionStart) error {
return t.handleTerminalResize(evt.TerminalSize)
}
func (t *ttyThumbnailGenerator) handleResize(evt *apievents.Resize) error {
return t.handleTerminalResize(evt.TerminalSize)
}
func (t *ttyThumbnailGenerator) handleSessionPrint(evt *apievents.SessionPrint) error {
if _, err := t.vt.Write(evt.Data); err != nil {
return trace.Errorf("writing data to terminal: %w", err)
}
return nil
}
func (t *ttyThumbnailGenerator) handleTerminalResize(terminalSize string) error {
size, err := session.UnmarshalTerminalParams(terminalSize)
if err != nil {
return trace.Wrap(err, "parsing terminal size %q", terminalSize)
}
t.vt.Resize(size.W, size.H)
return nil
}
func (t *ttyThumbnailGenerator) produceThumbnail() *pb.SessionRecordingThumbnail {
cols, rows := t.vt.Size()
cursor := t.vt.Cursor()
return &pb.SessionRecordingThumbnail{
Svg: terminal.VtToSvg(t.vt),
Cols: int32(cols),
Rows: int32(rows),
CursorX: int32(cursor.X),
CursorY: int32(cursor.Y),
CursorVisible: t.vt.CursorVisible(),
}
}
@@ -0,0 +1,165 @@
/**
* Teleport
* Copyright (C) 2026 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package recordingmetadatav1
import (
"testing"
"time"
"github.com/stretchr/testify/require"
apievents "github.com/gravitational/teleport/api/types/events"
)
func TestTtyThumbnailGenerator_HandleEvent(t *testing.T) {
startTime := time.Now()
t.Run("session start sets terminal size", func(t *testing.T) {
gen := newTTYThumbnailGenerator()
require.NoError(t, gen.handleEvent(sessionStartEvent(startTime, "100:50")))
thumb := gen.produceThumbnail()
require.Equal(t, int32(100), thumb.Cols)
require.Equal(t, int32(50), thumb.Rows)
})
t.Run("resize updates terminal size", func(t *testing.T) {
gen := newTTYThumbnailGenerator()
require.NoError(t, gen.handleEvent(sessionStartEvent(startTime, "80:24")))
require.NoError(t, gen.handleEvent(resizeEvent(startTime.Add(1*time.Second), "120:40")))
thumb := gen.produceThumbnail()
require.Equal(t, int32(120), thumb.Cols)
require.Equal(t, int32(40), thumb.Rows)
})
t.Run("session print writes data to terminal", func(t *testing.T) {
gen := newTTYThumbnailGenerator()
require.NoError(t, gen.handleEvent(sessionStartEvent(startTime, "80:24")))
require.NoError(t, gen.handleEvent(sessionPrintEvent(startTime.Add(1*time.Second), "Hello World\r\n")))
thumb := gen.produceThumbnail()
require.NotEmpty(t, thumb.Svg)
require.Contains(t, string(thumb.Svg), "Hello")
require.Contains(t, string(thumb.Svg), "World")
})
t.Run("unhandled event types are ignored", func(t *testing.T) {
gen := newTTYThumbnailGenerator()
require.NoError(t, gen.handleEvent(&apievents.SessionEnd{
Metadata: apievents.Metadata{Type: "session.end", Time: startTime},
}))
require.NoError(t, gen.handleEvent(&apievents.SessionJoin{
Metadata: apievents.Metadata{Type: "session.join", Time: startTime},
}))
})
t.Run("malformed terminal size returns error on start", func(t *testing.T) {
gen := newTTYThumbnailGenerator()
err := gen.handleEvent(sessionStartEvent(startTime, "invalid"))
require.Error(t, err)
require.Contains(t, err.Error(), "parsing terminal size")
})
t.Run("malformed terminal size returns error on resize", func(t *testing.T) {
gen := newTTYThumbnailGenerator()
err := gen.handleEvent(resizeEvent(startTime, "not:valid:size"))
require.Error(t, err)
require.Contains(t, err.Error(), "parsing terminal size")
})
}
func TestTtyThumbnailGenerator_ProduceThumbnail(t *testing.T) {
startTime := time.Now()
t.Run("produces valid thumbnail with all fields", func(t *testing.T) {
gen := newTTYThumbnailGenerator()
require.NoError(t, gen.handleEvent(sessionStartEvent(startTime, "80:24")))
require.NoError(t, gen.handleEvent(sessionPrintEvent(startTime.Add(1*time.Second), "hello\r\n")))
thumb := gen.produceThumbnail()
require.NotNil(t, thumb)
require.Equal(t, int32(80), thumb.Cols)
require.Equal(t, int32(24), thumb.Rows)
require.NotEmpty(t, thumb.Svg)
require.Contains(t, string(thumb.Svg), "<svg")
require.Contains(t, string(thumb.Svg), "hello")
})
t.Run("cursor tracks print output position", func(t *testing.T) {
gen := newTTYThumbnailGenerator()
require.NoError(t, gen.handleEvent(sessionStartEvent(startTime, "80:24")))
thumb0 := gen.produceThumbnail()
require.Equal(t, int32(0), thumb0.CursorX)
require.Equal(t, int32(0), thumb0.CursorY)
require.NoError(t, gen.handleEvent(sessionPrintEvent(startTime.Add(1*time.Second), "line1\r\nline2\r\nline3\r\n")))
thumb := gen.produceThumbnail()
require.Equal(t, int32(0), thumb.CursorX, "cursor should be at start of line after \\r\\n")
require.Equal(t, int32(3), thumb.CursorY, "cursor should be on 4th row after 3 lines")
})
t.Run("reflects latest terminal size after resize", func(t *testing.T) {
gen := newTTYThumbnailGenerator()
require.NoError(t, gen.handleEvent(sessionStartEvent(startTime, "80:24")))
require.NoError(t, gen.handleEvent(sessionPrintEvent(startTime.Add(1*time.Second), "before resize\r\n")))
require.NoError(t, gen.handleEvent(resizeEvent(startTime.Add(2*time.Second), "200:50")))
thumb := gen.produceThumbnail()
require.Equal(t, int32(200), thumb.Cols)
require.Equal(t, int32(50), thumb.Rows)
require.Contains(t, string(thumb.Svg), "before")
require.Contains(t, string(thumb.Svg), "resize")
})
t.Run("snapshots are independent after more output", func(t *testing.T) {
gen := newTTYThumbnailGenerator()
require.NoError(t, gen.handleEvent(sessionStartEvent(startTime, "80:24")))
require.NoError(t, gen.handleEvent(sessionPrintEvent(startTime.Add(1*time.Second), "first\r\n")))
thumb1 := gen.produceThumbnail()
require.NoError(t, gen.handleEvent(sessionPrintEvent(startTime.Add(2*time.Second), "second\r\n")))
thumb2 := gen.produceThumbnail()
require.NotEqual(t, thumb1.Svg, thumb2.Svg, "thumbnails should differ after more output")
require.NotContains(t, string(thumb1.Svg), "second", "first snapshot should not contain later output")
require.Contains(t, string(thumb2.Svg), "first", "second snapshot should still contain earlier output")
require.Contains(t, string(thumb2.Svg), "second")
require.Greater(t, thumb2.CursorY, thumb1.CursorY, "cursor should have advanced")
})
}
+8 -1
View File
@@ -24,9 +24,16 @@ import (
"github.com/gravitational/teleport/lib/session"
)
type SessionType int
const (
SessionTypeUnspecified SessionType = iota
SessionTypeTTY
)
// Service defines an interface for processing session recordings.
type Service interface {
// ProcessSessionRecording processes the session recording associated with the
// provided session ID.
ProcessSessionRecording(ctx context.Context, sessionID session.ID, duration time.Duration) error
ProcessSessionRecording(ctx context.Context, sessionID session.ID, sessionType SessionType, duration time.Duration) error
}
+3 -3
View File
@@ -393,7 +393,7 @@ func TestCallingSummarizerMetadata(t *testing.T) {
require.NoError(t, err)
metadataProvider := recordingmetadata.NewProvider()
recorderMetadata := &fakeRecordingMetadata{}
recorderMetadata.On("ProcessSessionRecording", mock.Anything, session.ID(sessionID.String()), mock.Anything).
recorderMetadata.On("ProcessSessionRecording", mock.Anything, session.ID(sessionID.String()), mock.Anything, mock.Anything).
Return(nil).Once()
metadataProvider.SetService(recorderMetadata)
@@ -439,8 +439,8 @@ type fakeRecordingMetadata struct {
mock.Mock
}
func (f *fakeRecordingMetadata) ProcessSessionRecording(ctx context.Context, sessionID session.ID, duration time.Duration) error {
args := f.Called(ctx, sessionID, duration)
func (f *fakeRecordingMetadata) ProcessSessionRecording(ctx context.Context, sessionID session.ID, sessionType recordingmetadata.SessionType, duration time.Duration) error {
args := f.Called(ctx, sessionID, sessionType, duration)
return args.Error(0)
}
+6 -1
View File
@@ -359,6 +359,7 @@ func (u *UploadCompleter) ensureSessionEndEvent(ctx context.Context, uploadData
// for both Desktop and SSH sessions
var lastEvent events.AuditEvent
var startTime time.Time
var sessionType recordingmetadata.SessionType
var isPTYSession bool
ctx, cancel := context.WithCancel(ctx)
defer cancel()
@@ -396,6 +397,7 @@ loop:
desktopSessionEnd.DesktopName = fmt.Sprintf("%v (recovered)", e.DesktopName)
case *events.SessionStart:
sessionType = recordingmetadata.SessionTypeTTY
isPTYSession = true
startTime = e.Time
sshSessionEnd.Type = SessionEndEvent
@@ -413,6 +415,9 @@ loop:
sshSessionEnd.Interactive = e.TerminalSize != ""
sshSessionEnd.Participants = append(sshSessionEnd.Participants, transformedUsername(e.UserMetadata, u.cfg.ClusterName))
case *events.DatabaseSessionStart:
startTime = e.Time
case *events.SessionJoin:
sshSessionEnd.Participants = append(sshSessionEnd.Participants, transformedUsername(e.UserMetadata, u.cfg.ClusterName))
}
@@ -466,7 +471,7 @@ loop:
recordingMetadata := u.cfg.RecordingMetadataProvider.Service()
if !startTime.IsZero() && !sessionEndEvent.GetTime().IsZero() {
duration := sessionEndEvent.GetTime().Sub(startTime)
if err := recordingMetadata.ProcessSessionRecording(ctx, uploadData.SessionID, duration); err != nil {
if err := recordingMetadata.ProcessSessionRecording(ctx, uploadData.SessionID, sessionType, duration); err != nil {
slog.WarnContext(ctx, "Failed to process session recording metadata", "error", err)
}
} else {
@@ -67,7 +67,7 @@ func Process(ctx context.Context, cfg Config) error {
metadataSvc := cfg.RecordingMetadataProvider.Service()
if !o.EndTime.IsZero() && !o.StartTime.IsZero() {
duration := o.EndTime.Sub(o.StartTime)
if err := metadataSvc.ProcessSessionRecording(ctx, cfg.SessionID, duration); err != nil {
if err := metadataSvc.ProcessSessionRecording(ctx, cfg.SessionID, recordingmetadata.SessionTypeTTY, duration); err != nil {
metadataErr = trace.Wrap(err, "failed to process session recording metadata")
}
}
@@ -44,6 +44,7 @@ func TestSessionPostProcessor(t *testing.T) {
mock.Anything,
sessionID,
mock.Anything,
mock.Anything,
).
Return(nil).Once()
metadataProvider.SetService(recorderMetadata)
@@ -82,8 +83,8 @@ type fakeRecordingMetadata struct {
mock.Mock
}
func (f *fakeRecordingMetadata) ProcessSessionRecording(ctx context.Context, sessionID session.ID, duration time.Duration) error {
args := f.Called(ctx, sessionID, duration)
func (f *fakeRecordingMetadata) ProcessSessionRecording(ctx context.Context, sessionID session.ID, sessionType recordingmetadata.SessionType, duration time.Duration) error {
args := f.Called(ctx, sessionID, sessionType, duration)
return args.Error(0)
}
+4 -1
View File
@@ -571,6 +571,8 @@ type sliceWriter struct {
sessionStartTime time.Time
// sessionEndTime is the time of the last event in the session
sessionEndTime time.Time
// sessionType is the type of the session, used for recording metadata processing
sessionType recordingmetadata.SessionType
// shouldProcessSession is set to true if the session should be processed
// by the recording metadata service (currently, this is true if the session
// is a SSH session).
@@ -694,6 +696,7 @@ func (w *sliceWriter) receiveAndUpload() error {
switch e := event.oneof.GetEvent().(type) {
case *apievents.OneOf_SessionStart:
w.sessionStartTime = e.SessionStart.Time
w.sessionType = recordingmetadata.SessionTypeTTY
w.shouldProcessSession = true
case *apievents.OneOf_SessionPrint:
@@ -843,7 +846,7 @@ func (w *sliceWriter) completeStream() {
if !w.sessionStartTime.IsZero() && !w.sessionEndTime.IsZero() {
duration := w.sessionEndTime.Sub(w.sessionStartTime)
if err := recordingMetadata.ProcessSessionRecording(w.proto.cancelCtx, w.proto.cfg.Upload.SessionID, duration); err != nil {
if err := recordingMetadata.ProcessSessionRecording(w.proto.cancelCtx, w.proto.cfg.Upload.SessionID, w.sessionType, duration); err != nil {
slog.WarnContext(w.proto.cancelCtx, "Failed to process session recording metadata", "error", err)
}
} else {