Skip to content

Commit 26f7f5d

Browse files
Add more initializer-related info to /insights API (#20572)
* [ws-manager, ws-daemon] Store initializer metrics in workspace.Status.InitializerMetrics Tool: gitpod/catfood.gitpod.cloud * [ws-mananger-api, -mk2] Emit new field .Status.InitializerMetrics Tool: gitpod/catfood.gitpod.cloud * [db] Introduce DBWorkspaceInstanceMetrics and persist all metrics from ws-manager-api into it Tool: gitpod/catfood.gitpod.cloud * [api] Expose session.Metrics.InitializerMetrics Tool: gitpod/catfood.gitpod.cloud * [dashboard] Export metrics into CSV Tool: gitpod/catfood.gitpod.cloud * [content-service] Fix: emit fromBackup stats Tool: gitpod/catfood.gitpod.cloud * Update components/ws-manager-api/core.proto Co-authored-by: Filip Troníček <[email protected]> --------- Co-authored-by: Filip Troníček <[email protected]>
1 parent f5eda87 commit 26f7f5d

38 files changed

+6266
-975
lines changed

components/content-service/pkg/initializer/initializer.go

+23-5
Original file line numberDiff line numberDiff line change
@@ -468,20 +468,38 @@ func InitializeWorkspace(ctx context.Context, location string, remoteStorage sto
468468
}
469469
}
470470

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

477483
span.SetTag("hasBackup", hasBackup)
478484
if hasBackup {
479485
src = csapi.WorkspaceInitFromBackup
480-
} else {
481-
src, stats, err = cfg.Initializer.Run(ctx, cfg.mappings)
482-
if err != nil {
483-
return src, nil, xerrors.Errorf("cannot initialize workspace: %w", err)
486+
487+
currentSize, fsErr := getFsUsage()
488+
if fsErr != nil {
489+
log.WithError(fsErr).Error("could not get disk usage")
484490
}
491+
stats = []csapi.InitializerMetric{{
492+
Type: "fromBackup",
493+
Duration: downloadDuration,
494+
Size: currentSize - initialSize,
495+
}}
496+
return
497+
}
498+
499+
// If there is not backup, run the initializer
500+
src, stats, err = cfg.Initializer.Run(ctx, cfg.mappings)
501+
if err != nil {
502+
return src, nil, xerrors.Errorf("cannot initialize workspace: %w", err)
485503
}
486504

487505
return

components/dashboard/src/Insights.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export const Insights = () => {
6363
"md:flex-row md:items-center md:space-x-4 md:space-y-0",
6464
)}
6565
>
66-
<DownloadUsage to={toDate} disabled={isLackingPermissions} />
66+
<DownloadInsights to={toDate} disabled={isLackingPermissions} />
6767
</div>
6868

