Files
teleport/lib/srv/app/metrics_test.go
T
Julia Ogris 6c8ea32bc2 app: Add active sessions Prometheus gauge (#65008)
* fncache: Pass non-cancellable context to `OnExpiry` callbacks

Call `OnExpiry` when `get()` replaces an expired entry on reload.
Previously, entries that expired between cleanup intervals were
silently dropped when a new request triggered a reload, skipping
the `OnExpiry` callback entirely.

Use `context.WithoutCancel(c.cfg.Context)` at both the
`removeExpiredLocked` and `get` call sites so that `OnExpiry` work
completes even after shutdown begins. `Shutdown` cancels
`c.cfg.Context` via `c.cancel()` before processing entries, but
these two call sites passed `c.cfg.Context` directly, meaning
goroutines spawned by `OnExpiry` could run with an
already-cancelled context.

Update the `OnExpiry` doc comment to warn that the cache mutex may
be held when the callback is invoked.

* app: Add active sessions Prometheus gauge

Add a `teleport_app_active_sessions` gauge labeled by app name that
tracks HTTP app sessions on each agent. The gauge increments when a
session chunk is created and decrements after the session chunk
finishes closing, so it reflects sessions still holding resources
(audit streams, disk I/O) rather than just sessions accepting new
requests.

TCP and MCP sessions are excluded because they bypass the session
chunk cache.

* fncache: Fix `OnExpires` typo in `Shutdown` doc comment

Correct the stale field name `OnExpires` to `OnExpiry` in the
`Shutdown` method's doc comment to match the actual field name in
`FnCacheConfig`.
2026-04-23 02:44:47 +00:00

138 lines
5.1 KiB
Go

/*
* 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 app
import (
"context"
"log/slog"
"testing"
"time"
"github.com/jonboulle/clockwork"
dto "github.com/prometheus/client_model/go"
"github.com/stretchr/testify/require"
"github.com/gravitational/teleport/api/client/proto"
"github.com/gravitational/teleport/api/types"
apievents "github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/lib/auth/authclient"
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/session"
"github.com/gravitational/teleport/lib/tlsca"
)
// gaugeValue reads the current value of the activeSessions gauge for the
// given app name.
func gaugeValue(t *testing.T, appName string) float64 {
t.Helper()
var m dto.Metric
require.NoError(t, activeSessions.WithLabelValues(appName).Write(&m))
return m.GetGauge().GetValue()
}
func TestActiveSessionsGauge(t *testing.T) {
// Reset the gauge so previous test runs do not interfere.
activeSessions.Reset()
c := newConnectionsHandler(t)
t.Cleanup(c.cacheCloseWg.Wait)
identity := &tlsca.Identity{Username: "alice"}
appA, err := types.NewAppV3(types.Metadata{Name: "app-a"}, types.AppSpecV3{URI: "http://localhost"})
require.NoError(t, err)
appB, err := types.NewAppV3(types.Metadata{Name: "app-b"}, types.AppSpecV3{URI: "http://localhost"})
require.NoError(t, err)
// Create two sessions for different apps.
now := time.Now()
sessA, err := c.newSessionChunk(t.Context(), identity, appA, now)
require.NoError(t, err)
sessB, err := c.newSessionChunk(t.Context(), identity, appB, now)
require.NoError(t, err)
require.Equal(t, 1.0, gaugeValue(t, "app-a")) //nolint:testifylint // Inc/Dec produce exact float64 integers
require.Equal(t, 1.0, gaugeValue(t, "app-b")) //nolint:testifylint // Inc/Dec produce exact float64 integers
// Expire the first session via the cache eviction callback. The gauge is
// decremented asynchronously after the session finishes closing, so wait
// for the background goroutine to complete before checking.
c.onSessionExpired(t.Context(), "key1", sessA)
c.cacheCloseWg.Wait()
require.Equal(t, 0.0, gaugeValue(t, "app-a")) //nolint:testifylint // Inc/Dec produce exact float64 integers
require.Equal(t, 1.0, gaugeValue(t, "app-b")) //nolint:testifylint // Inc/Dec produce exact float64 integers
// Expire the second session.
c.onSessionExpired(t.Context(), "key2", sessB)
c.cacheCloseWg.Wait()
require.Equal(t, 0.0, gaugeValue(t, "app-b")) //nolint:testifylint // Inc/Dec produce exact float64 integers
}
// newConnectionsHandler returns a ConnectionsHandler wired with minimal fakes
// so that newSessionChunk can be called without a full auth server.
func newConnectionsHandler(t *testing.T) *ConnectionsHandler {
t.Helper()
return &ConnectionsHandler{
cfg: &ConnectionsHandlerConfig{
Clock: clockwork.NewRealClock(),
DataDir: t.TempDir(),
Emitter: events.NewDiscardEmitter(),
AuthClient: metricsAuthClient{},
AccessPoint: metricsAccessPoint{},
},
closeContext: t.Context(),
log: slog.Default(),
}
}
// metricsAuthClient is a minimal fake that satisfies authclient.ClientI for
// the calls made by newSessionChunk: session tracker creation, tracker state
// updates, and audit stream creation.
type metricsAuthClient struct {
authclient.ClientI
}
func (metricsAuthClient) CreateSessionTracker(_ context.Context, st types.SessionTracker) (types.SessionTracker, error) {
return st, nil
}
func (metricsAuthClient) UpdateSessionTracker(_ context.Context, _ *proto.UpdateSessionTrackerRequest) error {
return nil
}
func (metricsAuthClient) CreateAuditStream(_ context.Context, _ session.ID) (apievents.Stream, error) {
return events.NewDiscardRecorder(), nil
}
func (metricsAuthClient) ResumeAuditStream(_ context.Context, _ session.ID, _ string) (apievents.Stream, error) {
return events.NewDiscardRecorder(), nil
}
// metricsAccessPoint is a minimal fake that satisfies
// [authclient.AppsAccessPoint] for the two calls made by newSessionRecorder.
type metricsAccessPoint struct {
authclient.AppsAccessPoint
}
func (metricsAccessPoint) GetSessionRecordingConfig(_ context.Context) (types.SessionRecordingConfig, error) {
return types.DefaultSessionRecordingConfig(), nil
}
func (metricsAccessPoint) GetClusterName(_ context.Context) (types.ClusterName, error) {
return types.NewClusterName(types.ClusterNameSpecV2{ClusterName: "test", ClusterID: "test"})
}