Skip to content

Add more initializer-related info to /insights API #20572

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Feb 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions components/content-service/pkg/initializer/initializer.go
Original file line number Diff line number Diff line change
Expand Up @@ -468,20 +468,38 @@ func InitializeWorkspace(ctx context.Context, location string, remoteStorage sto
}
}

// Run the initializer
// Try to download a backup first
initialSize, fsErr := getFsUsage()
if fsErr != nil {
log.WithError(fsErr).Error("could not get disk usage")
}
downloadStart := time.Now()
hasBackup, err := remoteStorage.Download(ctx, location, storage.DefaultBackup, cfg.mappings)
if err != nil {
return src, nil, xerrors.Errorf("cannot restore backup: %w", err)
}
downloadDuration := time.Since(downloadStart)

span.SetTag("hasBackup", hasBackup)
if hasBackup {
src = csapi.WorkspaceInitFromBackup
} else {
src, stats, err = cfg.Initializer.Run(ctx, cfg.mappings)
if err != nil {
return src, nil, xerrors.Errorf("cannot initialize workspace: %w", err)

currentSize, fsErr := getFsUsage()
if fsErr != nil {
log.WithError(fsErr).Error("could not get disk usage")
}
stats = []csapi.InitializerMetric{{
Type: "fromBackup",
Duration: downloadDuration,
Size: currentSize - initialSize,
}}
return
}

// If there is not backup, run the initializer
src, stats, err = cfg.Initializer.Run(ctx, cfg.mappings)
if err != nil {
return src, nil, xerrors.Errorf("cannot initialize workspace: %w", err)
}

return
Expand Down
4 changes: 2 additions & 2 deletions components/dashboard/src/Insights.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export const Insights = () => {
"md:flex-row md:items-center md:space-x-4 md:space-y-0",
)}
>
<DownloadUsage to={toDate} disabled={isLackingPermissions} />
<DownloadInsights to={toDate} disabled={isLackingPermissions} />
</div>