6969
<div
@@ -166,7 +166,7 @@ type DownloadUsageProps = {
166166
to: Timestamp;
167167
disabled?: boolean;
168168
};
169-
export const DownloadUsage = ({ to, disabled }: DownloadUsageProps) => {
169+
export const DownloadInsights = ({ to, disabled }: DownloadUsageProps) => {
170170
const { data: org } = useCurrentOrg();
171171
const { toast } = useToast();
172172
// When we start the download, we disable the button for a short time

components/dashboard/src/insights/download/download-sessions.ts

+25-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import dayjs from "dayjs";
1515
import { useQuery, useQueryClient } from "@tanstack/react-query";
1616
import { useCallback } from "react";
1717
import { noPersistence } from "../../data/setup";
18-
import { Timestamp } from "@bufbuild/protobuf";
18+
import { Duration, Timestamp } from "@bufbuild/protobuf";
1919

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

157+
const renderDuration = (duration?: Duration): string => {
158+
if (!duration) {
159+
return "";
160+
}
161+
162+
let seconds = Number(duration.seconds);
163+
seconds += duration.nanos / 1_000_000_000;
164+
return seconds.toString(10);
165+
};
166+
157167
export const transformSessionRecord = (session: WorkspaceSession) => {
158168
const initializerType = session.workspace?.spec?.initializer?.specs;
159169
const prebuildInitializer = initializerType?.find((i) => i.spec.case === "prebuild")?.spec.value as
@@ -190,6 +200,20 @@ export const transformSessionRecord = (session: WorkspaceSession) => {
190200
timeout: session.workspace?.spec?.timeout?.inactivity?.seconds,
191201
editor: session.workspace?.spec?.editor?.name,
192202
editorVersion: session.workspace?.spec?.editor?.version, // indicates whether user selected the stable or latest editor release channel
203+
204+
// initializer metrics
205+
contentInitGitDuration: renderDuration(session.metrics?.initializerMetrics?.git?.duration),
206+
contentInitGitSize: session.metrics?.initializerMetrics?.git?.size,
207+
contentInitFileDownloadDuration: renderDuration(session.metrics?.initializerMetrics?.fileDownload?.duration),
208+
contentInitFileDownloadSize: session.metrics?.initializerMetrics?.fileDownload?.size,
209+
contentInitSnapshotDuration: renderDuration(session.metrics?.initializerMetrics?.snapshot?.duration),
210+
contentInitSnapshotSize: session.metrics?.initializerMetrics?.snapshot?.size,
211+
contentInitBackupDuration: renderDuration(session.metrics?.initializerMetrics?.backup?.duration),
212+
contentInitBackupSize: session.metrics?.initializerMetrics?.backup?.size,
213+
contentInitPrebuildDuration: renderDuration(session.metrics?.initializerMetrics?.prebuild?.duration),
214+
contentInitPrebuildSize: session.metrics?.initializerMetrics?.prebuild?.size,
215+
contentInitCompositeDuration: renderDuration(session.metrics?.initializerMetrics?.composite?.duration),
216+
contentInitCompositeSize: session.metrics?.initializerMetrics?.composite?.size,
193217
};
194218

195219
return row;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Copyright (c) 2025 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { Entity, Column, PrimaryColumn } from "typeorm";
8+
import { WorkspaceInstanceMetrics } from "@gitpod/gitpod-protocol";
9+
10+
@Entity()
11+
export class DBWorkspaceInstanceMetrics {
12+
@PrimaryColumn()
13+
instanceId: string;
14+
15+
@Column("json", { nullable: true })
16+
metrics?: WorkspaceInstanceMetrics;
17+
18+
@Column()
19+
_lastModified: Date;
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Copyright (c) 2025 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { MigrationInterface, QueryRunner } from "typeorm";
8+
9+
export class AddWorkspaceInstanceMetricsTable1739892121734 implements MigrationInterface {
10+
public async up(queryRunner: QueryRunner): Promise<any> {
11+
await queryRunner.query(`CREATE TABLE IF NOT EXISTS d_b_workspace_instance_metrics (
12+
instanceId char(36) NOT NULL,
13+
metrics JSON,
14+
_lastModified timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
15+
PRIMARY KEY (instanceId),
16+
KEY ind_dbsync (_lastModified)
17+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`);
18+
}
19+
20+
public async down(queryRunner: QueryRunner): Promise<any> {
21+
await queryRunner.query(`DROP TABLE IF EXISTS d_b_workspace_instance_metrics`);
22+
}
23+
}

components/gitpod-db/src/typeorm/workspace-db-impl.ts

+52-4
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
WorkspaceAndInstance,
2020
WorkspaceInfo,
2121
WorkspaceInstance,
22+
WorkspaceInstanceMetrics,
2223
WorkspaceInstanceUser,
2324
WorkspaceSession,
2425
WorkspaceType,
@@ -62,6 +63,7 @@ import { TypeORM } from "./typeorm";
6263
import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
6364
import { DBProject } from "./entity/db-project";
6465
import { PrebuiltWorkspaceWithWorkspace } from "@gitpod/gitpod-protocol/src/protocol";
66+
import { DBWorkspaceInstanceMetrics } from "./entity/db-workspace-instance-metrics";
6567

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

114+
private async getWorkspaceInstanceMetricsRepo(): Promise<Repository<DBWorkspaceInstanceMetrics>> {
115+
return (await this.getEntityManager()).getRepository<DBWorkspaceInstanceMetrics>(DBWorkspaceInstanceMetrics);
116+
}
117+
112118
public async connect(maxTries: number = 3, timeout: number = 2000): Promise<void> {
113119
let tries = 1;
114120
while (tries <= maxTries) {
@@ -459,27 +465,39 @@ export class TypeORMWorkspaceDBImpl extends TransactionalDBImpl<WorkspaceDB> imp
459465
offset: number,
460466
): Promise<WorkspaceSession[]> {
461467
const workspaceInstanceRepo = await this.getWorkspaceInstanceRepo();
468+
462469
// The query basically selects all workspace instances for the given owner, whose startDate is within the period, and which are either:
463470
// - not stopped yet, or
464471
// - is stopped or stopping.
465-
const sessions = await workspaceInstanceRepo
472+
type JoinResult = DBWorkspaceInstance & {
473+
metrics: DBWorkspaceInstanceMetrics | undefined;
474+
workspace: DBWorkspace;
475+
};
476+
const sessions = (await workspaceInstanceRepo
466477
.createQueryBuilder("wsi")
467478
.leftJoinAndMapOne("wsi.workspace", DBWorkspace, "ws", "ws.id = wsi.workspaceId")
479+
.leftJoinAndMapOne("wsi.metrics", DBWorkspaceInstanceMetrics, "wsim", "wsim.instanceId = wsi.id")
468480
.where("ws.organizationId = :organizationId", { organizationId })
469481
.andWhere("wsi.creationTime >= :periodStart", { periodStart: periodStart.toISOString() })
470482
.andWhere("wsi.creationTime <= :periodEnd", { periodEnd: periodEnd.toISOString() })
471483
.orderBy("wsi.creationTime", "DESC")
472484
.skip(offset)
473485
.take(limit)
474-
.getMany();
486+
.getMany()) as JoinResult[];
475487

476-
const resultSessions: { instance: WorkspaceInstance; workspace: Workspace }[] = [];
488+
const resultSessions: {
489+
instance: WorkspaceInstance;
490+
workspace: Workspace;
491+
metrics?: WorkspaceInstanceMetrics;
492+
}[] = [];
477493
for (const session of sessions) {
478494
resultSessions.push({
479-
workspace: (session as any).workspace,
495+
workspace: session.workspace,
480496
instance: session,
497+
metrics: session.metrics?.metrics,
481498
});
482499
delete (session as any).workspace;
500+
delete (session as any).metrics;
483501
}
484502
return resultSessions;
485503
}
@@ -1143,6 +1161,36 @@ export class TypeORMWorkspaceDBImpl extends TransactionalDBImpl<WorkspaceDB> imp
11431161
const res = await query.getMany();
11441162
return res.map((r) => r.info);
11451163
}
1164+
1165+
async storeMetrics(instanceId: string, metrics: WorkspaceInstanceMetrics): Promise<WorkspaceInstanceMetrics> {
1166+
const repo = await this.getWorkspaceInstanceMetricsRepo();
1167+
const result = await repo.save({
1168+
instanceId,
1169+
metrics,
1170+
});
1171+
return result.metrics;
1172+
}
1173+
1174+
async getMetrics(instanceId: string): Promise<WorkspaceInstanceMetrics | undefined> {
1175+
const repo = await this.getWorkspaceInstanceMetricsRepo();
1176+
const dbMetrics = await repo.findOne({ where: { instanceId } });
1177+
return dbMetrics?.metrics;
1178+
}
1179+
1180+
async updateMetrics(
1181+
instanceId: string,
1182+
update: WorkspaceInstanceMetrics,
1183+
merge: (current: WorkspaceInstanceMetrics, update: WorkspaceInstanceMetrics) => WorkspaceInstanceMetrics,
1184+
): Promise<WorkspaceInstanceMetrics> {
1185+
return await this.transaction(async (db) => {
1186+
const current = await db.getMetrics(instanceId);
1187+
if (!current) {
1188+
return await db.storeMetrics(instanceId, update);
1189+
}
1190+
const merged = merge(current, update);
1191+
return await db.storeMetrics(instanceId, merged);
1192+
});
1193+
}
11461194
}
11471195

11481196
type InstanceJoinResult = DBWorkspace & { instance: WorkspaceInstance };

components/gitpod-db/src/workspace-db.ts

+9
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
WorkspaceSession,
2424
PrebuiltWorkspaceWithWorkspace,
2525
PrebuildWithStatus,
26+
WorkspaceInstanceMetrics,
2627
} from "@gitpod/gitpod-protocol";
2728

2829
export type MaybeWorkspace = Workspace | undefined;
@@ -196,4 +197,12 @@ export interface WorkspaceDB {
196197

197198
storePrebuildInfo(prebuildInfo: PrebuildInfo): Promise<void>;
198199
findPrebuildInfos(prebuildIds: string[]): Promise<PrebuildInfo[]>;
200+
201+
storeMetrics(instanceId: string, metrics: WorkspaceInstanceMetrics): Promise<WorkspaceInstanceMetrics>;
202+
getMetrics(instanceId: string): Promise<WorkspaceInstanceMetrics | undefined>;
203+
updateMetrics(
204+
instanceId: string,
205+
update: WorkspaceInstanceMetrics,
206+
merge: (current: WorkspaceInstanceMetrics, update: WorkspaceInstanceMetrics) => WorkspaceInstanceMetrics,
207+
): Promise<WorkspaceInstanceMetrics>;
199208
}

components/gitpod-protocol/src/protocol.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* See License.AGPL.txt in the project root for license information.
55
*/
66

7-
import { WorkspaceInstance, PortVisibility, PortProtocol } from "./workspace-instance";
7+
import { WorkspaceInstance, PortVisibility, PortProtocol, WorkspaceInstanceMetrics } from "./workspace-instance";
88
import { RoleOrPermission } from "./permission";
99
import { Project } from "./teams-projects-protocol";
1010
import { createHash } from "crypto";
@@ -1390,6 +1390,7 @@ export namespace WorkspaceInstancePortsChangedEvent {
13901390
export interface WorkspaceSession {
13911391
workspace: Workspace;
13921392
instance: WorkspaceInstance;
1393+
metrics?: WorkspaceInstanceMetrics;
13931394
}
13941395
export interface WorkspaceInfo {
13951396
workspace: Workspace;

components/gitpod-protocol/src/workspace-instance.ts

+39-10
Original file line numberDiff line numberDiff line change
@@ -332,14 +332,43 @@ export interface ImageBuildLogInfo {
332332
* Holds metrics about the workspace instance
333333
*/
334334
export interface WorkspaceInstanceMetrics {
335-
image?: Partial<{
336-
/**
337-
* the total size of the image in bytes (includes Gitpod-specific layers like IDE)
338-
*/
339-
totalSize: number;
340-
/**
341-
* the size of the workspace image in bytes
342-
*/
343-
workspaceImageSize: number;
344-
}>;
335+
image?: ImageMetrics;
336+
337+
/**
338+
* Metrics about the workspace initializer
339+
*/
340+
initializerMetrics?: InitializerMetrics;
341+
}
342+
343+
export interface ImageMetrics {
344+
/**
345+
* the total size of the image in bytes (includes Gitpod-specific layers like IDE)
346+
*/
347+
totalSize?: number;
348+
349+
/**
350+
* the size of the workspace image in bytes
351+
*/
352+
workspaceImageSize?: number;
353+
}
354+
355+
export interface InitializerMetrics {
356+
git?: InitializerMetric;
357+
fileDownload?: InitializerMetric;
358+
snapshot?: InitializerMetric;
359+
backup?: InitializerMetric;
360+
prebuild?: InitializerMetric;
361+
composite?: InitializerMetric;
362+
}
363+
364+
export interface InitializerMetric {
365+
/**
366+
* Duration in milliseconds
367+
*/
368+
duration: number;
369+
370+
/**
371+
* Size in bytes
372+
*/
373+
size: number;
345374
}

0 commit comments

Comments
 (0)