Skip to content
Open
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
5 changes: 5 additions & 0 deletions src/workspace/workspaceMonitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
import { createStatusBarItem } from "../util/statusBar";
import { vscodeProposed } from "../vscodeProposed";

import { WorkspaceStateLogger } from "./workspaceStateLogger";

import type { CoderApi } from "../api/coderApi";
import type { ServiceContainer } from "../core/container";
import type { ContextManager } from "../core/contextManager";
Expand Down Expand Up @@ -46,6 +48,7 @@ export class WorkspaceMonitor implements vscode.Disposable {
// For logging.
private readonly name: string;
private readonly telemetry: WorkspaceStateTelemetry;
private readonly stateLogger: WorkspaceStateLogger;
private readonly logger: Logger;
private readonly contextManager: ContextManager;

Expand All @@ -63,6 +66,7 @@ export class WorkspaceMonitor implements vscode.Disposable {
container.getTelemetryService(),
this.name,
);
this.stateLogger = new WorkspaceStateLogger(this.logger, this.name);
this.latestWorkspace = workspace;

const statusBarItem = createStatusBarItem("workspaceUpdate");
Expand Down Expand Up @@ -136,6 +140,7 @@ export class WorkspaceMonitor implements vscode.Disposable {

private update(workspace: Workspace) {
this.telemetry.observe(workspace);
this.stateLogger.observe(workspace);
this.latestWorkspace = workspace;
this.updateContext(workspace);
this.updateStatusBar(workspace);
Expand Down
87 changes: 87 additions & 0 deletions src/workspace/workspaceStateLogger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { extractAgents } from "../api/api-helper";

import type {
Workspace,
WorkspaceAgentLifecycle,
WorkspaceAgentStatus,
WorkspaceStatus,
} from "coder/site/src/api/typesGenerated";

import type { Logger } from "../logging/logger";

/** Sentinel for the "from" side before any state is observed, and for the
* agent/lifecycle dimensions while no agent exists yet. `"unknown"` is a real
* server-reported value, so avoid it. */
const INITIAL_STATE = "none";

interface ObservedState {
readonly workspaceStatus: WorkspaceStatus;
readonly agentStatus: WorkspaceAgentStatus | typeof INITIAL_STATE;
readonly lifecycleState: WorkspaceAgentLifecycle | typeof INITIAL_STATE;
}

/**
* Logs workspace, agent, and lifecycle status transitions at `info` level so
* connection debugging has a record of state changes correlated by the session
* ID. Tracks state per agent (keyed by agent ID) because a workspace can have
* several. Construct one per workspace; `WorkspaceMonitor` is the sole call
* site.
*/
export class WorkspaceStateLogger {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are lots of similarities between this and WorkspaceStateTelemetry, I suppose one is tracking the workspace states and one is tracking the agent states?

private readonly observed = new Map<string, ObservedState>();

public constructor(
private readonly logger: Logger,
private readonly workspaceName: string,
) {}

public observe(workspace: Workspace): void {
const workspaceStatus = workspace.latest_build.status;
const agents = extractAgents(workspace.latest_build.resources);

if (agents.length === 0) {
this.observeState(INITIAL_STATE, {
workspaceStatus,
agentStatus: INITIAL_STATE,
lifecycleState: INITIAL_STATE,
});
return;
}

for (const agent of agents) {
this.observeState(agent.id, {
workspaceStatus,
agentStatus: agent.status,
lifecycleState: agent.lifecycle_state,
});
}
}

private observeState(key: string, next: ObservedState): void {
const previous = this.observed.get(key);
if (
previous?.workspaceStatus === next.workspaceStatus &&
previous?.agentStatus === next.agentStatus &&
previous?.lifecycleState === next.lifecycleState
Comment on lines +63 to +65

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO I think we should log once for each workspace state change (basically what the workspace telemetry does) and independently track the agent state since they could be independent

) {
return;
}

this.logger.info(`Workspace ${this.workspaceName} state changed`, {
workspaceStatus: {
from: previous?.workspaceStatus ?? INITIAL_STATE,
to: next.workspaceStatus,
},
agentStatus: {
from: previous?.agentStatus ?? INITIAL_STATE,
to: next.agentStatus,
},
lifecycleState: {
from: previous?.lifecycleState ?? INITIAL_STATE,
to: next.lifecycleState,
},
});

this.observed.set(key, next);
}
}
129 changes: 129 additions & 0 deletions test/unit/workspace/workspaceStateLogger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";

import { WorkspaceStateLogger } from "@/workspace/workspaceStateLogger";

import {
agent as createAgent,
resource as createResource,
workspace as createWorkspace,
} from "@repo/mocks";

import { createMockLogger } from "../../mocks/testHelpers";

import type {
Workspace,
WorkspaceAgent,
WorkspaceStatus,
} from "coder/site/src/api/typesGenerated";

function workspaceWith(
status: WorkspaceStatus,
agents: WorkspaceAgent[] = [],
): Workspace {
return createWorkspace({
latest_build: {
status,
resources: [createResource({ agents })],
},
});
}

describe("WorkspaceStateLogger", () => {
it("logs the initial observed state with a `none` origin", () => {
const logger = createMockLogger();
const stateLogger = new WorkspaceStateLogger(logger, "testuser/ws");

stateLogger.observe(
workspaceWith("running", [
createAgent({ status: "connected", lifecycle_state: "ready" }),
]),
);

expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(
"Workspace testuser/ws state changed",
{
workspaceStatus: { from: "none", to: "running" },
agentStatus: { from: "none", to: "connected" },
lifecycleState: { from: "none", to: "ready" },
},
);
});

it("logs a transition when the agent status and lifecycle change", () => {
const logger = createMockLogger();
const stateLogger = new WorkspaceStateLogger(logger, "testuser/ws");

stateLogger.observe(
workspaceWith("starting", [
createAgent({ status: "connecting", lifecycle_state: "starting" }),
]),
);
stateLogger.observe(
workspaceWith("running", [
createAgent({ status: "connected", lifecycle_state: "ready" }),
]),
);

expect(logger.info).toHaveBeenCalledTimes(2);
expect(logger.info).toHaveBeenLastCalledWith(
"Workspace testuser/ws state changed",
{
workspaceStatus: { from: "starting", to: "running" },
agentStatus: { from: "connecting", to: "connected" },
lifecycleState: { from: "starting", to: "ready" },
},
);
});

it("does not log when nothing changes", () => {
const logger = createMockLogger();
const stateLogger = new WorkspaceStateLogger(logger, "testuser/ws");
const snapshot = workspaceWith("running", [
createAgent({ status: "connected", lifecycle_state: "ready" }),
]);

stateLogger.observe(snapshot);
stateLogger.observe(snapshot);

expect(logger.info).toHaveBeenCalledTimes(1);
});

it("uses `none` for the agent dimensions while no agent exists yet", () => {
const logger = createMockLogger();
const stateLogger = new WorkspaceStateLogger(logger, "testuser/ws");

stateLogger.observe(workspaceWith("pending"));

expect(logger.info).toHaveBeenCalledWith(
"Workspace testuser/ws state changed",
{
workspaceStatus: { from: "none", to: "pending" },
agentStatus: { from: "none", to: "none" },
lifecycleState: { from: "none", to: "none" },
},
);
});

it("tracks each agent independently", () => {
const logger = createMockLogger();
const stateLogger = new WorkspaceStateLogger(logger, "testuser/ws");

stateLogger.observe(
workspaceWith("running", [
createAgent({ id: "a1", name: "first", status: "connected" }),
createAgent({ id: "a2", name: "second", status: "connecting" }),
]),
);
expect(logger.info).toHaveBeenCalledTimes(2);

// Only the second agent changes; expect a single new log.
stateLogger.observe(
workspaceWith("running", [
createAgent({ id: "a1", name: "first", status: "connected" }),
createAgent({ id: "a2", name: "second", status: "connected" }),
]),
);
expect(logger.info).toHaveBeenCalledTimes(3);
});
});