<div
Expand Down Expand Up @@ -166,7 +166,7 @@ type DownloadUsageProps = {
to: Timestamp;
disabled?: boolean;
};
export const DownloadUsage = ({ to, disabled }: DownloadUsageProps) => {
export const DownloadInsights = ({ to, disabled }: DownloadUsageProps) => {
const { data: org } = useCurrentOrg();
const { toast } = useToast();
// When we start the download, we disable the button for a short time
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import dayjs from "dayjs";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react";
import { noPersistence } from "../../data/setup";
import { Timestamp } from "@bufbuild/protobuf";
import { Duration, Timestamp } from "@bufbuild/protobuf";

const pageSize = 100;
const maxPages = 100; // safety limit if something goes wrong with pagination
Expand Down Expand Up @@ -154,6 +154,16 @@ const displayTime = (timestamp?: Timestamp) => {
return timestamp.toDate().toISOString();
};

const renderDuration = (duration?: Duration): string => {
if (!duration) {
return "";
}

let seconds = Number(duration.seconds);
seconds += duration.nanos / 1_000_000_000;
return seconds.toString(10);
};

export const transformSessionRecord = (session: WorkspaceSession) => {
const initializerType = session.workspace?.spec?.initializer?.specs;
const prebuildInitializer = initializerType?.find((i) => i.spec.case === "prebuild")?.spec.value as
Expand Down Expand Up @@ -190,6 +200,20 @@ export const transformSessionRecord = (session: WorkspaceSession) => {
timeout: session.workspace?.spec?.timeout?.inactivity?.seconds,
editor: session.workspace?.spec?.editor?.name,
editorVersion: session.workspace?.spec?.editor?.version, // indicates whether user selected the stable or latest editor release channel

// initializer metrics
contentInitGitDuration: renderDuration(session.metrics?.initializerMetrics?.git?.duration),
contentInitGitSize: session.metrics?.initializerMetrics?.git?.size,
contentInitFileDownloadDuration: renderDuration(session.metrics?.initializerMetrics?.fileDownload?.duration),
contentInitFileDownloadSize: session.metrics?.initializerMetrics?.fileDownload?.size,
contentInitSnapshotDuration: renderDuration(session.metrics?.initializerMetrics?.snapshot?.duration),
contentInitSnapshotSize: session.metrics?.initializerMetrics?.snapshot?.size,
contentInitBackupDuration: renderDuration(session.metrics?.initializerMetrics?.backup?.duration),
contentInitBackupSize: session.metrics?.initializerMetrics?.backup?.size,
contentInitPrebuildDuration: renderDuration(session.metrics?.initializerMetrics?.prebuild?.duration),
contentInitPrebuildSize: session.metrics?.initializerMetrics?.prebuild?.size,
contentInitCompositeDuration: renderDuration(session.metrics?.initializerMetrics?.composite?.duration),
contentInitCompositeSize: session.metrics?.initializerMetrics?.composite?.size,
};

return row;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Copyright (c) 2025 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License.AGPL.txt in the project root for license information.
*/

import { Entity, Column, PrimaryColumn } from "typeorm";
import { WorkspaceInstanceMetrics } from "@gitpod/gitpod-protocol";

@Entity()
export class DBWorkspaceInstanceMetrics {
@PrimaryColumn()
instanceId: string;

@Column("json", { nullable: true })
metrics?: WorkspaceInstanceMetrics;

@Column()
_lastModified: Date;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Copyright (c) 2025 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License.AGPL.txt in the project root for license information.
*/

import { MigrationInterface, QueryRunner } from "typeorm";

export class AddWorkspaceInstanceMetricsTable1739892121734 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<any> {
await queryRunner.query(`CREATE TABLE IF NOT EXISTS d_b_workspace_instance_metrics (
instanceId char(36) NOT NULL,
metrics JSON,
_lastModified timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (instanceId),
KEY ind_dbsync (_lastModified)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`);
}

public async down(queryRunner: QueryRunner): Promise<any> {
await queryRunner.query(`DROP TABLE IF EXISTS d_b_workspace_instance_metrics`);
}
}
56 changes: 52 additions & 4 deletions components/gitpod-db/src/typeorm/workspace-db-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
WorkspaceAndInstance,
WorkspaceInfo,
WorkspaceInstance,
WorkspaceInstanceMetrics,
WorkspaceInstanceUser,
WorkspaceSession,
WorkspaceType,
Expand Down Expand Up @@ -62,6 +63,7 @@ import { TypeORM } from "./typeorm";
import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
import { DBProject } from "./entity/db-project";
import { PrebuiltWorkspaceWithWorkspace } from "@gitpod/gitpod-protocol/src/protocol";
import { DBWorkspaceInstanceMetrics } from "./entity/db-workspace-instance-metrics";

type RawTo<T> = (instance: WorkspaceInstance, ws: Workspace) => T;
interface OrderBy {
Expand Down Expand Up @@ -109,6 +111,10 @@ export class TypeORMWorkspaceDBImpl extends TransactionalDBImpl<WorkspaceDB> imp
);
}

private async getWorkspaceInstanceMetricsRepo(): Promise<Repository<DBWorkspaceInstanceMetrics>> {
return (await this.getEntityManager()).getRepository<DBWorkspaceInstanceMetrics>(DBWorkspaceInstanceMetrics);
}

public async connect(maxTries: number = 3, timeout: number = 2000): Promise<void> {
let tries = 1;
while (tries <= maxTries) {
Expand Down Expand Up @@ -459,27 +465,39 @@ export class TypeORMWorkspaceDBImpl extends TransactionalDBImpl<WorkspaceDB> imp
offset: number,
): Promise<WorkspaceSession[]> {
const workspaceInstanceRepo = await this.getWorkspaceInstanceRepo();

// The query basically selects all workspace instances for the given owner, whose startDate is within the period, and which are either:
// - not stopped yet, or
// - is stopped or stopping.
const sessions = await workspaceInstanceRepo
type JoinResult = DBWorkspaceInstance & {
metrics: DBWorkspaceInstanceMetrics | undefined;
workspace: DBWorkspace;
};
const sessions = (await workspaceInstanceRepo
.createQueryBuilder("wsi")
.leftJoinAndMapOne("wsi.workspace", DBWorkspace, "ws", "ws.id = wsi.workspaceId")
.leftJoinAndMapOne("wsi.metrics", DBWorkspaceInstanceMetrics, "wsim", "wsim.instanceId = wsi.id")
.where("ws.organizationId = :organizationId", { organizationId })
.andWhere("wsi.creationTime >= :periodStart", { periodStart: periodStart.toISOString() })
.andWhere("wsi.creationTime <= :periodEnd", { periodEnd: periodEnd.toISOString() })
.orderBy("wsi.creationTime", "DESC")
.skip(offset)
.take(limit)
.getMany();
.getMany()) as JoinResult[];

const resultSessions: { instance: WorkspaceInstance; workspace: Workspace }[] = [];
const resultSessions: {
instance: WorkspaceInstance;
workspace: Workspace;
metrics?: WorkspaceInstanceMetrics;
}[] = [];
for (const session of sessions) {
resultSessions.push({
workspace: (session as any).workspace,
workspace: session.workspace,
instance: session,
metrics: session.metrics?.metrics,
});
delete (session as any).workspace;
delete (session as any).metrics;
}
return resultSessions;
}
Expand Down Expand Up @@ -1143,6 +1161,36 @@ export class TypeORMWorkspaceDBImpl extends TransactionalDBImpl<WorkspaceDB> imp
const res = await query.getMany();
return res.map((r) => r.info);
}

async storeMetrics(instanceId: string, metrics: WorkspaceInstanceMetrics): Promise<WorkspaceInstanceMetrics> {
const repo = await this.getWorkspaceInstanceMetricsRepo();
const result = await repo.save({
instanceId,
metrics,
});
return result.metrics;
}

async getMetrics(instanceId: string): Promise<WorkspaceInstanceMetrics | undefined> {
const repo = await this.getWorkspaceInstanceMetricsRepo();
const dbMetrics = await repo.findOne({ where: { instanceId } });
return dbMetrics?.metrics;
}

async updateMetrics(
instanceId: string,
update: WorkspaceInstanceMetrics,
merge: (current: WorkspaceInstanceMetrics, update: WorkspaceInstanceMetrics) => WorkspaceInstanceMetrics,
): Promise<WorkspaceInstanceMetrics> {
return await this.transaction(async (db) => {
const current = await db.getMetrics(instanceId);
if (!current) {
return await db.storeMetrics(instanceId, update);
}
const merged = merge(current, update);
return await db.storeMetrics(instanceId, merged);
});
}
}

type InstanceJoinResult = DBWorkspace & { instance: WorkspaceInstance };
9 changes: 9 additions & 0 deletions components/gitpod-db/src/workspace-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
WorkspaceSession,
PrebuiltWorkspaceWithWorkspace,
PrebuildWithStatus,
WorkspaceInstanceMetrics,
} from "@gitpod/gitpod-protocol";

