mirror of
https://github.com/coder/coder.git
synced 2026-06-03 13:08:25 +00:00
2556 lines
93 KiB
Go
2556 lines
93 KiB
Go
package telemetry
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"runtime"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/elastic/go-sysinfo"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/sync/errgroup"
|
|
"golang.org/x/xerrors"
|
|
"google.golang.org/protobuf/types/known/durationpb"
|
|
"google.golang.org/protobuf/types/known/wrapperspb"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/buildinfo"
|
|
clitelemetry "github.com/coder/coder/v2/cli/telemetry"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
|
"github.com/coder/coder/v2/coderd/util/ptr"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
tailnetproto "github.com/coder/coder/v2/tailnet/proto"
|
|
"github.com/coder/quartz"
|
|
)
|
|
|
|
const (
|
|
// VersionHeader is sent in every telemetry request to
|
|
// report the semantic version of Coder.
|
|
VersionHeader = "X-Coder-Version"
|
|
|
|
DefaultSnapshotFrequency = 30 * time.Minute
|
|
)
|
|
|
|
type Options struct {
|
|
Disabled bool
|
|
Database database.Store
|
|
Logger slog.Logger
|
|
Clock quartz.Clock
|
|
// URL is an endpoint to direct telemetry towards!
|
|
URL *url.URL
|
|
Experiments codersdk.Experiments
|
|
|
|
DeploymentID string
|
|
DeploymentConfig *codersdk.DeploymentValues
|
|
BuiltinPostgres bool
|
|
Tunnel bool
|
|
|
|
SnapshotFrequency time.Duration
|
|
ParseLicenseJWT func(lic *License) error
|
|
}
|
|
|
|
// New constructs a reporter for telemetry data.
|
|
// Duplicate data will be sent, it's on the server-side to index by UUID.
|
|
// Data is anonymized prior to being sent!
|
|
func New(options Options) (Reporter, error) {
|
|
if options.Clock == nil {
|
|
options.Clock = quartz.NewReal()
|
|
}
|
|
if options.SnapshotFrequency == 0 {
|
|
options.SnapshotFrequency = DefaultSnapshotFrequency
|
|
}
|
|
snapshotURL, err := options.URL.Parse("/snapshot")
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("parse snapshot url: %w", err)
|
|
}
|
|
deploymentURL, err := options.URL.Parse("/deployment")
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("parse deployment url: %w", err)
|
|
}
|
|
|
|
ctx, cancelFunc := context.WithCancel(context.Background())
|
|
reporter := &remoteReporter{
|
|
ctx: ctx,
|
|
closed: make(chan struct{}),
|
|
closeFunc: cancelFunc,
|
|
options: options,
|
|
deploymentURL: deploymentURL,
|
|
snapshotURL: snapshotURL,
|
|
startedAt: dbtime.Time(options.Clock.Now()).UTC(),
|
|
client: &http.Client{},
|
|
}
|
|
go reporter.runSnapshotter()
|
|
return reporter, nil
|
|
}
|
|
|
|
// NewNoop creates a new telemetry reporter that entirely discards all requests.
|
|
func NewNoop() Reporter {
|
|
return &noopReporter{}
|
|
}
|
|
|
|
// Reporter sends data to the telemetry server.
|
|
type Reporter interface {
|
|
// Report sends a snapshot to the telemetry server.
|
|
// The contents of the snapshot can be a partial representation of the
|
|
// database. For example, if a new user is added, a snapshot can
|
|
// contain just that user entry.
|
|
Report(snapshot *Snapshot)
|
|
Enabled() bool
|
|
Close()
|
|
}
|
|
|
|
type remoteReporter struct {
|
|
ctx context.Context
|
|
closed chan struct{}
|
|
closeMutex sync.Mutex
|
|
closeFunc context.CancelFunc
|
|
|
|
options Options
|
|
deploymentURL,
|
|
snapshotURL *url.URL
|
|
startedAt time.Time
|
|
shutdownAt *time.Time
|
|
client *http.Client
|
|
}
|
|
|
|
func (r *remoteReporter) Enabled() bool {
|
|
return !r.options.Disabled
|
|
}
|
|
|
|
func (r *remoteReporter) Report(snapshot *Snapshot) {
|
|
go r.reportSync(snapshot)
|
|
}
|
|
|
|
func (r *remoteReporter) reportSync(snapshot *Snapshot) {
|
|
snapshot.DeploymentID = r.options.DeploymentID
|
|
data, err := json.Marshal(snapshot)
|
|
if err != nil {
|
|
r.options.Logger.Error(r.ctx, "marshal snapshot: %w", slog.Error(err))
|
|
return
|
|
}
|
|
req, err := http.NewRequestWithContext(r.ctx, "POST", r.snapshotURL.String(), bytes.NewReader(data))
|
|
if err != nil {
|
|
r.options.Logger.Error(r.ctx, "unable to create snapshot request", slog.Error(err))
|
|
return
|
|
}
|
|
req.Header.Set(VersionHeader, buildinfo.Version())
|
|
resp, err := r.client.Do(req)
|
|
if err != nil {
|
|
// If the request fails it's not necessarily an error.
|
|
// In an airgapped environment, it's fine if this fails!
|
|
r.options.Logger.Debug(r.ctx, "submit", slog.Error(err))
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusAccepted {
|
|
r.options.Logger.Debug(r.ctx, "bad response from telemetry server", slog.F("status", resp.StatusCode))
|
|
return
|
|
}
|
|
r.options.Logger.Debug(r.ctx, "submitted snapshot")
|
|
}
|
|
|
|
func (r *remoteReporter) Close() {
|
|
r.closeMutex.Lock()
|
|
defer r.closeMutex.Unlock()
|
|
if r.isClosed() {
|
|
return
|
|
}
|
|
close(r.closed)
|
|
now := dbtime.Time(r.options.Clock.Now()).UTC()
|
|
r.shutdownAt = &now
|
|
if r.Enabled() {
|
|
// Report a final collection of telemetry prior to close!
|
|
// This could indicate final actions a user has taken, and
|
|
// the time the deployment was shutdown.
|
|
r.reportWithDeployment()
|
|
}
|
|
r.closeFunc()
|
|
}
|
|
|
|
func (r *remoteReporter) isClosed() bool {
|
|
select {
|
|
case <-r.closed:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// See the corresponding test in telemetry_test.go for a truth table.
|
|
func ShouldReportTelemetryDisabled(recordedTelemetryEnabled *bool, telemetryEnabled bool) bool {
|
|
return recordedTelemetryEnabled != nil && *recordedTelemetryEnabled && !telemetryEnabled
|
|
}
|
|
|
|
// RecordTelemetryStatus records the telemetry status in the database.
|
|
// If the status changed from enabled to disabled, returns a snapshot to
|
|
// be sent to the telemetry server.
|
|
func RecordTelemetryStatus( //nolint:revive
|
|
ctx context.Context,
|
|
logger slog.Logger,
|
|
db database.Store,
|
|
telemetryEnabled bool,
|
|
) (*Snapshot, error) {
|
|
item, err := db.GetTelemetryItem(ctx, string(TelemetryItemKeyTelemetryEnabled))
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return nil, xerrors.Errorf("get telemetry enabled: %w", err)
|
|
}
|
|
var recordedTelemetryEnabled *bool
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
value, err := strconv.ParseBool(item.Value)
|
|
if err != nil {
|
|
logger.Debug(ctx, "parse telemetry enabled", slog.Error(err))
|
|
}
|
|
// If ParseBool fails, value will default to false.
|
|
// This may happen if an admin manually edits the telemetry item
|
|
// in the database.
|
|
recordedTelemetryEnabled = &value
|
|
}
|
|
|
|
if err := db.UpsertTelemetryItem(ctx, database.UpsertTelemetryItemParams{
|
|
Key: string(TelemetryItemKeyTelemetryEnabled),
|
|
Value: strconv.FormatBool(telemetryEnabled),
|
|
}); err != nil {
|
|
return nil, xerrors.Errorf("upsert telemetry enabled: %w", err)
|
|
}
|
|
|
|
shouldReport := ShouldReportTelemetryDisabled(recordedTelemetryEnabled, telemetryEnabled)
|
|
if !shouldReport {
|
|
return nil, nil //nolint:nilnil
|
|
}
|
|
// If any of the following calls fail, we will never report that telemetry changed
|
|
// from enabled to disabled. This is okay. We only want to ping the telemetry server
|
|
// once, and never again. If that attempt fails, so be it.
|
|
item, err = db.GetTelemetryItem(ctx, string(TelemetryItemKeyTelemetryEnabled))
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("get telemetry enabled after upsert: %w", err)
|
|
}
|
|
return &Snapshot{
|
|
TelemetryItems: []TelemetryItem{
|
|
ConvertTelemetryItem(item),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (r *remoteReporter) runSnapshotter() {
|
|
telemetryDisabledSnapshot, err := RecordTelemetryStatus(r.ctx, r.options.Logger, r.options.Database, r.Enabled())
|
|
if err != nil {
|
|
r.options.Logger.Debug(r.ctx, "record and maybe report telemetry status", slog.Error(err))
|
|
}
|
|
if telemetryDisabledSnapshot != nil {
|
|
r.reportSync(telemetryDisabledSnapshot)
|
|
}
|
|
r.options.Logger.Debug(r.ctx, "finished telemetry status check")
|
|
if !r.Enabled() {
|
|
return
|
|
}
|
|
|
|
first := true
|
|
ticker := time.NewTicker(r.options.SnapshotFrequency)
|
|
defer ticker.Stop()
|
|
for {
|
|
if !first {
|
|
select {
|
|
case <-r.closed:
|
|
return
|
|
case <-ticker.C:
|
|
}
|
|
// Skip the ticker on the first run to report instantly!
|
|
}
|
|
first = false
|
|
r.closeMutex.Lock()
|
|
if r.isClosed() {
|
|
r.closeMutex.Unlock()
|
|
return
|
|
}
|
|
r.reportWithDeployment()
|
|
r.closeMutex.Unlock()
|
|
}
|
|
}
|
|
|
|
func (r *remoteReporter) reportWithDeployment() {
|
|
// Submit deployment information before creating a snapshot!
|
|
// This is separated from the snapshot API call to reduce
|
|
// duplicate data from being inserted. Snapshot may be called
|
|
// numerous times simultaneously if there is lots of activity!
|
|
err := r.deployment()
|
|
if err != nil {
|
|
r.options.Logger.Debug(r.ctx, "update deployment", slog.Error(err))
|
|
return
|
|
}
|
|
snapshot, err := r.createSnapshot()
|
|
if errors.Is(err, context.Canceled) {
|
|
return
|
|
}
|
|
if err != nil {
|
|
r.options.Logger.Error(r.ctx, "unable to create deployment snapshot", slog.Error(err))
|
|
return
|
|
}
|
|
r.reportSync(snapshot)
|
|
}
|
|
|
|
// deployment collects host information and reports it to the telemetry server.
|
|
func (r *remoteReporter) deployment() error {
|
|
sysInfoHost, err := sysinfo.Host()
|
|
if err != nil {
|
|
return xerrors.Errorf("get host info: %w", err)
|
|
}
|
|
mem, err := sysInfoHost.Memory()
|
|
if err != nil {
|
|
return xerrors.Errorf("get memory info: %w", err)
|
|
}
|
|
sysInfo := sysInfoHost.Info()
|
|
|
|
containerized := false
|
|
if sysInfo.Containerized != nil {
|
|
containerized = *sysInfo.Containerized
|
|
}
|
|
|
|
// Tracks where Coder was installed from!
|
|
installSource := os.Getenv("CODER_TELEMETRY_INSTALL_SOURCE")
|
|
if len(installSource) > 64 {
|
|
return xerrors.Errorf("install source must be <=64 chars: %s", installSource)
|
|
}
|
|
|
|
idpOrgSync, err := checkIDPOrgSync(r.ctx, r.options.Database, r.options.DeploymentConfig)
|
|
if err != nil {
|
|
r.options.Logger.Debug(r.ctx, "check IDP org sync", slog.Error(err))
|
|
}
|
|
|
|
data, err := json.Marshal(&Deployment{
|
|
ID: r.options.DeploymentID,
|
|
Architecture: sysInfo.Architecture,
|
|
BuiltinPostgres: r.options.BuiltinPostgres,
|
|
Containerized: containerized,
|
|
Config: r.options.DeploymentConfig,
|
|
Kubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "",
|
|
InstallSource: installSource,
|
|
Tunnel: r.options.Tunnel,
|
|
OSType: sysInfo.OS.Type,
|
|
OSFamily: sysInfo.OS.Family,
|
|
OSPlatform: sysInfo.OS.Platform,
|
|
OSName: sysInfo.OS.Name,
|
|
OSVersion: sysInfo.OS.Version,
|
|
CPUCores: runtime.NumCPU(),
|
|
MemoryTotal: mem.Total,
|
|
MachineID: sysInfo.UniqueID,
|
|
StartedAt: r.startedAt,
|
|
ShutdownAt: r.shutdownAt,
|
|
IDPOrgSync: &idpOrgSync,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("marshal deployment: %w", err)
|
|
}
|
|
req, err := http.NewRequestWithContext(r.ctx, "POST", r.deploymentURL.String(), bytes.NewReader(data))
|
|
if err != nil {
|
|
return xerrors.Errorf("create deployment request: %w", err)
|
|
}
|
|
req.Header.Set(VersionHeader, buildinfo.Version())
|
|
resp, err := r.client.Do(req)
|
|
if err != nil {
|
|
return xerrors.Errorf("perform request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusAccepted {
|
|
return xerrors.Errorf("update deployment: %w", err)
|
|
}
|
|
r.options.Logger.Debug(r.ctx, "submitted deployment info")
|
|
return nil
|
|
}
|
|
|
|
// idpOrgSyncConfig is a subset of
|
|
// https://github.com/coder/coder/blob/5c6578d84e2940b9cfd04798c45e7c8042c3fe0e/coderd/idpsync/organization.go#L148
|
|
type idpOrgSyncConfig struct {
|
|
Field string `json:"field"`
|
|
}
|
|
|
|
// checkIDPOrgSync inspects the server flags and the runtime config. It's based on
|
|
// the OrganizationSyncEnabled function from enterprise/coderd/enidpsync/organizations.go.
|
|
// It has one distinct difference: it doesn't check if the license entitles to the
|
|
// feature, it only checks if the feature is configured.
|
|
//
|
|
// The above function is not used because it's very hard to make it available in
|
|
// the telemetry package due to coder/coder package structure and initialization
|
|
// order of the coder server.
|
|
//
|
|
// We don't check license entitlements because it's also hard to do from the
|
|
// telemetry package, and the config check should be sufficient for telemetry purposes.
|
|
//
|
|
// While this approach duplicates code, it's simpler than the alternative.
|
|
//
|
|
// See https://github.com/coder/coder/pull/16323 for more details.
|
|
func checkIDPOrgSync(ctx context.Context, db database.Store, values *codersdk.DeploymentValues) (bool, error) {
|
|
// key based on https://github.com/coder/coder/blob/5c6578d84e2940b9cfd04798c45e7c8042c3fe0e/coderd/idpsync/idpsync.go#L168
|
|
syncConfigRaw, err := db.GetRuntimeConfig(ctx, "organization-sync-settings")
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
// If the runtime config is not set, we check if the deployment config
|
|
// has the organization field set.
|
|
return values != nil && values.OIDC.OrganizationField != "", nil
|
|
}
|
|
return false, xerrors.Errorf("get runtime config: %w", err)
|
|
}
|
|
syncConfig := idpOrgSyncConfig{}
|
|
if err := json.Unmarshal([]byte(syncConfigRaw), &syncConfig); err != nil {
|
|
return false, xerrors.Errorf("unmarshal runtime config: %w", err)
|
|
}
|
|
return syncConfig.Field != "", nil
|
|
}
|
|
|
|
// createSnapshot collects a full snapshot from the database.
|
|
func (r *remoteReporter) createSnapshot() (*Snapshot, error) {
|
|
var (
|
|
ctx = r.ctx
|
|
now = r.options.Clock.Now()
|
|
// For resources that grow in size very quickly (like workspace builds),
|
|
// we only report events that occurred within the past hour.
|
|
createdAfter = dbtime.Time(now.Add(-1 * time.Hour)).UTC()
|
|
eg errgroup.Group
|
|
snapshot = &Snapshot{
|
|
DeploymentID: r.options.DeploymentID,
|
|
}
|
|
)
|
|
|
|
eg.Go(func() error {
|
|
apiKeys, err := r.options.Database.GetAPIKeysLastUsedAfter(ctx, createdAfter)
|
|
if err != nil {
|
|
return xerrors.Errorf("get api keys last used: %w", err)
|
|
}
|
|
snapshot.APIKeys = make([]APIKey, 0, len(apiKeys))
|
|
for _, apiKey := range apiKeys {
|
|
snapshot.APIKeys = append(snapshot.APIKeys, ConvertAPIKey(apiKey))
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
jobs, err := r.options.Database.GetProvisionerJobsCreatedAfter(ctx, createdAfter)
|
|
if err != nil {
|
|
return xerrors.Errorf("get provisioner jobs: %w", err)
|
|
}
|
|
snapshot.ProvisionerJobs = make([]ProvisionerJob, 0, len(jobs))
|
|
for _, job := range jobs {
|
|
snapshot.ProvisionerJobs = append(snapshot.ProvisionerJobs, ConvertProvisionerJob(job))
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
templates, err := r.options.Database.GetTemplates(ctx)
|
|
if err != nil {
|
|
return xerrors.Errorf("get templates: %w", err)
|
|
}
|
|
snapshot.Templates = make([]Template, 0, len(templates))
|
|
for _, dbTemplate := range templates {
|
|
snapshot.Templates = append(snapshot.Templates, ConvertTemplate(dbTemplate))
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
templateVersions, err := r.options.Database.GetTemplateVersionsCreatedAfter(ctx, createdAfter)
|
|
if err != nil {
|
|
return xerrors.Errorf("get template versions: %w", err)
|
|
}
|
|
snapshot.TemplateVersions = make([]TemplateVersion, 0, len(templateVersions))
|
|
for _, version := range templateVersions {
|
|
snapshot.TemplateVersions = append(snapshot.TemplateVersions, ConvertTemplateVersion(version))
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
userRows, err := r.options.Database.GetUsers(ctx, database.GetUsersParams{})
|
|
if err != nil {
|
|
return xerrors.Errorf("get users: %w", err)
|
|
}
|
|
users := database.ConvertUserRows(userRows)
|
|
var firstUser database.User
|
|
for _, dbUser := range users {
|
|
if firstUser.CreatedAt.IsZero() {
|
|
firstUser = dbUser
|
|
}
|
|
if dbUser.CreatedAt.Before(firstUser.CreatedAt) {
|
|
firstUser = dbUser
|
|
}
|
|
}
|
|
snapshot.Users = make([]User, 0, len(users))
|
|
for _, dbUser := range users {
|
|
user := ConvertUser(dbUser)
|
|
// If it's the first user, we'll send the email!
|
|
if firstUser.ID == dbUser.ID {
|
|
email := dbUser.Email
|
|
user.Email = &email
|
|
}
|
|
snapshot.Users = append(snapshot.Users, user)
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
groups, err := r.options.Database.GetGroups(ctx, database.GetGroupsParams{})
|
|
if err != nil {
|
|
return xerrors.Errorf("get groups: %w", err)
|
|
}
|
|
snapshot.Groups = make([]Group, 0, len(groups))
|
|
for _, group := range groups {
|
|
snapshot.Groups = append(snapshot.Groups, ConvertGroup(group.Group))
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
groupMembers, err := r.options.Database.GetGroupMembers(ctx, false)
|
|
if err != nil {
|
|
return xerrors.Errorf("get groups: %w", err)
|
|
}
|
|
snapshot.GroupMembers = make([]GroupMember, 0, len(groupMembers))
|
|
for _, member := range groupMembers {
|
|
snapshot.GroupMembers = append(snapshot.GroupMembers, ConvertGroupMember(member))
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
workspaceRows, err := r.options.Database.GetWorkspaces(ctx, database.GetWorkspacesParams{})
|
|
if err != nil {
|
|
return xerrors.Errorf("get workspaces: %w", err)
|
|
}
|
|
workspaces, err := database.ConvertWorkspaceRows(workspaceRows)
|
|
if err != nil {
|
|
return xerrors.Errorf("convert workspace rows: %w", err)
|
|
}
|
|
snapshot.Workspaces = make([]Workspace, 0, len(workspaces))
|
|
for _, dbWorkspace := range workspaces {
|
|
snapshot.Workspaces = append(snapshot.Workspaces, ConvertWorkspace(dbWorkspace))
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
workspaceApps, err := r.options.Database.GetWorkspaceAppsCreatedAfter(ctx, createdAfter)
|
|
if err != nil {
|
|
return xerrors.Errorf("get workspace apps: %w", err)
|
|
}
|
|
snapshot.WorkspaceApps = make([]WorkspaceApp, 0, len(workspaceApps))
|
|
for _, app := range workspaceApps {
|
|
snapshot.WorkspaceApps = append(snapshot.WorkspaceApps, ConvertWorkspaceApp(app))
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
workspaceAgents, err := r.options.Database.GetWorkspaceAgentsCreatedAfter(ctx, createdAfter)
|
|
if err != nil {
|
|
return xerrors.Errorf("get workspace agents: %w", err)
|
|
}
|
|
snapshot.WorkspaceAgents = make([]WorkspaceAgent, 0, len(workspaceAgents))
|
|
for _, agent := range workspaceAgents {
|
|
snapshot.WorkspaceAgents = append(snapshot.WorkspaceAgents, ConvertWorkspaceAgent(agent))
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
workspaceBuilds, err := r.options.Database.GetWorkspaceBuildsCreatedAfter(ctx, createdAfter)
|
|
if err != nil {
|
|
return xerrors.Errorf("get workspace builds: %w", err)
|
|
}
|
|
snapshot.WorkspaceBuilds = make([]WorkspaceBuild, 0, len(workspaceBuilds))
|
|
for _, build := range workspaceBuilds {
|
|
snapshot.WorkspaceBuilds = append(snapshot.WorkspaceBuilds, ConvertWorkspaceBuild(build))
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
workspaceResources, err := r.options.Database.GetWorkspaceResourcesCreatedAfter(ctx, createdAfter)
|
|
if err != nil {
|
|
return xerrors.Errorf("get workspace resources: %w", err)
|
|
}
|
|
snapshot.WorkspaceResources = make([]WorkspaceResource, 0, len(workspaceResources))
|
|
for _, resource := range workspaceResources {
|
|
snapshot.WorkspaceResources = append(snapshot.WorkspaceResources, ConvertWorkspaceResource(resource))
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
workspaceMetadata, err := r.options.Database.GetWorkspaceResourceMetadataCreatedAfter(ctx, createdAfter)
|
|
if err != nil {
|
|
return xerrors.Errorf("get workspace resource metadata: %w", err)
|
|
}
|
|
snapshot.WorkspaceResourceMetadata = make([]WorkspaceResourceMetadata, 0, len(workspaceMetadata))
|
|
for _, metadata := range workspaceMetadata {
|
|
snapshot.WorkspaceResourceMetadata = append(snapshot.WorkspaceResourceMetadata, ConvertWorkspaceResourceMetadata(metadata))
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
workspaceModules, err := r.options.Database.GetWorkspaceModulesCreatedAfter(ctx, createdAfter)
|
|
if err != nil {
|
|
return xerrors.Errorf("get workspace modules: %w", err)
|
|
}
|
|
snapshot.WorkspaceModules = make([]WorkspaceModule, 0, len(workspaceModules))
|
|
for _, module := range workspaceModules {
|
|
snapshot.WorkspaceModules = append(snapshot.WorkspaceModules, ConvertWorkspaceModule(module))
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
licenses, err := r.options.Database.GetUnexpiredLicenses(ctx)
|
|
if err != nil {
|
|
return xerrors.Errorf("get licenses: %w", err)
|
|
}
|
|
snapshot.Licenses = make([]License, 0, len(licenses))
|
|
for _, license := range licenses {
|
|
tl := ConvertLicense(license)
|
|
if r.options.ParseLicenseJWT != nil {
|
|
if err := r.options.ParseLicenseJWT(&tl); err != nil {
|
|
r.options.Logger.Warn(ctx, "parse license JWT", slog.Error(err))
|
|
}
|
|
}
|
|
snapshot.Licenses = append(snapshot.Licenses, tl)
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
if r.options.DeploymentConfig != nil && slices.Contains(r.options.DeploymentConfig.Experiments, string(codersdk.ExperimentWorkspaceUsage)) {
|
|
agentStats, err := r.options.Database.GetWorkspaceAgentUsageStats(ctx, createdAfter)
|
|
if err != nil {
|
|
return xerrors.Errorf("get workspace agent stats: %w", err)
|
|
}
|
|
snapshot.WorkspaceAgentStats = make([]WorkspaceAgentStat, 0, len(agentStats))
|
|
for _, stat := range agentStats {
|
|
snapshot.WorkspaceAgentStats = append(snapshot.WorkspaceAgentStats, ConvertWorkspaceAgentStat(database.GetWorkspaceAgentStatsRow(stat)))
|
|
}
|
|
} else {
|
|
agentStats, err := r.options.Database.GetWorkspaceAgentStats(ctx, createdAfter)
|
|
if err != nil {
|
|
return xerrors.Errorf("get workspace agent stats: %w", err)
|
|
}
|
|
snapshot.WorkspaceAgentStats = make([]WorkspaceAgentStat, 0, len(agentStats))
|
|
for _, stat := range agentStats {
|
|
snapshot.WorkspaceAgentStats = append(snapshot.WorkspaceAgentStats, ConvertWorkspaceAgentStat(stat))
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
memoryMonitors, err := r.options.Database.FetchMemoryResourceMonitorsUpdatedAfter(ctx, createdAfter)
|
|
if err != nil {
|
|
return xerrors.Errorf("get memory resource monitors: %w", err)
|
|
}
|
|
snapshot.WorkspaceAgentMemoryResourceMonitors = make([]WorkspaceAgentMemoryResourceMonitor, 0, len(memoryMonitors))
|
|
for _, monitor := range memoryMonitors {
|
|
snapshot.WorkspaceAgentMemoryResourceMonitors = append(snapshot.WorkspaceAgentMemoryResourceMonitors, ConvertWorkspaceAgentMemoryResourceMonitor(monitor))
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
volumeMonitors, err := r.options.Database.FetchVolumesResourceMonitorsUpdatedAfter(ctx, createdAfter)
|
|
if err != nil {
|
|
return xerrors.Errorf("get volume resource monitors: %w", err)
|
|
}
|
|
snapshot.WorkspaceAgentVolumeResourceMonitors = make([]WorkspaceAgentVolumeResourceMonitor, 0, len(volumeMonitors))
|
|
for _, monitor := range volumeMonitors {
|
|
snapshot.WorkspaceAgentVolumeResourceMonitors = append(snapshot.WorkspaceAgentVolumeResourceMonitors, ConvertWorkspaceAgentVolumeResourceMonitor(monitor))
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
proxies, err := r.options.Database.GetWorkspaceProxies(ctx)
|
|
if err != nil {
|
|
return xerrors.Errorf("get workspace proxies: %w", err)
|
|
}
|
|
snapshot.WorkspaceProxies = make([]WorkspaceProxy, 0, len(proxies))
|
|
for _, proxy := range proxies {
|
|
snapshot.WorkspaceProxies = append(snapshot.WorkspaceProxies, ConvertWorkspaceProxy(proxy))
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
// Warning: When an organization is deleted, it's completely removed from
|
|
// the database. It will no longer be reported, and there will be no other
|
|
// indicator that it was deleted. This requires special handling when
|
|
// interpreting the telemetry data later.
|
|
orgs, err := r.options.Database.GetOrganizations(r.ctx, database.GetOrganizationsParams{})
|
|
if err != nil {
|
|
return xerrors.Errorf("get organizations: %w", err)
|
|
}
|
|
snapshot.Organizations = make([]Organization, 0, len(orgs))
|
|
for _, org := range orgs {
|
|
snapshot.Organizations = append(snapshot.Organizations, ConvertOrganization(org))
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
items, err := r.options.Database.GetTelemetryItems(ctx)
|
|
if err != nil {
|
|
return xerrors.Errorf("get telemetry items: %w", err)
|
|
}
|
|
snapshot.TelemetryItems = make([]TelemetryItem, 0, len(items))
|
|
for _, item := range items {
|
|
snapshot.TelemetryItems = append(snapshot.TelemetryItems, ConvertTelemetryItem(item))
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
metrics, err := r.options.Database.GetPrebuildMetrics(ctx)
|
|
if err != nil {
|
|
return xerrors.Errorf("get prebuild metrics: %w", err)
|
|
}
|
|
|
|
var totalCreated, totalFailed, totalClaimed int64
|
|
for _, metric := range metrics {
|
|
totalCreated += metric.CreatedCount
|
|
totalFailed += metric.FailedCount
|
|
totalClaimed += metric.ClaimedCount
|
|
}
|
|
|
|
snapshot.PrebuiltWorkspaces = make([]PrebuiltWorkspace, 0, 3)
|
|
now := dbtime.Now()
|
|
|
|
if totalCreated > 0 {
|
|
snapshot.PrebuiltWorkspaces = append(snapshot.PrebuiltWorkspaces, PrebuiltWorkspace{
|
|
ID: uuid.New(),
|
|
CreatedAt: now,
|
|
EventType: PrebuiltWorkspaceEventTypeCreated,
|
|
Count: int(totalCreated),
|
|
})
|
|
}
|
|
if totalFailed > 0 {
|
|
snapshot.PrebuiltWorkspaces = append(snapshot.PrebuiltWorkspaces, PrebuiltWorkspace{
|
|
ID: uuid.New(),
|
|
CreatedAt: now,
|
|
EventType: PrebuiltWorkspaceEventTypeFailed,
|
|
Count: int(totalFailed),
|
|
})
|
|
}
|
|
if totalClaimed > 0 {
|
|
snapshot.PrebuiltWorkspaces = append(snapshot.PrebuiltWorkspaces, PrebuiltWorkspace{
|
|
ID: uuid.New(),
|
|
CreatedAt: now,
|
|
EventType: PrebuiltWorkspaceEventTypeClaimed,
|
|
Count: int(totalClaimed),
|
|
})
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
tasks, err := CollectTasks(ctx, r.options.Database)
|
|
if err != nil {
|
|
return xerrors.Errorf("collect tasks telemetry: %w", err)
|
|
}
|
|
snapshot.Tasks = tasks
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
events, err := CollectTaskEvents(ctx, r.options.Database, createdAfter, now)
|
|
if err != nil {
|
|
return xerrors.Errorf("collect task events telemetry: %w", err)
|
|
}
|
|
snapshot.TaskEvents = events
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
summaries, err := r.generateAIBridgeInterceptionsSummaries(ctx)
|
|
if err != nil {
|
|
return xerrors.Errorf("generate AI Bridge interceptions telemetry summaries: %w", err)
|
|
}
|
|
snapshot.AIBridgeInterceptionsSummaries = summaries
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
summary, err := r.collectBoundaryUsageSummary(ctx)
|
|
if err != nil {
|
|
return xerrors.Errorf("collect boundary usage summary: %w", err)
|
|
}
|
|
// Only send a summary if there was actual usage.
|
|
if summary != nil && summary.UniqueUsers > 0 {
|
|
snapshot.BoundaryUsageSummary = summary
|
|
}
|
|
return nil
|
|
})
|
|
|
|
eg.Go(func() error {
|
|
chats, err := r.options.Database.GetChatsUpdatedAfter(ctx, createdAfter)
|
|
if err != nil {
|
|
return xerrors.Errorf("get chats updated after: %w", err)
|
|
}
|
|
snapshot.Chats = make([]Chat, 0, len(chats))
|
|
for _, chat := range chats {
|
|
snapshot.Chats = append(snapshot.Chats, ConvertChat(chat))
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
summaries, err := r.options.Database.GetChatMessageSummariesPerChat(ctx, createdAfter)
|
|
if err != nil {
|
|
return xerrors.Errorf("get chat message summaries: %w", err)
|
|
}
|
|
snapshot.ChatMessageSummaries = make([]ChatMessageSummary, 0, len(summaries))
|
|
for _, s := range summaries {
|
|
snapshot.ChatMessageSummaries = append(snapshot.ChatMessageSummaries, ConvertChatMessageSummary(s))
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
configs, err := r.options.Database.GetChatModelConfigsForTelemetry(ctx)
|
|
if err != nil {
|
|
return xerrors.Errorf("get chat model configs: %w", err)
|
|
}
|
|
snapshot.ChatModelConfigs = make([]ChatModelConfig, 0, len(configs))
|
|
for _, c := range configs {
|
|
snapshot.ChatModelConfigs = append(snapshot.ChatModelConfigs, ConvertChatModelConfig(c))
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
row, err := r.options.Database.GetChatDiffStatusSummary(ctx)
|
|
if err != nil {
|
|
return xerrors.Errorf("get chat diff status summary: %w", err)
|
|
}
|
|
snapshot.ChatDiffStatusSummary = &ChatDiffStatusSummary{
|
|
Total: row.Total,
|
|
Open: row.Open,
|
|
Merged: row.Merged,
|
|
Closed: row.Closed,
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() error {
|
|
summary, err := r.collectUserSecretsSummary(ctx)
|
|
if err != nil {
|
|
return xerrors.Errorf("collect user secrets summary: %w", err)
|
|
}
|
|
// summary is nil when another replica already claimed the
|
|
// telemetry lock for this period.
|
|
if summary != nil {
|
|
snapshot.UserSecretsSummary = summary
|
|
}
|
|
return nil
|
|
})
|
|
|
|
err := eg.Wait()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return snapshot, nil
|
|
}
|
|
|
|
func (r *remoteReporter) generateAIBridgeInterceptionsSummaries(ctx context.Context) ([]AIBridgeInterceptionsSummary, error) {
|
|
// Get the current timeframe, which is the previous hour.
|
|
now := dbtime.Time(r.options.Clock.Now()).UTC()
|
|
endedAtBefore := now.Truncate(time.Hour)
|
|
endedAtAfter := endedAtBefore.Add(-1 * time.Hour)
|
|
|
|
// Note: we don't use a transaction for this function since we do tolerate
|
|
// some errors, like duplicate lock rows, and we also calculate
|
|
// summaries in parallel.
|
|
|
|
// Claim the heartbeat lock row for this hour.
|
|
err := r.options.Database.InsertTelemetryLock(ctx, database.InsertTelemetryLockParams{
|
|
EventType: "aibridge_interceptions_summary",
|
|
PeriodEndingAt: endedAtBefore,
|
|
})
|
|
if database.IsUniqueViolation(err, database.UniqueTelemetryLocksPkey) {
|
|
// Another replica has already claimed the lock row for this hour.
|
|
r.options.Logger.Debug(ctx, "aibridge interceptions telemetry lock already claimed for this hour by another replica, skipping", slog.F("period_ending_at", endedAtBefore))
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("insert AI Bridge interceptions telemetry lock (period_ending_at=%q): %w", endedAtBefore, err)
|
|
}
|
|
|
|
// List the summary categories that need to be calculated.
|
|
summaryCategories, err := r.options.Database.ListAIBridgeInterceptionsTelemetrySummaries(ctx, database.ListAIBridgeInterceptionsTelemetrySummariesParams{
|
|
EndedAtAfter: endedAtAfter, // inclusive
|
|
EndedAtBefore: endedAtBefore, // exclusive
|
|
})
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("list AI Bridge interceptions telemetry summaries (startedAtAfter=%q, endedAtBefore=%q): %w", endedAtAfter, endedAtBefore, err)
|
|
}
|
|
|
|
// Calculate and convert the summaries for all categories.
|
|
var (
|
|
eg, egCtx = errgroup.WithContext(ctx)
|
|
mu sync.Mutex
|
|
summaries = make([]AIBridgeInterceptionsSummary, 0, len(summaryCategories))
|
|
)
|
|
for _, category := range summaryCategories {
|
|
eg.Go(func() error {
|
|
summary, err := r.options.Database.CalculateAIBridgeInterceptionsTelemetrySummary(egCtx, database.CalculateAIBridgeInterceptionsTelemetrySummaryParams{
|
|
Provider: category.Provider,
|
|
Model: category.Model,
|
|
Client: category.Client,
|
|
EndedAtAfter: endedAtAfter,
|
|
EndedAtBefore: endedAtBefore,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("calculate AI Bridge interceptions telemetry summary (provider=%q, model=%q, client=%q, startedAtAfter=%q, endedAtBefore=%q): %w", category.Provider, category.Model, category.Client, endedAtAfter, endedAtBefore, err)
|
|
}
|
|
|
|
// Double check that at least one interception was found in the
|
|
// timeframe.
|
|
if summary.InterceptionCount == 0 {
|
|
return nil
|
|
}
|
|
|
|
converted := ConvertAIBridgeInterceptionsSummary(endedAtBefore, category.Provider, category.Model, category.Client, summary)
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
summaries = append(summaries, converted)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
return summaries, eg.Wait()
|
|
}
|
|
|
|
// collectBoundaryUsageSummary collects boundary usage statistics from all
|
|
// replicas and resets the stats for the next telemetry period. Returns nil if
|
|
// another replica has already collected for this period.
|
|
func (r *remoteReporter) collectBoundaryUsageSummary(ctx context.Context) (*BoundaryUsageSummary, error) {
|
|
// Use twice the snapshot frequency as the staleness limit to ensure we
|
|
// capture data from replicas that may have slightly different flush times.
|
|
maxStaleness := r.options.SnapshotFrequency * 2
|
|
//nolint:gocritic // This is the actual collection of boundary usage tracking.
|
|
boundaryCtx := dbauthz.AsBoundaryUsageTracker(ctx)
|
|
|
|
// Claim the telemetry lock for this period. Use snapshot frequency so each
|
|
// telemetry snapshot period gets exactly one collection.
|
|
now := dbtime.Time(r.options.Clock.Now()).UTC()
|
|
periodEndingAt := now.Truncate(r.options.SnapshotFrequency)
|
|
err := r.options.Database.InsertTelemetryLock(ctx, database.InsertTelemetryLockParams{
|
|
EventType: "boundary_usage_summary",
|
|
PeriodEndingAt: periodEndingAt,
|
|
})
|
|
if database.IsUniqueViolation(err, database.UniqueTelemetryLocksPkey) {
|
|
r.options.Logger.Debug(ctx, "boundary usage telemetry lock already claimed by another replica, skipping", slog.F("period_ending_at", periodEndingAt))
|
|
return nil, nil //nolint:nilnil // This is simple to handle when dealing with telemetry.
|
|
}
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("insert boundary usage telemetry lock (period_ending_at=%q): %w", periodEndingAt, err)
|
|
}
|
|
|
|
var summary database.GetAndResetBoundaryUsageSummaryRow
|
|
err = r.options.Database.InTx(func(tx database.Store) error {
|
|
// The advisory lock use here ensures a clean transition to the next snapshot by
|
|
// preventing replicas from upserting row(s) at the same time as we aggregate and
|
|
// delete all rows here.
|
|
var txErr error
|
|
if txErr = tx.AcquireLock(boundaryCtx, database.LockIDBoundaryUsageStats); txErr != nil {
|
|
return txErr
|
|
}
|
|
summary, txErr = tx.GetAndResetBoundaryUsageSummary(boundaryCtx, maxStaleness.Milliseconds())
|
|
return txErr
|
|
}, nil)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("get and reset boundary usage summary: %w", err)
|
|
}
|
|
|
|
return &BoundaryUsageSummary{
|
|
UniqueWorkspaces: summary.UniqueWorkspaces,
|
|
UniqueUsers: summary.UniqueUsers,
|
|
AllowedRequests: summary.AllowedRequests,
|
|
DeniedRequests: summary.DeniedRequests,
|
|
PeriodStart: now.Add(-r.options.SnapshotFrequency),
|
|
PeriodDurationMilliseconds: r.options.SnapshotFrequency.Milliseconds(),
|
|
}, nil
|
|
}
|
|
|
|
// collectUserSecretsSummary returns a deployment-wide aggregate of user
|
|
// secrets configuration. Returns nil if another replica has already
|
|
// collected for this period.
|
|
//
|
|
// The summary has no natural per-row UUID for the telemetry server to
|
|
// de-duplicate on, so we elect a single replica per snapshot period
|
|
// via the telemetry_locks table.
|
|
func (r *remoteReporter) collectUserSecretsSummary(ctx context.Context) (*UserSecretsSummary, error) {
|
|
// Claim the telemetry lock for this period. Use snapshot frequency so
|
|
// each telemetry snapshot period gets exactly one collection across
|
|
// replicas.
|
|
periodEndingAt := dbtime.Time(r.options.Clock.Now()).UTC().Truncate(r.options.SnapshotFrequency)
|
|
err := r.options.Database.InsertTelemetryLock(ctx, database.InsertTelemetryLockParams{
|
|
EventType: "user_secrets_summary",
|
|
PeriodEndingAt: periodEndingAt,
|
|
})
|
|
if database.IsUniqueViolation(err, database.UniqueTelemetryLocksPkey) {
|
|
r.options.Logger.Debug(ctx, "user secrets telemetry lock already claimed by another replica, skipping", slog.F("period_ending_at", periodEndingAt))
|
|
return nil, nil //nolint:nilnil // This is simple to handle when dealing with telemetry.
|
|
}
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("insert user secrets telemetry lock (period_ending_at=%q): %w", periodEndingAt, err)
|
|
}
|
|
|
|
row, err := r.options.Database.GetUserSecretsTelemetrySummary(ctx)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("get user secrets telemetry summary: %w", err)
|
|
}
|
|
return &UserSecretsSummary{
|
|
UsersWithSecrets: row.UsersWithSecrets,
|
|
TotalSecrets: row.TotalSecrets,
|
|
EnvNameOnly: row.EnvNameOnly,
|
|
FilePathOnly: row.FilePathOnly,
|
|
Both: row.Both,
|
|
Neither: row.Neither,
|
|
SecretsPerUserMax: row.SecretsPerUserMax,
|
|
SecretsPerUserP25: row.SecretsPerUserP25,
|
|
SecretsPerUserP50: row.SecretsPerUserP50,
|
|
SecretsPerUserP75: row.SecretsPerUserP75,
|
|
SecretsPerUserP90: row.SecretsPerUserP90,
|
|
}, nil
|
|
}
|
|
|
|
func CollectTasks(ctx context.Context, db database.Store) ([]Task, error) {
|
|
dbTasks, err := db.ListTasks(ctx, database.ListTasksParams{
|
|
OwnerID: uuid.Nil,
|
|
OrganizationID: uuid.Nil,
|
|
Status: "",
|
|
})
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("list tasks: %w", err)
|
|
}
|
|
if len(dbTasks) == 0 {
|
|
return []Task{}, nil
|
|
}
|
|
|
|
tasks := make([]Task, 0, len(dbTasks))
|
|
for _, dbTask := range dbTasks {
|
|
tasks = append(tasks, ConvertTask(dbTask))
|
|
}
|
|
return tasks, nil
|
|
}
|
|
|
|
// buildTaskEvent constructs a TaskEvent from the combined query row.
|
|
func buildTaskEvent(
|
|
row database.GetTelemetryTaskEventsRow,
|
|
createdAfter time.Time,
|
|
now time.Time,
|
|
) TaskEvent {
|
|
event := TaskEvent{
|
|
TaskID: row.TaskID.String(),
|
|
}
|
|
|
|
var (
|
|
hasStartBuild = row.StartBuildCreatedAt.Valid
|
|
isResumed = hasStartBuild && row.StartBuildNumber.Valid && row.StartBuildNumber.Int32 > 1
|
|
hasStopBuild = row.StopBuildCreatedAt.Valid
|
|
startedAfterStop = hasStartBuild && hasStopBuild && row.StartBuildCreatedAt.Time.After(row.StopBuildCreatedAt.Time)
|
|
currentlyPaused = hasStopBuild && !startedAfterStop
|
|
)
|
|
|
|
// Pause-related fields (requires a stop build).
|
|
if hasStopBuild {
|
|
event.LastPausedAt = &row.StopBuildCreatedAt.Time
|
|
switch {
|
|
case row.StopBuildReason.Valid && row.StopBuildReason.BuildReason == database.BuildReasonTaskAutoPause:
|
|
event.PauseReason = ptr.Ref("auto")
|
|
case row.StopBuildReason.Valid && row.StopBuildReason.BuildReason == database.BuildReasonTaskManualPause:
|
|
event.PauseReason = ptr.Ref("manual")
|
|
default:
|
|
event.PauseReason = ptr.Ref("other")
|
|
}
|
|
|
|
// Idle duration: time between last working status and the pause.
|
|
if row.LastWorkingStatusAt.Valid &&
|
|
row.StopBuildCreatedAt.Time.After(row.LastWorkingStatusAt.Time) {
|
|
idle := row.StopBuildCreatedAt.Time.Sub(row.LastWorkingStatusAt.Time)
|
|
event.IdleDurationMS = ptr.Ref(idle.Milliseconds())
|
|
}
|
|
}
|
|
|
|
// Resume-related fields (requires task_resume start after stop).
|
|
if startedAfterStop {
|
|
// Paused duration: time between pause and resume.
|
|
if row.StartBuildCreatedAt.Time.After(createdAfter) {
|
|
paused := row.StartBuildCreatedAt.Time.Sub(row.StopBuildCreatedAt.Time)
|
|
event.PausedDurationMS = ptr.Ref(paused.Milliseconds())
|
|
}
|
|
|
|
// Below only relevant for "resumed" tasks, not when initially created.
|
|
if isResumed {
|
|
event.LastResumedAt = &row.StartBuildCreatedAt.Time
|
|
switch {
|
|
// TODO(Cian): will this exist? Future readers may know better than I.
|
|
// case row.StartBuildReason == database.BuildReasonTaskAutoResume:
|
|
// event.ResumeReason = ptr.Ref("auto")
|
|
case row.StartBuildReason.BuildReason == database.BuildReasonTaskResume:
|
|
event.ResumeReason = ptr.Ref("manual")
|
|
default: // Task resumed by starting workspace?
|
|
event.ResumeReason = ptr.Ref("other")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Unresolved pause: report current paused duration.
|
|
if currentlyPaused {
|
|
paused := now.Sub(row.StopBuildCreatedAt.Time)
|
|
event.PausedDurationMS = ptr.Ref(paused.Milliseconds())
|
|
}
|
|
|
|
// Resume-to-status duration.
|
|
if row.FirstStatusAfterResumeAt.Valid && isResumed {
|
|
delta := row.FirstStatusAfterResumeAt.Time.Sub(row.StartBuildCreatedAt.Time)
|
|
event.ResumeToStatusMS = ptr.Ref(delta.Milliseconds())
|
|
}
|
|
|
|
// Active duration: from SQL calculation.
|
|
if row.ActiveDurationMs > 0 {
|
|
event.ActiveDurationMS = ptr.Ref(row.ActiveDurationMs)
|
|
}
|
|
|
|
return event
|
|
}
|
|
|
|
// CollectTaskEvents collects lifecycle events for tasks with recent activity.
|
|
func CollectTaskEvents(ctx context.Context, db database.Store, createdAfter, now time.Time) ([]TaskEvent, error) {
|
|
rows, err := db.GetTelemetryTaskEvents(ctx, database.GetTelemetryTaskEventsParams{
|
|
CreatedAfter: createdAfter,
|
|
Now: now,
|
|
})
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("get telemetry task events: %w", err)
|
|
}
|
|
events := make([]TaskEvent, 0, len(rows))
|
|
for _, row := range rows {
|
|
events = append(events, buildTaskEvent(row, createdAfter, now))
|
|
}
|
|
return events, nil
|
|
}
|
|
|
|
// HashContent returns a SHA256 hash of the content as a hex string.
|
|
// This is useful for hashing sensitive content like prompts for telemetry.
|
|
func HashContent(content string) string {
|
|
return fmt.Sprintf("%x", sha256.Sum256([]byte(content)))
|
|
}
|
|
|
|
// ConvertAPIKey anonymizes an API key.
|
|
func ConvertAPIKey(apiKey database.APIKey) APIKey {
|
|
a := APIKey{
|
|
ID: apiKey.ID,
|
|
UserID: apiKey.UserID,
|
|
CreatedAt: apiKey.CreatedAt,
|
|
LastUsed: apiKey.LastUsed,
|
|
LoginType: apiKey.LoginType,
|
|
}
|
|
if apiKey.IPAddress.Valid {
|
|
a.IPAddress = apiKey.IPAddress.IPNet.IP
|
|
}
|
|
return a
|
|
}
|
|
|
|
// ConvertWorkspace anonymizes a workspace.
|
|
func ConvertWorkspace(workspace database.Workspace) Workspace {
|
|
return Workspace{
|
|
ID: workspace.ID,
|
|
OrganizationID: workspace.OrganizationID,
|
|
OwnerID: workspace.OwnerID,
|
|
TemplateID: workspace.TemplateID,
|
|
CreatedAt: workspace.CreatedAt,
|
|
Deleted: workspace.Deleted,
|
|
Name: workspace.Name,
|
|
AutostartSchedule: workspace.AutostartSchedule.String,
|
|
AutomaticUpdates: string(workspace.AutomaticUpdates),
|
|
}
|
|
}
|
|
|
|
// ConvertWorkspaceBuild anonymizes a workspace build.
|
|
func ConvertWorkspaceBuild(build database.WorkspaceBuild) WorkspaceBuild {
|
|
wb := WorkspaceBuild{
|
|
ID: build.ID,
|
|
CreatedAt: build.CreatedAt,
|
|
WorkspaceID: build.WorkspaceID,
|
|
JobID: build.JobID,
|
|
TemplateVersionID: build.TemplateVersionID,
|
|
// #nosec G115 - Safe conversion as build numbers are expected to be positive and within uint32 range
|
|
BuildNumber: uint32(build.BuildNumber),
|
|
}
|
|
if build.HasAITask.Valid {
|
|
wb.HasAITask = ptr.Ref(build.HasAITask.Bool)
|
|
}
|
|
return wb
|
|
}
|
|
|
|
// ConvertProvisionerJob anonymizes a provisioner job.
|
|
func ConvertProvisionerJob(job database.ProvisionerJob) ProvisionerJob {
|
|
snapJob := ProvisionerJob{
|
|
ID: job.ID,
|
|
OrganizationID: job.OrganizationID,
|
|
InitiatorID: job.InitiatorID,
|
|
CreatedAt: job.CreatedAt,
|
|
UpdatedAt: job.UpdatedAt,
|
|
Error: job.Error.String,
|
|
Type: job.Type,
|
|
}
|
|
if job.StartedAt.Valid {
|
|
snapJob.StartedAt = &job.StartedAt.Time
|
|
}
|
|
if job.CanceledAt.Valid {
|
|
snapJob.CanceledAt = &job.CanceledAt.Time
|
|
}
|
|
if job.CompletedAt.Valid {
|
|
snapJob.CompletedAt = &job.CompletedAt.Time
|
|
}
|
|
return snapJob
|
|
}
|
|
|
|
// ConvertWorkspaceAgent anonymizes a workspace agent.
|
|
func ConvertWorkspaceAgent(agent database.WorkspaceAgent) WorkspaceAgent {
|
|
subsystems := []string{}
|
|
for _, subsystem := range agent.Subsystems {
|
|
subsystems = append(subsystems, string(subsystem))
|
|
}
|
|
|
|
snapAgent := WorkspaceAgent{
|
|
ID: agent.ID,
|
|
CreatedAt: agent.CreatedAt,
|
|
ResourceID: agent.ResourceID,
|
|
InstanceAuth: agent.AuthInstanceID.Valid,
|
|
Architecture: agent.Architecture,
|
|
OperatingSystem: agent.OperatingSystem,
|
|
EnvironmentVariables: agent.EnvironmentVariables.Valid,
|
|
Directory: agent.Directory != "",
|
|
ConnectionTimeoutSeconds: agent.ConnectionTimeoutSeconds,
|
|
Subsystems: subsystems,
|
|
}
|
|
if agent.FirstConnectedAt.Valid {
|
|
snapAgent.FirstConnectedAt = &agent.FirstConnectedAt.Time
|
|
}
|
|
if agent.LastConnectedAt.Valid {
|
|
snapAgent.LastConnectedAt = &agent.LastConnectedAt.Time
|
|
}
|
|
if agent.DisconnectedAt.Valid {
|
|
snapAgent.DisconnectedAt = &agent.DisconnectedAt.Time
|
|
}
|
|
return snapAgent
|
|
}
|
|
|
|
func ConvertWorkspaceAgentMemoryResourceMonitor(monitor database.WorkspaceAgentMemoryResourceMonitor) WorkspaceAgentMemoryResourceMonitor {
|
|
return WorkspaceAgentMemoryResourceMonitor{
|
|
AgentID: monitor.AgentID,
|
|
Enabled: monitor.Enabled,
|
|
Threshold: monitor.Threshold,
|
|
CreatedAt: monitor.CreatedAt,
|
|
UpdatedAt: monitor.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func ConvertWorkspaceAgentVolumeResourceMonitor(monitor database.WorkspaceAgentVolumeResourceMonitor) WorkspaceAgentVolumeResourceMonitor {
|
|
return WorkspaceAgentVolumeResourceMonitor{
|
|
AgentID: monitor.AgentID,
|
|
Enabled: monitor.Enabled,
|
|
Threshold: monitor.Threshold,
|
|
CreatedAt: monitor.CreatedAt,
|
|
UpdatedAt: monitor.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
// ConvertWorkspaceAgentStat anonymizes a workspace agent stat.
|
|
func ConvertWorkspaceAgentStat(stat database.GetWorkspaceAgentStatsRow) WorkspaceAgentStat {
|
|
return WorkspaceAgentStat{
|
|
UserID: stat.UserID,
|
|
TemplateID: stat.TemplateID,
|
|
WorkspaceID: stat.WorkspaceID,
|
|
AgentID: stat.AgentID,
|
|
AggregatedFrom: stat.AggregatedFrom,
|
|
ConnectionLatency50: stat.WorkspaceConnectionLatency50,
|
|
ConnectionLatency95: stat.WorkspaceConnectionLatency95,
|
|
RxBytes: stat.WorkspaceRxBytes,
|
|
TxBytes: stat.WorkspaceTxBytes,
|
|
SessionCountVSCode: stat.SessionCountVSCode,
|
|
SessionCountJetBrains: stat.SessionCountJetBrains,
|
|
SessionCountReconnectingPTY: stat.SessionCountReconnectingPTY,
|
|
SessionCountSSH: stat.SessionCountSSH,
|
|
}
|
|
}
|
|
|
|
// ConvertWorkspaceApp anonymizes a workspace app.
|
|
func ConvertWorkspaceApp(app database.WorkspaceApp) WorkspaceApp {
|
|
return WorkspaceApp{
|
|
ID: app.ID,
|
|
CreatedAt: app.CreatedAt,
|
|
AgentID: app.AgentID,
|
|
Icon: app.Icon,
|
|
Subdomain: app.Subdomain,
|
|
}
|
|
}
|
|
|
|
// ConvertWorkspaceResource anonymizes a workspace resource.
|
|
func ConvertWorkspaceResource(resource database.WorkspaceResource) WorkspaceResource {
|
|
r := WorkspaceResource{
|
|
ID: resource.ID,
|
|
JobID: resource.JobID,
|
|
CreatedAt: resource.CreatedAt,
|
|
Transition: resource.Transition,
|
|
Type: resource.Type,
|
|
InstanceType: resource.InstanceType.String,
|
|
}
|
|
if resource.ModulePath.Valid {
|
|
r.ModulePath = &resource.ModulePath.String
|
|
}
|
|
return r
|
|
}
|
|
|
|
// ConvertWorkspaceResourceMetadata anonymizes workspace metadata.
|
|
func ConvertWorkspaceResourceMetadata(metadata database.WorkspaceResourceMetadatum) WorkspaceResourceMetadata {
|
|
return WorkspaceResourceMetadata{
|
|
ResourceID: metadata.WorkspaceResourceID,
|
|
Key: metadata.Key,
|
|
Sensitive: metadata.Sensitive,
|
|
}
|
|
}
|
|
|
|
func shouldSendRawModuleSource(source string) bool {
|
|
return strings.Contains(source, "registry.coder.com")
|
|
}
|
|
|
|
// ModuleSourceType is the type of source for a module.
|
|
// For reference, see https://developer.hashicorp.com/terraform/language/modules/sources
|
|
type ModuleSourceType string
|
|
|
|
const (
|
|
ModuleSourceTypeLocal ModuleSourceType = "local"
|
|
ModuleSourceTypeLocalAbs ModuleSourceType = "local_absolute"
|
|
ModuleSourceTypePublicRegistry ModuleSourceType = "public_registry"
|
|
ModuleSourceTypePrivateRegistry ModuleSourceType = "private_registry"
|
|
ModuleSourceTypeCoderRegistry ModuleSourceType = "coder_registry"
|
|
ModuleSourceTypeGitHub ModuleSourceType = "github"
|
|
ModuleSourceTypeBitbucket ModuleSourceType = "bitbucket"
|
|
ModuleSourceTypeGit ModuleSourceType = "git"
|
|
ModuleSourceTypeMercurial ModuleSourceType = "mercurial"
|
|
ModuleSourceTypeHTTP ModuleSourceType = "http"
|
|
ModuleSourceTypeS3 ModuleSourceType = "s3"
|
|
ModuleSourceTypeGCS ModuleSourceType = "gcs"
|
|
ModuleSourceTypeUnknown ModuleSourceType = "unknown"
|
|
)
|
|
|
|
// Terraform supports a variety of module source types, like:
|
|
// - local paths (./ or ../)
|
|
// - absolute local paths (/)
|
|
// - git URLs (git:: or git@)
|
|
// - http URLs
|
|
// - s3 URLs
|
|
//
|
|
// and more!
|
|
//
|
|
// See https://developer.hashicorp.com/terraform/language/modules/sources for an overview.
|
|
//
|
|
// This function attempts to classify the source type of a module. It's imperfect,
|
|
// as checks that terraform actually does are pretty complicated.
|
|
// See e.g. https://github.com/hashicorp/go-getter/blob/842d6c379e5e70d23905b8f6b5a25a80290acb66/detect.go#L47
|
|
// if you're interested in the complexity.
|
|
func GetModuleSourceType(source string) ModuleSourceType {
|
|
source = strings.TrimSpace(source)
|
|
source = strings.ToLower(source)
|
|
if strings.HasPrefix(source, "./") || strings.HasPrefix(source, "../") {
|
|
return ModuleSourceTypeLocal
|
|
}
|
|
if strings.HasPrefix(source, "/") {
|
|
return ModuleSourceTypeLocalAbs
|
|
}
|
|
// Match public registry modules in the format <NAMESPACE>/<NAME>/<PROVIDER>
|
|
// Sources can have a `//...` suffix, which signifies a subdirectory.
|
|
// The allowed characters are based on
|
|
// https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/modules#request-body-1
|
|
// because Hashicorp's documentation about module sources doesn't mention it.
|
|
if matched, _ := regexp.MatchString(`^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+(//.*)?$`, source); matched {
|
|
return ModuleSourceTypePublicRegistry
|
|
}
|
|
if strings.Contains(source, "github.com") {
|
|
return ModuleSourceTypeGitHub
|
|
}
|
|
if strings.Contains(source, "bitbucket.org") {
|
|
return ModuleSourceTypeBitbucket
|
|
}
|
|
if strings.HasPrefix(source, "git::") || strings.HasPrefix(source, "git@") {
|
|
return ModuleSourceTypeGit
|
|
}
|
|
if strings.HasPrefix(source, "hg::") {
|
|
return ModuleSourceTypeMercurial
|
|
}
|
|
if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") {
|
|
return ModuleSourceTypeHTTP
|
|
}
|
|
if strings.HasPrefix(source, "s3::") {
|
|
return ModuleSourceTypeS3
|
|
}
|
|
if strings.HasPrefix(source, "gcs::") {
|
|
return ModuleSourceTypeGCS
|
|
}
|
|
if strings.Contains(source, "registry.terraform.io") {
|
|
return ModuleSourceTypePublicRegistry
|
|
}
|
|
if strings.Contains(source, "app.terraform.io") || strings.Contains(source, "localterraform.com") {
|
|
return ModuleSourceTypePrivateRegistry
|
|
}
|
|
if strings.Contains(source, "registry.coder.com") {
|
|
return ModuleSourceTypeCoderRegistry
|
|
}
|
|
return ModuleSourceTypeUnknown
|
|
}
|
|
|
|
func ConvertWorkspaceModule(module database.WorkspaceModule) WorkspaceModule {
|
|
source := module.Source
|
|
version := module.Version
|
|
sourceType := GetModuleSourceType(source)
|
|
if !shouldSendRawModuleSource(source) {
|
|
source = fmt.Sprintf("%x", sha256.Sum256([]byte(source)))
|
|
version = fmt.Sprintf("%x", sha256.Sum256([]byte(version)))
|
|
}
|
|
|
|
return WorkspaceModule{
|
|
ID: module.ID,
|
|
JobID: module.JobID,
|
|
Transition: module.Transition,
|
|
Source: source,
|
|
Version: version,
|
|
SourceType: sourceType,
|
|
Key: module.Key,
|
|
CreatedAt: module.CreatedAt,
|
|
}
|
|
}
|
|
|
|
// ConvertUser anonymizes a user.
|
|
func ConvertUser(dbUser database.User) User {
|
|
emailHashed := ""
|
|
atSymbol := strings.LastIndex(dbUser.Email, "@")
|
|
if atSymbol >= 0 {
|
|
// We hash the beginning of the user to allow for indexing users
|
|
// by email between deployments.
|
|
hash := sha256.Sum256([]byte(dbUser.Email[:atSymbol]))
|
|
emailHashed = fmt.Sprintf("%x%s", hash[:], dbUser.Email[atSymbol:])
|
|
}
|
|
return User{
|
|
ID: dbUser.ID,
|
|
EmailHashed: emailHashed,
|
|
RBACRoles: dbUser.RBACRoles,
|
|
CreatedAt: dbUser.CreatedAt,
|
|
Status: dbUser.Status,
|
|
GithubComUserID: dbUser.GithubComUserID.Int64,
|
|
LoginType: string(dbUser.LoginType),
|
|
}
|
|
}
|
|
|
|
func ConvertGroup(group database.Group) Group {
|
|
return Group{
|
|
ID: group.ID,
|
|
Name: group.Name,
|
|
OrganizationID: group.OrganizationID,
|
|
AvatarURL: group.AvatarURL,
|
|
QuotaAllowance: group.QuotaAllowance,
|
|
DisplayName: group.DisplayName,
|
|
Source: group.Source,
|
|
}
|
|
}
|
|
|
|
func ConvertGroupMember(member database.GroupMember) GroupMember {
|
|
return GroupMember{
|
|
GroupID: member.GroupID,
|
|
UserID: member.UserID,
|
|
}
|
|
}
|
|
|
|
// ConvertTemplate anonymizes a template.
|
|
func ConvertTemplate(dbTemplate database.Template) Template {
|
|
return Template{
|
|
ID: dbTemplate.ID,
|
|
CreatedBy: dbTemplate.CreatedBy,
|
|
CreatedAt: dbTemplate.CreatedAt,
|
|
UpdatedAt: dbTemplate.UpdatedAt,
|
|
OrganizationID: dbTemplate.OrganizationID,
|
|
Deleted: dbTemplate.Deleted,
|
|
ActiveVersionID: dbTemplate.ActiveVersionID,
|
|
Name: dbTemplate.Name,
|
|
Description: dbTemplate.Description != "",
|
|
|
|
// Some of these fields are meant to be accessed using a specialized
|
|
// interface (for entitlement purposes), but for telemetry purposes
|
|
// there's minimal harm accessing them directly.
|
|
DefaultTTLMillis: time.Duration(dbTemplate.DefaultTTL).Milliseconds(),
|
|
AllowUserCancelWorkspaceJobs: dbTemplate.AllowUserCancelWorkspaceJobs,
|
|
AllowUserAutostart: dbTemplate.AllowUserAutostart,
|
|
AllowUserAutostop: dbTemplate.AllowUserAutostop,
|
|
FailureTTLMillis: time.Duration(dbTemplate.FailureTTL).Milliseconds(),
|
|
TimeTilDormantMillis: time.Duration(dbTemplate.TimeTilDormant).Milliseconds(),
|
|
TimeTilDormantAutoDeleteMillis: time.Duration(dbTemplate.TimeTilDormantAutoDelete).Milliseconds(),
|
|
// #nosec G115 - Safe conversion as AutostopRequirementDaysOfWeek is a bitmap of 7 days, easily within uint8 range
|
|
AutostopRequirementDaysOfWeek: codersdk.BitmapToWeekdays(uint8(dbTemplate.AutostopRequirementDaysOfWeek)),
|
|
AutostopRequirementWeeks: dbTemplate.AutostopRequirementWeeks,
|
|
AutostartAllowedDays: codersdk.BitmapToWeekdays(dbTemplate.AutostartAllowedDays()),
|
|
RequireActiveVersion: dbTemplate.RequireActiveVersion,
|
|
Deprecated: dbTemplate.Deprecated != "",
|
|
UseClassicParameterFlow: ptr.Ref(dbTemplate.UseClassicParameterFlow),
|
|
}
|
|
}
|
|
|
|
// ConvertTemplateVersion anonymizes a template version.
|
|
func ConvertTemplateVersion(version database.TemplateVersion) TemplateVersion {
|
|
snapVersion := TemplateVersion{
|
|
ID: version.ID,
|
|
CreatedAt: version.CreatedAt,
|
|
OrganizationID: version.OrganizationID,
|
|
JobID: version.JobID,
|
|
}
|
|
if version.TemplateID.Valid {
|
|
snapVersion.TemplateID = &version.TemplateID.UUID
|
|
}
|
|
if version.SourceExampleID.Valid {
|
|
snapVersion.SourceExampleID = &version.SourceExampleID.String
|
|
}
|
|
if version.HasAITask.Valid {
|
|
snapVersion.HasAITask = ptr.Ref(version.HasAITask.Bool)
|
|
}
|
|
return snapVersion
|
|
}
|
|
|
|
func ConvertLicense(license database.License) License {
|
|
// License is intentionally not anonymized because it's
|
|
// deployment-wide, and we already have an index of all issued
|
|
// licenses.
|
|
return License{
|
|
JWT: license.JWT,
|
|
Exp: license.Exp,
|
|
UploadedAt: license.UploadedAt,
|
|
UUID: license.UUID,
|
|
}
|
|
}
|
|
|
|
// ConvertWorkspaceProxy anonymizes a workspace proxy.
|
|
func ConvertWorkspaceProxy(proxy database.WorkspaceProxy) WorkspaceProxy {
|
|
return WorkspaceProxy{
|
|
ID: proxy.ID,
|
|
Name: proxy.Name,
|
|
DisplayName: proxy.DisplayName,
|
|
DerpEnabled: proxy.DerpEnabled,
|
|
DerpOnly: proxy.DerpOnly,
|
|
CreatedAt: proxy.CreatedAt,
|
|
UpdatedAt: proxy.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func ConvertExternalProvisioner(id uuid.UUID, tags map[string]string, provisioners []database.ProvisionerType) ExternalProvisioner {
|
|
tagsCopy := make(map[string]string, len(tags))
|
|
for k, v := range tags {
|
|
tagsCopy[k] = v
|
|
}
|
|
strProvisioners := make([]string, 0, len(provisioners))
|
|
for _, prov := range provisioners {
|
|
strProvisioners = append(strProvisioners, string(prov))
|
|
}
|
|
return ExternalProvisioner{
|
|
ID: id.String(),
|
|
Tags: tagsCopy,
|
|
Provisioners: strProvisioners,
|
|
StartedAt: time.Now(),
|
|
}
|
|
}
|
|
|
|
func ConvertOrganization(org database.Organization) Organization {
|
|
return Organization{
|
|
ID: org.ID,
|
|
CreatedAt: org.CreatedAt,
|
|
IsDefault: org.IsDefault,
|
|
}
|
|
}
|
|
|
|
func ConvertTelemetryItem(item database.TelemetryItem) TelemetryItem {
|
|
return TelemetryItem{
|
|
Key: item.Key,
|
|
Value: item.Value,
|
|
CreatedAt: item.CreatedAt,
|
|
UpdatedAt: item.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
// Snapshot represents a point-in-time anonymized database dump.
|
|
// Data is aggregated by latest on the server-side, so partial data
|
|
// can be sent without issue.
|
|
type Snapshot struct {
|
|
DeploymentID string `json:"deployment_id"`
|
|
|
|
APIKeys []APIKey `json:"api_keys"`
|
|
CLIInvocations []clitelemetry.Invocation `json:"cli_invocations"`
|
|
ExternalProvisioners []ExternalProvisioner `json:"external_provisioners"`
|
|
Licenses []License `json:"licenses"`
|
|
ProvisionerJobs []ProvisionerJob `json:"provisioner_jobs"`
|
|
TemplateVersions []TemplateVersion `json:"template_versions"`
|
|
Templates []Template `json:"templates"`
|
|
Users []User `json:"users"`
|
|
Groups []Group `json:"groups"`
|
|
GroupMembers []GroupMember `json:"group_members"`
|
|
WorkspaceAgentStats []WorkspaceAgentStat `json:"workspace_agent_stats"`
|
|
WorkspaceAgents []WorkspaceAgent `json:"workspace_agents"`
|
|
WorkspaceApps []WorkspaceApp `json:"workspace_apps"`
|
|
WorkspaceBuilds []WorkspaceBuild `json:"workspace_build"`
|
|
WorkspaceProxies []WorkspaceProxy `json:"workspace_proxies"`
|
|
WorkspaceResourceMetadata []WorkspaceResourceMetadata `json:"workspace_resource_metadata"`
|
|
WorkspaceResources []WorkspaceResource `json:"workspace_resources"`
|
|
WorkspaceAgentMemoryResourceMonitors []WorkspaceAgentMemoryResourceMonitor `json:"workspace_agent_memory_resource_monitors"`
|
|
WorkspaceAgentVolumeResourceMonitors []WorkspaceAgentVolumeResourceMonitor `json:"workspace_agent_volume_resource_monitors"`
|
|
WorkspaceModules []WorkspaceModule `json:"workspace_modules"`
|
|
Workspaces []Workspace `json:"workspaces"`
|
|
NetworkEvents []NetworkEvent `json:"network_events"`
|
|
Organizations []Organization `json:"organizations"`
|
|
Tasks []Task `json:"tasks"`
|
|
TaskEvents []TaskEvent `json:"task_events"`
|
|
TelemetryItems []TelemetryItem `json:"telemetry_items"`
|
|
UserTailnetConnections []UserTailnetConnection `json:"user_tailnet_connections"`
|
|
PrebuiltWorkspaces []PrebuiltWorkspace `json:"prebuilt_workspaces"`
|
|
AIBridgeInterceptionsSummaries []AIBridgeInterceptionsSummary `json:"aibridge_interceptions_summaries"`
|
|
BoundaryUsageSummary *BoundaryUsageSummary `json:"boundary_usage_summary"`
|
|
FirstUserOnboarding *FirstUserOnboarding `json:"first_user_onboarding"`
|
|
Chats []Chat `json:"chats"`
|
|
ChatMessageSummaries []ChatMessageSummary `json:"chat_message_summaries"`
|
|
ChatModelConfigs []ChatModelConfig `json:"chat_model_configs"`
|
|
ChatDiffStatusSummary *ChatDiffStatusSummary `json:"chat_diff_status_summary"`
|
|
UserSecretsSummary *UserSecretsSummary `json:"user_secrets_summary"`
|
|
TemplateBuilderSessions []TemplateBuilderSession `json:"template_builder_sessions"`
|
|
}
|
|
|
|
// Deployment contains information about the host running Coder.
|
|
type Deployment struct {
|
|
ID string `json:"id"`
|
|
Architecture string `json:"architecture"`
|
|
BuiltinPostgres bool `json:"builtin_postgres"`
|
|
Containerized bool `json:"containerized"`
|
|
Kubernetes bool `json:"kubernetes"`
|
|
Config *codersdk.DeploymentValues `json:"config"`
|
|
Tunnel bool `json:"tunnel"`
|
|
InstallSource string `json:"install_source"`
|
|
OSType string `json:"os_type"`
|
|
OSFamily string `json:"os_family"`
|
|
OSPlatform string `json:"os_platform"`
|
|
OSName string `json:"os_name"`
|
|
OSVersion string `json:"os_version"`
|
|
CPUCores int `json:"cpu_cores"`
|
|
MemoryTotal uint64 `json:"memory_total"`
|
|
MachineID string `json:"machine_id"`
|
|
StartedAt time.Time `json:"started_at"`
|
|
ShutdownAt *time.Time `json:"shutdown_at"`
|
|
// While IDPOrgSync will always be set, it's nullable to make
|
|
// the struct backwards compatible with older coder versions.
|
|
IDPOrgSync *bool `json:"idp_org_sync"`
|
|
}
|
|
|
|
type APIKey struct {
|
|
ID string `json:"id"`
|
|
UserID uuid.UUID `json:"user_id"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
LastUsed time.Time `json:"last_used"`
|
|
LoginType database.LoginType `json:"login_type"`
|
|
IPAddress net.IP `json:"ip_address"`
|
|
}
|
|
|
|
type User struct {
|
|
ID uuid.UUID `json:"id"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
// Email is only filled in for the first/admin user!
|
|
Email *string `json:"email"`
|
|
EmailHashed string `json:"email_hashed"`
|
|
RBACRoles []string `json:"rbac_roles"`
|
|
Status database.UserStatus `json:"status"`
|
|
GithubComUserID int64 `json:"github_com_user_id"`
|
|
// Omitempty for backwards compatibility.
|
|
LoginType string `json:"login_type,omitempty"`
|
|
}
|
|
|
|
// FirstUserOnboarding contains optional newsletter preference data
|
|
// collected during first user setup. This is sent once when the first
|
|
// user is created.
|
|
type FirstUserOnboarding struct {
|
|
NewsletterMarketing bool `json:"newsletter_marketing"`
|
|
NewsletterReleases bool `json:"newsletter_releases"`
|
|
}
|
|
|
|
type Group struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Name string `json:"name"`
|
|
OrganizationID uuid.UUID `json:"organization_id"`
|
|
AvatarURL string `json:"avatar_url"`
|
|
QuotaAllowance int32 `json:"quota_allowance"`
|
|
DisplayName string `json:"display_name"`
|
|
Source database.GroupSource `json:"source"`
|
|
}
|
|
|
|
type GroupMember struct {
|
|
UserID uuid.UUID `json:"user_id"`
|
|
GroupID uuid.UUID `json:"group_id"`
|
|
}
|
|
|
|
type WorkspaceResource struct {
|
|
ID uuid.UUID `json:"id"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
JobID uuid.UUID `json:"job_id"`
|
|
Transition database.WorkspaceTransition `json:"transition"`
|
|
Type string `json:"type"`
|
|
InstanceType string `json:"instance_type"`
|
|
// ModulePath is nullable because it was added a long time after the
|
|
// original workspace resource telemetry was added. All new resources
|
|
// will have a module path, but deployments with older resources still
|
|
// in the database will not.
|
|
ModulePath *string `json:"module_path"`
|
|
}
|
|
|
|
type WorkspaceResourceMetadata struct {
|
|
ResourceID uuid.UUID `json:"resource_id"`
|
|
Key string `json:"key"`
|
|
Sensitive bool `json:"sensitive"`
|
|
}
|
|
|
|
type WorkspaceModule struct {
|
|
ID uuid.UUID `json:"id"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
JobID uuid.UUID `json:"job_id"`
|
|
Transition database.WorkspaceTransition `json:"transition"`
|
|
Key string `json:"key"`
|
|
Version string `json:"version"`
|
|
Source string `json:"source"`
|
|
SourceType ModuleSourceType `json:"source_type"`
|
|
}
|
|
|
|
type WorkspaceAgent struct {
|
|
ID uuid.UUID `json:"id"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
ResourceID uuid.UUID `json:"resource_id"`
|
|
InstanceAuth bool `json:"instance_auth"`
|
|
Architecture string `json:"architecture"`
|
|
OperatingSystem string `json:"operating_system"`
|
|
EnvironmentVariables bool `json:"environment_variables"`
|
|
Directory bool `json:"directory"`
|
|
FirstConnectedAt *time.Time `json:"first_connected_at"`
|
|
LastConnectedAt *time.Time `json:"last_connected_at"`
|
|
DisconnectedAt *time.Time `json:"disconnected_at"`
|
|
ConnectionTimeoutSeconds int32 `json:"connection_timeout_seconds"`
|
|
Subsystems []string `json:"subsystems"`
|
|
}
|
|
|
|
type WorkspaceAgentStat struct {
|
|
UserID uuid.UUID `json:"user_id"`
|
|
TemplateID uuid.UUID `json:"template_id"`
|
|
WorkspaceID uuid.UUID `json:"workspace_id"`
|
|
AggregatedFrom time.Time `json:"aggregated_from"`
|
|
AgentID uuid.UUID `json:"agent_id"`
|
|
RxBytes int64 `json:"rx_bytes"`
|
|
TxBytes int64 `json:"tx_bytes"`
|
|
ConnectionLatency50 float64 `json:"connection_latency_50"`
|
|
ConnectionLatency95 float64 `json:"connection_latency_95"`
|
|
SessionCountVSCode int64 `json:"session_count_vscode"`
|
|
SessionCountJetBrains int64 `json:"session_count_jetbrains"`
|
|
SessionCountReconnectingPTY int64 `json:"session_count_reconnecting_pty"`
|
|
SessionCountSSH int64 `json:"session_count_ssh"`
|
|
}
|
|
|
|
type WorkspaceAgentMemoryResourceMonitor struct {
|
|
AgentID uuid.UUID `json:"agent_id"`
|
|
Enabled bool `json:"enabled"`
|
|
Threshold int32 `json:"threshold"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type WorkspaceAgentVolumeResourceMonitor struct {
|
|
AgentID uuid.UUID `json:"agent_id"`
|
|
Enabled bool `json:"enabled"`
|
|
Threshold int32 `json:"threshold"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type WorkspaceApp struct {
|
|
ID uuid.UUID `json:"id"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
AgentID uuid.UUID `json:"agent_id"`
|
|
Icon string `json:"icon"`
|
|
Subdomain bool `json:"subdomain"`
|
|
}
|
|
|
|
type WorkspaceBuild struct {
|
|
ID uuid.UUID `json:"id"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
WorkspaceID uuid.UUID `json:"workspace_id"`
|
|
TemplateVersionID uuid.UUID `json:"template_version_id"`
|
|
JobID uuid.UUID `json:"job_id"`
|
|
BuildNumber uint32 `json:"build_number"`
|
|
HasAITask *bool `json:"has_ai_task"`
|
|
}
|
|
|
|
type Workspace struct {
|
|
ID uuid.UUID `json:"id"`
|
|
OrganizationID uuid.UUID `json:"organization_id"`
|
|
OwnerID uuid.UUID `json:"owner_id"`
|
|
TemplateID uuid.UUID `json:"template_id"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
Deleted bool `json:"deleted"`
|
|
Name string `json:"name"`
|
|
AutostartSchedule string `json:"autostart_schedule"`
|
|
AutomaticUpdates string `json:"automatic_updates"`
|
|
}
|
|
|
|
type Template struct {
|
|
ID uuid.UUID `json:"id"`
|
|
CreatedBy uuid.UUID `json:"created_by"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
OrganizationID uuid.UUID `json:"organization_id"`
|
|
Deleted bool `json:"deleted"`
|
|
ActiveVersionID uuid.UUID `json:"active_version_id"`
|
|
Name string `json:"name"`
|
|
Description bool `json:"description"`
|
|
|
|
DefaultTTLMillis int64 `json:"default_ttl_ms"`
|
|
AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs"`
|
|
AllowUserAutostart bool `json:"allow_user_autostart"`
|
|
AllowUserAutostop bool `json:"allow_user_autostop"`
|
|
FailureTTLMillis int64 `json:"failure_ttl_ms"`
|
|
TimeTilDormantMillis int64 `json:"time_til_dormant_ms"`
|
|
TimeTilDormantAutoDeleteMillis int64 `json:"time_til_dormant_auto_delete_ms"`
|
|
AutostopRequirementDaysOfWeek []string `json:"autostop_requirement_days_of_week"`
|
|
AutostopRequirementWeeks int64 `json:"autostop_requirement_weeks"`
|
|
AutostartAllowedDays []string `json:"autostart_allowed_days"`
|
|
RequireActiveVersion bool `json:"require_active_version"`
|
|
Deprecated bool `json:"deprecated"`
|
|
UseClassicParameterFlow *bool `json:"use_classic_parameter_flow"`
|
|
}
|
|
|
|
type TemplateVersion struct {
|
|
ID uuid.UUID `json:"id"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
TemplateID *uuid.UUID `json:"template_id,omitempty"`
|
|
OrganizationID uuid.UUID `json:"organization_id"`
|
|
JobID uuid.UUID `json:"job_id"`
|
|
SourceExampleID *string `json:"source_example_id,omitempty"`
|
|
HasAITask *bool `json:"has_ai_task"`
|
|
}
|
|
|
|
type ProvisionerJob struct {
|
|
ID uuid.UUID `json:"id"`
|
|
OrganizationID uuid.UUID `json:"organization_id"`
|
|
InitiatorID uuid.UUID `json:"initiator_id"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
StartedAt *time.Time `json:"started_at,omitempty"`
|
|
CanceledAt *time.Time `json:"canceled_at,omitempty"`
|
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
|
Error string `json:"error"`
|
|
Type database.ProvisionerJobType `json:"type"`
|
|
}
|
|
|
|
type License struct {
|
|
JWT string `json:"jwt"`
|
|
UploadedAt time.Time `json:"uploaded_at"`
|
|
Exp time.Time `json:"exp"`
|
|
UUID uuid.UUID `json:"uuid"`
|
|
// These two fields are set by decoding the JWT. If the signing keys aren't
|
|
// passed in, these will always be nil.
|
|
Email *string `json:"email"`
|
|
Trial *bool `json:"trial"`
|
|
}
|
|
|
|
type WorkspaceProxy struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Name string `json:"name"`
|
|
DisplayName string `json:"display_name"`
|
|
// No URLs since we don't send deployment URL.
|
|
DerpEnabled bool `json:"derp_enabled"`
|
|
DerpOnly bool `json:"derp_only"`
|
|
// No Status since it may contain sensitive information.
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type ExternalProvisioner struct {
|
|
ID string `json:"id"`
|
|
Tags map[string]string `json:"tags"`
|
|
Provisioners []string `json:"provisioners"`
|
|
StartedAt time.Time `json:"started_at"`
|
|
ShutdownAt *time.Time `json:"shutdown_at"`
|
|
}
|
|
|
|
type NetworkEventIPFields struct {
|
|
Version int32 `json:"version"` // 4 or 6
|
|
Class string `json:"class"` // public, private, link_local, unique_local, loopback
|
|
}
|
|
|
|
func ipFieldsFromProto(proto *tailnetproto.IPFields) NetworkEventIPFields {
|
|
if proto == nil {
|
|
return NetworkEventIPFields{}
|
|
}
|
|
return NetworkEventIPFields{
|
|
Version: proto.Version,
|
|
Class: strings.ToLower(proto.Class.String()),
|
|
}
|
|
}
|
|
|
|
type NetworkEventP2PEndpoint struct {
|
|
Hash string `json:"hash"`
|
|
Port int `json:"port"`
|
|
Fields NetworkEventIPFields `json:"fields"`
|
|
}
|
|
|
|
func p2pEndpointFromProto(proto *tailnetproto.TelemetryEvent_P2PEndpoint) NetworkEventP2PEndpoint {
|
|
if proto == nil {
|
|
return NetworkEventP2PEndpoint{}
|
|
}
|
|
return NetworkEventP2PEndpoint{
|
|
Hash: proto.Hash,
|
|
Port: int(proto.Port),
|
|
Fields: ipFieldsFromProto(proto.Fields),
|
|
}
|
|
}
|
|
|
|
type DERPMapHomeParams struct {
|
|
RegionScore map[int64]float64 `json:"region_score"`
|
|
}
|
|
|
|
func derpMapHomeParamsFromProto(proto *tailnetproto.DERPMap_HomeParams) DERPMapHomeParams {
|
|
if proto == nil {
|
|
return DERPMapHomeParams{}
|
|
}
|
|
out := DERPMapHomeParams{
|
|
RegionScore: make(map[int64]float64, len(proto.RegionScore)),
|
|
}
|
|
for k, v := range proto.RegionScore {
|
|
out.RegionScore[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
type DERPRegion struct {
|
|
RegionID int64 `json:"region_id"`
|
|
EmbeddedRelay bool `json:"embedded_relay"`
|
|
RegionCode string
|
|
RegionName string
|
|
Avoid bool
|
|
Nodes []DERPNode `json:"nodes"`
|
|
}
|
|
|
|
func derpRegionFromProto(proto *tailnetproto.DERPMap_Region) DERPRegion {
|
|
if proto == nil {
|
|
return DERPRegion{}
|
|
}
|
|
nodes := make([]DERPNode, 0, len(proto.Nodes))
|
|
for _, node := range proto.Nodes {
|
|
nodes = append(nodes, derpNodeFromProto(node))
|
|
}
|
|
return DERPRegion{
|
|
RegionID: proto.RegionId,
|
|
EmbeddedRelay: proto.EmbeddedRelay,
|
|
RegionCode: proto.RegionCode,
|
|
RegionName: proto.RegionName,
|
|
Avoid: proto.Avoid,
|
|
Nodes: nodes,
|
|
}
|
|
}
|
|
|
|
type DERPNode struct {
|
|
Name string `json:"name"`
|
|
RegionID int64 `json:"region_id"`
|
|
HostName string `json:"host_name"`
|
|
CertName string `json:"cert_name"`
|
|
IPv4 string `json:"ipv4"`
|
|
IPv6 string `json:"ipv6"`
|
|
STUNPort int32 `json:"stun_port"`
|
|
STUNOnly bool `json:"stun_only"`
|
|
DERPPort int32 `json:"derp_port"`
|
|
InsecureForTests bool `json:"insecure_for_tests"`
|
|
ForceHTTP bool `json:"force_http"`
|
|
STUNTestIP string `json:"stun_test_ip"`
|
|
CanPort80 bool `json:"can_port_80"`
|
|
}
|
|
|
|
func derpNodeFromProto(proto *tailnetproto.DERPMap_Region_Node) DERPNode {
|
|
if proto == nil {
|
|
return DERPNode{}
|
|
}
|
|
return DERPNode{
|
|
Name: proto.Name,
|
|
RegionID: proto.RegionId,
|
|
HostName: proto.HostName,
|
|
CertName: proto.CertName,
|
|
IPv4: proto.Ipv4,
|
|
IPv6: proto.Ipv6,
|
|
STUNPort: proto.StunPort,
|
|
STUNOnly: proto.StunOnly,
|
|
DERPPort: proto.DerpPort,
|
|
InsecureForTests: proto.InsecureForTests,
|
|
ForceHTTP: proto.ForceHttp,
|
|
STUNTestIP: proto.StunTestIp,
|
|
CanPort80: proto.CanPort_80,
|
|
}
|
|
}
|
|
|
|
type DERPMap struct {
|
|
HomeParams DERPMapHomeParams `json:"home_params"`
|
|
Regions map[int64]DERPRegion
|
|
}
|
|
|
|
func derpMapFromProto(proto *tailnetproto.DERPMap) DERPMap {
|
|
if proto == nil {
|
|
return DERPMap{}
|
|
}
|
|
regionMap := make(map[int64]DERPRegion, len(proto.Regions))
|
|
for k, v := range proto.Regions {
|
|
regionMap[k] = derpRegionFromProto(v)
|
|
}
|
|
return DERPMap{
|
|
HomeParams: derpMapHomeParamsFromProto(proto.HomeParams),
|
|
Regions: regionMap,
|
|
}
|
|
}
|
|
|
|
type NetcheckIP struct {
|
|
Hash string `json:"hash"`
|
|
Fields NetworkEventIPFields `json:"fields"`
|
|
}
|
|
|
|
func netcheckIPFromProto(proto *tailnetproto.Netcheck_NetcheckIP) NetcheckIP {
|
|
if proto == nil {
|
|
return NetcheckIP{}
|
|
}
|
|
return NetcheckIP{
|
|
Hash: proto.Hash,
|
|
Fields: ipFieldsFromProto(proto.Fields),
|
|
}
|
|
}
|
|
|
|
type Netcheck struct {
|
|
UDP bool `json:"udp"`
|
|
IPv6 bool `json:"ipv6"`
|
|
IPv4 bool `json:"ipv4"`
|
|
IPv6CanSend bool `json:"ipv6_can_send"`
|
|
IPv4CanSend bool `json:"ipv4_can_send"`
|
|
ICMPv4 bool `json:"icmpv4"`
|
|
|
|
OSHasIPv6 *bool `json:"os_has_ipv6"`
|
|
MappingVariesByDestIP *bool `json:"mapping_varies_by_dest_ip"`
|
|
HairPinning *bool `json:"hair_pinning"`
|
|
UPnP *bool `json:"upnp"`
|
|
PMP *bool `json:"pmp"`
|
|
PCP *bool `json:"pcp"`
|
|
|
|
PreferredDERP int64 `json:"preferred_derp"`
|
|
|
|
RegionV4Latency map[int64]time.Duration `json:"region_v4_latency"`
|
|
RegionV6Latency map[int64]time.Duration `json:"region_v6_latency"`
|
|
|
|
GlobalV4 NetcheckIP `json:"global_v4"`
|
|
GlobalV6 NetcheckIP `json:"global_v6"`
|
|
}
|
|
|
|
func protoBool(b *wrapperspb.BoolValue) *bool {
|
|
if b == nil {
|
|
return nil
|
|
}
|
|
return &b.Value
|
|
}
|
|
|
|
func netcheckFromProto(proto *tailnetproto.Netcheck) Netcheck {
|
|
if proto == nil {
|
|
return Netcheck{}
|
|
}
|
|
|
|
durationMapFromProto := func(m map[int64]*durationpb.Duration) map[int64]time.Duration {
|
|
out := make(map[int64]time.Duration, len(m))
|
|
for k, v := range m {
|
|
out[k] = v.AsDuration()
|
|
}
|
|
return out
|
|
}
|
|
|
|
return Netcheck{
|
|
UDP: proto.UDP,
|
|
IPv6: proto.IPv6,
|
|
IPv4: proto.IPv4,
|
|
IPv6CanSend: proto.IPv6CanSend,
|
|
IPv4CanSend: proto.IPv4CanSend,
|
|
ICMPv4: proto.ICMPv4,
|
|
|
|
OSHasIPv6: protoBool(proto.OSHasIPv6),
|
|
MappingVariesByDestIP: protoBool(proto.MappingVariesByDestIP),
|
|
HairPinning: protoBool(proto.HairPinning),
|
|
UPnP: protoBool(proto.UPnP),
|
|
PMP: protoBool(proto.PMP),
|
|
PCP: protoBool(proto.PCP),
|
|
|
|
PreferredDERP: proto.PreferredDERP,
|
|
|
|
RegionV4Latency: durationMapFromProto(proto.RegionV4Latency),
|
|
RegionV6Latency: durationMapFromProto(proto.RegionV6Latency),
|
|
|
|
GlobalV4: netcheckIPFromProto(proto.GlobalV4),
|
|
GlobalV6: netcheckIPFromProto(proto.GlobalV6),
|
|
}
|
|
}
|
|
|
|
// NetworkEvent and all related structs come from tailnet.proto.
|
|
type NetworkEvent struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Time time.Time `json:"time"`
|
|
Application string `json:"application"`
|
|
Status string `json:"status"` // connected, disconnected
|
|
ClientType string `json:"client_type"` // cli, agent, coderd, wsproxy
|
|
ClientVersion string `json:"client_version"`
|
|
NodeIDSelf uint64 `json:"node_id_self"`
|
|
NodeIDRemote uint64 `json:"node_id_remote"`
|
|
P2PEndpoint NetworkEventP2PEndpoint `json:"p2p_endpoint"`
|
|
HomeDERP int `json:"home_derp"`
|
|
DERPMap DERPMap `json:"derp_map"`
|
|
LatestNetcheck Netcheck `json:"latest_netcheck"`
|
|
|
|
ConnectionAge *time.Duration `json:"connection_age"`
|
|
ConnectionSetup *time.Duration `json:"connection_setup"`
|
|
P2PSetup *time.Duration `json:"p2p_setup"`
|
|
DERPLatency *time.Duration `json:"derp_latency"`
|
|
P2PLatency *time.Duration `json:"p2p_latency"`
|
|
ThroughputMbits *float32 `json:"throughput_mbits"`
|
|
}
|
|
|
|
func protoFloat(f *wrapperspb.FloatValue) *float32 {
|
|
if f == nil {
|
|
return nil
|
|
}
|
|
return &f.Value
|
|
}
|
|
|
|
func protoDurationNil(d *durationpb.Duration) *time.Duration {
|
|
if d == nil {
|
|
return nil
|
|
}
|
|
dur := d.AsDuration()
|
|
return &dur
|
|
}
|
|
|
|
func NetworkEventFromProto(proto *tailnetproto.TelemetryEvent) (NetworkEvent, error) {
|
|
if proto == nil {
|
|
return NetworkEvent{}, xerrors.New("nil event")
|
|
}
|
|
id, err := uuid.FromBytes(proto.Id)
|
|
if err != nil {
|
|
return NetworkEvent{}, xerrors.Errorf("parse id %q: %w", proto.Id, err)
|
|
}
|
|
|
|
return NetworkEvent{
|
|
ID: id,
|
|
Time: proto.Time.AsTime(),
|
|
Application: proto.Application,
|
|
Status: strings.ToLower(proto.Status.String()),
|
|
ClientType: strings.ToLower(proto.ClientType.String()),
|
|
ClientVersion: proto.ClientVersion,
|
|
NodeIDSelf: proto.NodeIdSelf,
|
|
NodeIDRemote: proto.NodeIdRemote,
|
|
P2PEndpoint: p2pEndpointFromProto(proto.P2PEndpoint),
|
|
HomeDERP: int(proto.HomeDerp),
|
|
DERPMap: derpMapFromProto(proto.DerpMap),
|
|
LatestNetcheck: netcheckFromProto(proto.LatestNetcheck),
|
|
|
|
ConnectionAge: protoDurationNil(proto.ConnectionAge),
|
|
ConnectionSetup: protoDurationNil(proto.ConnectionSetup),
|
|
P2PSetup: protoDurationNil(proto.P2PSetup),
|
|
DERPLatency: protoDurationNil(proto.DerpLatency),
|
|
P2PLatency: protoDurationNil(proto.P2PLatency),
|
|
ThroughputMbits: protoFloat(proto.ThroughputMbits),
|
|
}, nil
|
|
}
|
|
|
|
type Organization struct {
|
|
ID uuid.UUID `json:"id"`
|
|
IsDefault bool `json:"is_default"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
type Task struct {
|
|
ID string `json:"id"`
|
|
OrganizationID string `json:"organization_id"`
|
|
OwnerID string `json:"owner_id"`
|
|
Name string `json:"name"`
|
|
WorkspaceID *string `json:"workspace_id"`
|
|
WorkspaceBuildNumber *int64 `json:"workspace_build_number"`
|
|
WorkspaceAgentID *string `json:"workspace_agent_id"`
|
|
WorkspaceAppID *string `json:"workspace_app_id"`
|
|
TemplateVersionID string `json:"template_version_id"`
|
|
PromptHash string `json:"prompt_hash"` // Prompt is hashed for privacy.
|
|
Status string `json:"status"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// TaskEvent represents lifecycle events for a task (pause/resume
|
|
// cycles). The createdAfter parameter gates PausedDurationMS so
|
|
// that only recent pause/resume pairs are reported.
|
|
type TaskEvent struct {
|
|
TaskID string `json:"task_id"`
|
|
LastPausedAt *time.Time `json:"last_paused_at"`
|
|
LastResumedAt *time.Time `json:"last_resumed_at"`
|
|
PauseReason *string `json:"pause_reason"`
|
|
ResumeReason *string `json:"resume_reason"`
|
|
IdleDurationMS *int64 `json:"idle_duration_ms"`
|
|
PausedDurationMS *int64 `json:"paused_duration_ms"`
|
|
ResumeToStatusMS *int64 `json:"resume_to_status_ms"`
|
|
ActiveDurationMS *int64 `json:"active_duration_ms"`
|
|
}
|
|
|
|
// ConvertTask converts a database Task to a telemetry Task.
|
|
func ConvertTask(task database.Task) Task {
|
|
t := Task{
|
|
ID: task.ID.String(),
|
|
OrganizationID: task.OrganizationID.String(),
|
|
OwnerID: task.OwnerID.String(),
|
|
Name: task.Name,
|
|
TemplateVersionID: task.TemplateVersionID.String(),
|
|
PromptHash: HashContent(task.Prompt),
|
|
Status: string(task.Status),
|
|
CreatedAt: task.CreatedAt,
|
|
}
|
|
if task.WorkspaceID.Valid {
|
|
t.WorkspaceID = ptr.Ref(task.WorkspaceID.UUID.String())
|
|
}
|
|
if task.WorkspaceBuildNumber.Valid {
|
|
t.WorkspaceBuildNumber = ptr.Ref(int64(task.WorkspaceBuildNumber.Int32))
|
|
}
|
|
if task.WorkspaceAgentID.Valid {
|
|
t.WorkspaceAgentID = ptr.Ref(task.WorkspaceAgentID.UUID.String())
|
|
}
|
|
if task.WorkspaceAppID.Valid {
|
|
t.WorkspaceAppID = ptr.Ref(task.WorkspaceAppID.UUID.String())
|
|
}
|
|
return t
|
|
}
|
|
|
|
// ConvertChat converts a database chat row to a telemetry Chat.
|
|
func ConvertChat(dbChat database.GetChatsUpdatedAfterRow) Chat {
|
|
c := Chat{
|
|
ID: dbChat.ID,
|
|
OwnerID: dbChat.OwnerID,
|
|
CreatedAt: dbChat.CreatedAt,
|
|
UpdatedAt: dbChat.UpdatedAt,
|
|
Status: string(dbChat.Status),
|
|
HasParent: dbChat.HasParent,
|
|
Archived: dbChat.Archived,
|
|
LastModelConfigID: dbChat.LastModelConfigID,
|
|
}
|
|
if dbChat.RootChatID.Valid {
|
|
c.RootChatID = &dbChat.RootChatID.UUID
|
|
}
|
|
if dbChat.WorkspaceID.Valid {
|
|
c.WorkspaceID = &dbChat.WorkspaceID.UUID
|
|
}
|
|
if dbChat.Mode.Valid {
|
|
mode := string(dbChat.Mode.ChatMode)
|
|
c.Mode = &mode
|
|
}
|
|
c.ClientType = string(dbChat.ClientType)
|
|
if dbChat.PullRequestState.Valid {
|
|
c.PullRequestState = &dbChat.PullRequestState.String
|
|
}
|
|
return c
|
|
}
|
|
|
|
// ConvertChatMessageSummary converts a database chat message
|
|
// summary row to a telemetry ChatMessageSummary.
|
|
func ConvertChatMessageSummary(dbRow database.GetChatMessageSummariesPerChatRow) ChatMessageSummary {
|
|
return ChatMessageSummary{
|
|
ChatID: dbRow.ChatID,
|
|
MessageCount: dbRow.MessageCount,
|
|
UserMessageCount: dbRow.UserMessageCount,
|
|
AssistantMessageCount: dbRow.AssistantMessageCount,
|
|
ToolMessageCount: dbRow.ToolMessageCount,
|
|
SystemMessageCount: dbRow.SystemMessageCount,
|
|
TotalInputTokens: dbRow.TotalInputTokens,
|
|
TotalOutputTokens: dbRow.TotalOutputTokens,
|
|
TotalReasoningTokens: dbRow.TotalReasoningTokens,
|
|
TotalCacheCreationTokens: dbRow.TotalCacheCreationTokens,
|
|
TotalCacheReadTokens: dbRow.TotalCacheReadTokens,
|
|
TotalCostMicros: dbRow.TotalCostMicros,
|
|
TotalRuntimeMs: dbRow.TotalRuntimeMs,
|
|
DistinctModelCount: dbRow.DistinctModelCount,
|
|
CompressedMessageCount: dbRow.CompressedMessageCount,
|
|
}
|
|
}
|
|
|
|
// ConvertChatModelConfig converts a database model config row to a
|
|
// telemetry ChatModelConfig.
|
|
func ConvertChatModelConfig(dbRow database.GetChatModelConfigsForTelemetryRow) ChatModelConfig {
|
|
return ChatModelConfig{
|
|
ID: dbRow.ID,
|
|
Provider: dbRow.Provider,
|
|
Model: dbRow.Model,
|
|
ContextLimit: dbRow.ContextLimit,
|
|
Enabled: dbRow.Enabled,
|
|
IsDefault: dbRow.IsDefault,
|
|
}
|
|
}
|
|
|
|
type telemetryItemKey string
|
|
|
|
// The comment below gets rid of the warning that the name "TelemetryItemKey" has
|
|
// the "Telemetry" prefix, and that stutters when you use it outside the package
|
|
// (telemetry.TelemetryItemKey...). "TelemetryItem" is the name of a database table,
|
|
// so it makes sense to use the "Telemetry" prefix.
|
|
//
|
|
//revive:disable:exported
|
|
const (
|
|
TelemetryItemKeyHTMLFirstServedAt telemetryItemKey = "html_first_served_at"
|
|
TelemetryItemKeyTelemetryEnabled telemetryItemKey = "telemetry_enabled"
|
|
)
|
|
|
|
type TelemetryItem struct {
|
|
Key string `json:"key"`
|
|
Value string `json:"value"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type UserTailnetConnection struct {
|
|
ConnectedAt time.Time `json:"connected_at"`
|
|
DisconnectedAt *time.Time `json:"disconnected_at"`
|
|
UserID string `json:"user_id"`
|
|
PeerID string `json:"peer_id"`
|
|
DeviceID *string `json:"device_id"`
|
|
DeviceOS *string `json:"device_os"`
|
|
CoderDesktopVersion *string `json:"coder_desktop_version"`
|
|
}
|
|
|
|
type PrebuiltWorkspaceEventType string
|
|
|
|
const (
|
|
PrebuiltWorkspaceEventTypeCreated PrebuiltWorkspaceEventType = "created"
|
|
PrebuiltWorkspaceEventTypeFailed PrebuiltWorkspaceEventType = "failed"
|
|
PrebuiltWorkspaceEventTypeClaimed PrebuiltWorkspaceEventType = "claimed"
|
|
)
|
|
|
|
type PrebuiltWorkspace struct {
|
|
ID uuid.UUID `json:"id"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
EventType PrebuiltWorkspaceEventType `json:"event_type"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
type AIBridgeInterceptionsSummaryDurationMillis struct {
|
|
P50 int64 `json:"p50"`
|
|
P90 int64 `json:"p90"`
|
|
P95 int64 `json:"p95"`
|
|
P99 int64 `json:"p99"`
|
|
}
|
|
|
|
type AIBridgeInterceptionsSummaryTokenCount struct {
|
|
Input int64 `json:"input"`
|
|
Output int64 `json:"output"`
|
|
CachedRead int64 `json:"cached_read"`
|
|
CachedWritten int64 `json:"cached_written"`
|
|
}
|
|
|
|
type AIBridgeInterceptionsSummaryToolCallsCount struct {
|
|
Injected int64 `json:"injected"`
|
|
NonInjected int64 `json:"non_injected"`
|
|
}
|
|
|
|
// AIBridgeInterceptionsSummary is a summary of aggregated AI Bridge
|
|
// interception data over a period of 1 hour. We send a summary each hour for
|
|
// each unique provider + model + client combination.
|
|
type AIBridgeInterceptionsSummary struct {
|
|
ID uuid.UUID `json:"id"`
|
|
|
|
// The end of the hour for which the summary is taken. This will always be a
|
|
// UTC timestamp truncated to the hour.
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Provider string `json:"provider"`
|
|
Model string `json:"model"`
|
|
Client string `json:"client"`
|
|
|
|
InterceptionCount int64 `json:"interception_count"`
|
|
InterceptionDurationMillis AIBridgeInterceptionsSummaryDurationMillis `json:"interception_duration_millis"`
|
|
|
|
// Map of route to number of interceptions.
|
|
// e.g. "/v1/chat/completions:blocking", "/v1/chat/completions:streaming"
|
|
InterceptionsByRoute map[string]int64 `json:"interceptions_by_route"`
|
|
|
|
UniqueInitiatorCount int64 `json:"unique_initiator_count"`
|
|
|
|
UserPromptsCount int64 `json:"user_prompts_count"`
|
|
|
|
TokenUsagesCount int64 `json:"token_usages_count"`
|
|
TokenCount AIBridgeInterceptionsSummaryTokenCount `json:"token_count"`
|
|
|
|
ToolCallsCount AIBridgeInterceptionsSummaryToolCallsCount `json:"tool_calls_count"`
|
|
InjectedToolCallErrorCount int64 `json:"injected_tool_call_error_count"`
|
|
}
|
|
|
|
// BoundaryUsageSummary contains aggregated boundary usage statistics across all
|
|
// replicas for the telemetry period. See the boundaryusage package documentation
|
|
// for the full tracking architecture.
|
|
type BoundaryUsageSummary struct {
|
|
UniqueWorkspaces int64 `json:"unique_workspaces"`
|
|
UniqueUsers int64 `json:"unique_users"`
|
|
AllowedRequests int64 `json:"allowed_requests"`
|
|
DeniedRequests int64 `json:"denied_requests"`
|
|
|
|
// PeriodStart and PeriodDurationMilliseconds describe the approximate collection
|
|
// window. The actual data may not align *exactly* to these boundaries because:
|
|
//
|
|
// - Each replica flushes to the database independently on its own schedule
|
|
// - The summary captures "data flushed since last reset" rather than "usage
|
|
// during exactly the stated interval"
|
|
// - Unflushed in-memory data at snapshot time rolls into the next period
|
|
//
|
|
// This is adequate for our purposes of gathering general usage and trends.
|
|
//
|
|
// PeriodStart is the approximate start of the collection period.
|
|
PeriodStart time.Time `json:"period_start"`
|
|
// PeriodDurationMilliseconds is the expected duration of the collection
|
|
// period (the telemetry snapshot frequency).
|
|
PeriodDurationMilliseconds int64 `json:"period_duration_ms"`
|
|
}
|
|
|
|
// Chat contains anonymized metadata about a chat for telemetry.
|
|
// Titles and message content are excluded to avoid PII leakage.
|
|
type Chat struct {
|
|
ID uuid.UUID `json:"id"`
|
|
OwnerID uuid.UUID `json:"owner_id"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
Status string `json:"status"`
|
|
HasParent bool `json:"has_parent"`
|
|
RootChatID *uuid.UUID `json:"root_chat_id"`
|
|
WorkspaceID *uuid.UUID `json:"workspace_id"`
|
|
Mode *string `json:"mode"`
|
|
Archived bool `json:"archived"`
|
|
LastModelConfigID uuid.UUID `json:"last_model_config_id"`
|
|
ClientType string `json:"client_type"`
|
|
PullRequestState *string `json:"pull_request_state"`
|
|
}
|
|
|
|
// ChatMessageSummary contains per-chat aggregated message metrics
|
|
// for telemetry. Individual message content is never included.
|
|
type ChatMessageSummary struct {
|
|
ChatID uuid.UUID `json:"chat_id"`
|
|
MessageCount int64 `json:"message_count"`
|
|
UserMessageCount int64 `json:"user_message_count"`
|
|
AssistantMessageCount int64 `json:"assistant_message_count"`
|
|
ToolMessageCount int64 `json:"tool_message_count"`
|
|
SystemMessageCount int64 `json:"system_message_count"`
|
|
TotalInputTokens int64 `json:"total_input_tokens"`
|
|
TotalOutputTokens int64 `json:"total_output_tokens"`
|
|
TotalReasoningTokens int64 `json:"total_reasoning_tokens"`
|
|
TotalCacheCreationTokens int64 `json:"total_cache_creation_tokens"`
|
|
TotalCacheReadTokens int64 `json:"total_cache_read_tokens"`
|
|
TotalCostMicros int64 `json:"total_cost_micros"`
|
|
TotalRuntimeMs int64 `json:"total_runtime_ms"`
|
|
DistinctModelCount int64 `json:"distinct_model_count"`
|
|
CompressedMessageCount int64 `json:"compressed_message_count"`
|
|
}
|
|
|
|
// ChatModelConfig contains model configuration metadata for
|
|
// telemetry. Sensitive fields like API keys are excluded.
|
|
type ChatModelConfig struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Provider string `json:"provider"`
|
|
Model string `json:"model"`
|
|
ContextLimit int64 `json:"context_limit"`
|
|
Enabled bool `json:"enabled"`
|
|
IsDefault bool `json:"is_default"`
|
|
}
|
|
|
|
// ChatDiffStatusSummary contains aggregate PR counts across all
|
|
// agent chats. Total counts unique PRs with a known state
|
|
// (open + merged + closed). Open, Merged, and Closed break that
|
|
// total down by state.
|
|
type ChatDiffStatusSummary struct {
|
|
Total int64 `json:"total"`
|
|
Open int64 `json:"open"`
|
|
Merged int64 `json:"merged"`
|
|
Closed int64 `json:"closed"`
|
|
}
|
|
|
|
// UserSecretsSummary contains deployment-wide aggregates about user
|
|
// secrets. All counts are scoped to active non-system users so that
|
|
// soft-deleted accounts, dormant or suspended users, and internal
|
|
// subjects (e.g. the prebuilds user) do not skew the results. Status
|
|
// transitions move users in and out of this denominator, so a
|
|
// snapshot's UsersWithSecrets can drop without any secret being
|
|
// deleted.
|
|
//
|
|
// UsersWithSecrets is the count of active non-system users that have
|
|
// at least one secret. TotalSecrets is the count of secrets owned by
|
|
// those users. EnvNameOnly, FilePathOnly, Both, and Neither break
|
|
// TotalSecrets down by which injection fields are populated.
|
|
//
|
|
// The SecretsPerUser* fields describe the distribution of secrets per
|
|
// user across the entire active non-system user base, including users
|
|
// with zero secrets, so the percentiles reflect deployment-wide
|
|
// adoption rather than only the power-user subset. Max and Px are the
|
|
// maximum and the 25th, 50th, 75th, and 90th percentiles.
|
|
type UserSecretsSummary struct {
|
|
UsersWithSecrets int64 `json:"users_with_secrets"`
|
|
TotalSecrets int64 `json:"total_secrets"`
|
|
EnvNameOnly int64 `json:"env_name_only"`
|
|
FilePathOnly int64 `json:"file_path_only"`
|
|
Both int64 `json:"both"`
|
|
Neither int64 `json:"neither"`
|
|
SecretsPerUserMax int64 `json:"secrets_per_user_max"`
|
|
SecretsPerUserP25 int64 `json:"secrets_per_user_p25"`
|
|
SecretsPerUserP50 int64 `json:"secrets_per_user_p50"`
|
|
SecretsPerUserP75 int64 `json:"secrets_per_user_p75"`
|
|
SecretsPerUserP90 int64 `json:"secrets_per_user_p90"`
|
|
}
|
|
|
|
// TemplateBuilderSession tracks a single event in the template builder
|
|
// wizard. Two events are emitted per session: one on wizard entry and
|
|
// one on compose completion. User-supplied variable values are never
|
|
// included.
|
|
type TemplateBuilderSession struct {
|
|
ID uuid.UUID `json:"id"`
|
|
EventType string `json:"event_type"`
|
|
UserID uuid.UUID `json:"user_id"`
|
|
BaseTemplateID string `json:"base_template_id,omitempty"`
|
|
ModuleIDs []string `json:"module_ids,omitempty"`
|
|
DurationSeconds float64 `json:"duration_seconds,omitempty"`
|
|
Success bool `json:"success,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
func ConvertAIBridgeInterceptionsSummary(endTime time.Time, provider, model, client string, summary database.CalculateAIBridgeInterceptionsTelemetrySummaryRow) AIBridgeInterceptionsSummary {
|
|
return AIBridgeInterceptionsSummary{
|
|
ID: uuid.New(),
|
|
Timestamp: endTime,
|
|
Provider: provider,
|
|
Model: model,
|
|
Client: client,
|
|
InterceptionCount: summary.InterceptionCount,
|
|
InterceptionDurationMillis: AIBridgeInterceptionsSummaryDurationMillis{
|
|
P50: summary.InterceptionDurationP50Millis,
|
|
P90: summary.InterceptionDurationP90Millis,
|
|
P95: summary.InterceptionDurationP95Millis,
|
|
P99: summary.InterceptionDurationP99Millis,
|
|
},
|
|
// TODO: currently we don't track by route
|
|
InterceptionsByRoute: make(map[string]int64),
|
|
UniqueInitiatorCount: summary.UniqueInitiatorCount,
|
|
UserPromptsCount: summary.UserPromptsCount,
|
|
TokenUsagesCount: summary.TokenUsagesCount,
|
|
TokenCount: AIBridgeInterceptionsSummaryTokenCount{
|
|
Input: summary.TokenCountInput,
|
|
Output: summary.TokenCountOutput,
|
|
CachedRead: summary.TokenCountCachedRead,
|
|
CachedWritten: summary.TokenCountCachedWritten,
|
|
},
|
|
ToolCallsCount: AIBridgeInterceptionsSummaryToolCallsCount{
|
|
Injected: summary.ToolCallsCountInjected,
|
|
NonInjected: summary.ToolCallsCountNonInjected,
|
|
},
|
|
InjectedToolCallErrorCount: summary.InjectedToolCallErrorCount,
|
|
}
|
|
}
|
|
|
|
type noopReporter struct{}
|
|
|
|
func (*noopReporter) Report(_ *Snapshot) {}
|
|
func (*noopReporter) Enabled() bool { return false }
|
|
func (*noopReporter) Close() {}
|
|
func (*noopReporter) RunSnapshotter() {}
|
|
func (*noopReporter) ReportDisabledIfNeeded() error { return nil }
|