export type MaybeWorkspace = Workspace | undefined;
Expand Down Expand Up @@ -196,4 +197,12 @@ export interface WorkspaceDB {

storePrebuildInfo(prebuildInfo: PrebuildInfo): Promise<void>;
findPrebuildInfos(prebuildIds: string[]): Promise<PrebuildInfo[]>;

storeMetrics(instanceId: string, metrics: WorkspaceInstanceMetrics): Promise<WorkspaceInstanceMetrics>;
getMetrics(instanceId: string): Promise<WorkspaceInstanceMetrics | undefined>;
updateMetrics(
instanceId: string,
update: WorkspaceInstanceMetrics,
merge: (current: WorkspaceInstanceMetrics, update: WorkspaceInstanceMetrics) => WorkspaceInstanceMetrics,
): Promise<WorkspaceInstanceMetrics>;
}
3 changes: 2 additions & 1 deletion components/gitpod-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* See License.AGPL.txt in the project root for license information.
*/

import { WorkspaceInstance, PortVisibility, PortProtocol } from "./workspace-instance";
import { WorkspaceInstance, PortVisibility, PortProtocol, WorkspaceInstanceMetrics } from "./workspace-instance";
import { RoleOrPermission } from "./permission";
import { Project } from "./teams-projects-protocol";
import { createHash } from "crypto";
Expand Down Expand Up @@ -1390,6 +1390,7 @@ export namespace WorkspaceInstancePortsChangedEvent {
export interface WorkspaceSession {
workspace: Workspace;
instance: WorkspaceInstance;
metrics?: WorkspaceInstanceMetrics;
}
export interface WorkspaceInfo {
workspace: Workspace;
Expand Down
49 changes: 39 additions & 10 deletions components/gitpod-protocol/src/workspace-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,14 +332,43 @@ export interface ImageBuildLogInfo {
* Holds metrics about the workspace instance
*/
export interface WorkspaceInstanceMetrics {
image?: Partial<{
/**
* the total size of the image in bytes (includes Gitpod-specific layers like IDE)
*/
totalSize: number;
/**
* the size of the workspace image in bytes
*/
workspaceImageSize: number;
}>;
image?: ImageMetrics;

/**
* Metrics about the workspace initializer
*/
initializerMetrics?: InitializerMetrics;
}

export interface ImageMetrics {
/**
* the total size of the image in bytes (includes Gitpod-specific layers like IDE)
*/
totalSize?: number;

/**
* the size of the workspace image in bytes
*/
workspaceImageSize?: number;
}

export interface InitializerMetrics {
git?: InitializerMetric;
fileDownload?: InitializerMetric;
snapshot?: InitializerMetric;
backup?: InitializerMetric;
prebuild?: InitializerMetric;
composite?: InitializerMetric;
}

export interface InitializerMetric {
/**
* Duration in milliseconds
*/
duration: number;

/**
* Size in bytes
*/
size: number;
}
Loading
Loading