Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
be665e9
feat(workspace): log workspace and agent state changes
aqandrew Aug 11, 2026
66c84be
refactor(workspace): split state logging into workspace and agent log…
aqandrew Aug 18, 2026
7dc5332
feat(workspace): log build transition and reason on state changes
aqandrew Aug 18, 2026
7359423
refactor(instrumentation): fold TransitionTracker into the observers …
aqandrew Aug 18, 2026
e518ee4
refactor(workspace): detect state in observers, log inline, record te…
aqandrew Aug 19, 2026
bce450d
docs(events): rescope workspace.agent.state_transitioned
aqandrew Aug 19, 2026
295a6c8
docs: trim comments to non-obvious info only
aqandrew Aug 19, 2026
f144921
docs: rm redundant comments
aqandrew Aug 19, 2026
bfb7706
refactor(workspace): move state observers out of instrumentation
aqandrew Aug 19, 2026
8400c8b
docs(events): lift the coalescing note to cover both state events
aqandrew Aug 19, 2026
7919d7c
refactor(workspace): inline transition tracking into each observer
aqandrew Aug 20, 2026
3a5ce54
chore: rm unused reset methods
aqandrew Aug 20, 2026
18bcfa2
refactor: flatten status/lifecycleState's from/to properties into Age…
aqandrew Aug 20, 2026
980b099
Revert "refactor: flatten status/lifecycleState's from/to properties …
aqandrew Aug 20, 2026
4162366
refactor: flatten status/lifecycle from/to properties into AgentState…
aqandrew Aug 20, 2026
8bf51f5
refactor: move INITIAL_STATE from workspace.ts to observers.ts
aqandrew Aug 20, 2026
797069b
refactor: extract stateVerb function
aqandrew Aug 20, 2026
82ae2dd
refactor: track name in ObservedAgentState
aqandrew Aug 20, 2026
fb6ce76
refactor(workspace): report removed agents as a name array
aqandrew Aug 20, 2026
cffeadb
refactor(workspace): simplify build-duration bookkeeping
aqandrew Aug 20, 2026
951628b
test(workspace): trim monitor tests to observer wiring
aqandrew Aug 20, 2026
83e1767
test(workspace): cover agent lifecycle-only transitions
aqandrew Aug 20, 2026
399ba97
test(workspace): share the workspaceWith builder
aqandrew Aug 20, 2026
2fac677
test(workspace): reuse workspaceEvent for agent stream messages
aqandrew Aug 20, 2026
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
10 changes: 8 additions & 2 deletions src/instrumentation/EVENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,8 +474,8 @@ success or termination).

Emitted by `WorkspaceOperationTelemetry` (start and update),
`WorkspaceOpenTelemetry` (open, picker, dev container), and
`WorkspaceStateTelemetry` / `WorkspaceAgentTelemetry` (the state-transition
logs).
`recordWorkspaceState` / `recordAgentState` (the state-transition events, from
transitions detected by `WorkspaceStateObserver` / `WorkspaceAgentObserver`).

### Spans

Expand Down Expand Up @@ -537,6 +537,9 @@ Opening a workspace from any entry point.

### Logs

Both state-transition events are sampled from the workspace event stream, so
intermediate hops between samples may coalesce into a single transition.

#### `workspace.state_transitioned`

| Attribute | Values |
Expand All @@ -550,6 +553,9 @@ Opening a workspace from any entry point.

#### `workspace.agent.state_transitioned`

Emitted for every agent in the workspace, deduped per agent, for the whole
monitored session (not only the connected agent during connection setup).

| Attribute | Values |
| -------------------------------------------- | ---------------------------------------------------- |
| `workspace_name`, `agent_name` | names |
Expand Down
192 changes: 52 additions & 140 deletions src/instrumentation/workspace.ts
Original file line number Diff line number Diff line change
@@ -1,159 +1,71 @@
import { WorkspaceUpdateCancelledError } from "../api/updateParameters";
import {
INITIAL_STATE,
type AgentStateTransition,
type WorkspaceStateTransition,
} from "../workspace/observers";

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

import type { TelemetryReporter } from "../telemetry/reporter";
import type { Span } from "../telemetry/span";

/** Sentinel for `from*` before any state is observed. `"unknown"` is a real server-reported value, so avoid it. */
const INITIAL_STATE = "none";

/** Statuses where a provisioner job is actively running. */
const PROVISIONING_STATUSES: ReadonlySet<WorkspaceStatus> = new Set([
"pending",
"starting",
"stopping",
"canceling",
"deleting",
]);

export type WorkspacePromptAction = "start" | "update";
export type WorkspaceUpdatePrompt = "parameters" | "confirmation";

interface ObservedWorkspaceState {
readonly status: WorkspaceStatus;
readonly buildTransition: WorkspaceBuild["transition"];
readonly buildReason: WorkspaceBuild["reason"];
readonly observedAtMs: number;
}

interface ObservedAgentState {
readonly status: WorkspaceAgentStatus;
readonly lifecycleState: WorkspaceAgentLifecycle;
readonly observedAtMs: number;
}

/**
* Emits `workspace.state_transitioned` as a workspace progresses through
* statuses, plus `observed_build_duration_ms` when a provisioner run resolves.
* Construct one per workspace; `WorkspaceMonitor` is the sole call site.
* Emits `workspace.state_transitioned` for a detected workspace transition.
* Telemetry only; pair with `WorkspaceStateObserver`.
*/
export class WorkspaceStateTelemetry {
private observed: ObservedWorkspaceState | undefined;
/** Set on first observation of a provisioning status; cleared when the build resolves. */
private buildStartedAtMs: number | undefined;

public constructor(
private readonly telemetry: TelemetryReporter,
private readonly workspaceName: string,
) {}

public observe(workspace: Workspace): void {
const {
status,
transition: buildTransition,
reason: buildReason,
} = workspace.latest_build;
const previous = this.observed;
if (
previous?.status === status &&
previous.buildTransition === buildTransition &&
previous.buildReason === buildReason
) {
return;
}

const now = performance.now();
const measurements: Record<string, number> = previous
? { observed_duration_ms: now - previous.observedAtMs }
: {};

const wasProvisioning =
previous && PROVISIONING_STATUSES.has(previous.status);
const isProvisioning = PROVISIONING_STATUSES.has(status);
if (isProvisioning) {
this.buildStartedAtMs ??= now;
} else {
if (wasProvisioning && this.buildStartedAtMs !== undefined) {
measurements.observed_build_duration_ms = now - this.buildStartedAtMs;
}
this.buildStartedAtMs = undefined;
}

this.telemetry.log(
"workspace.state_transitioned",
{
workspace_name: this.workspaceName,
from: previous?.status ?? INITIAL_STATE,
to: status,
"build.transition": buildTransition,
"build.reason": buildReason,
},
measurements,
);
this.observed = {
status,
buildTransition,
buildReason,
observedAtMs: now,
};
export function recordWorkspaceState(
telemetry: TelemetryReporter,
workspaceName: string,
transition: WorkspaceStateTransition,
): void {
const measurements: Record<string, number> = {};
if (transition.durationMs !== undefined) {
measurements.observed_duration_ms = transition.durationMs;
}
if (transition.buildDurationMs !== undefined) {
measurements.observed_build_duration_ms = transition.buildDurationMs;
}

telemetry.log(
"workspace.state_transitioned",
{
workspace_name: workspaceName,
from: transition.from ?? INITIAL_STATE,
to: transition.to,
"build.transition": transition.buildTransition,
"build.reason": transition.buildReason,
},
measurements,
);
}

/**
* Emits `workspace.agent.state_transitioned` as the agent's `status` and
* `lifecycle_state` change. The agent has two state dimensions so the event
* carries `status.*` and `lifecycle_state.*` properties. Construct one per
* workspace.
* Emits `workspace.agent.state_transitioned` for a detected agent transition.
* Telemetry only; pair with `WorkspaceAgentObserver`.
*/
export class WorkspaceAgentTelemetry {
private observed: ObservedAgentState | undefined;

public constructor(
private readonly telemetry: TelemetryReporter,
private readonly workspaceName: string,
) {}

public observe(agent: WorkspaceAgent): void {
const previous = this.observed;
if (
previous?.status === agent.status &&
previous.lifecycleState === agent.lifecycle_state
) {
return;
}
const now = performance.now();

this.telemetry.log(
"workspace.agent.state_transitioned",
{
workspace_name: this.workspaceName,
agent_name: agent.name,
"status.from": previous?.status ?? INITIAL_STATE,
"status.to": agent.status,
"lifecycle_state.from": previous?.lifecycleState ?? INITIAL_STATE,
"lifecycle_state.to": agent.lifecycle_state,
},
previous ? { observed_duration_ms: now - previous.observedAtMs } : {},
);
this.observed = {
status: agent.status,
lifecycleState: agent.lifecycle_state,
observedAtMs: now,
};
}

public reset(): void {
this.observed = undefined;
}
export function recordAgentState(
telemetry: TelemetryReporter,
workspaceName: string,
transition: AgentStateTransition,
): void {
telemetry.log(
"workspace.agent.state_transitioned",
{
workspace_name: workspaceName,
agent_name: transition.agentName,
"status.from": transition.statusFrom ?? INITIAL_STATE,
"status.to": transition.statusTo,
"lifecycle_state.from": transition.lifecycleFrom ?? INITIAL_STATE,
"lifecycle_state.to": transition.lifecycleTo,
},
transition.durationMs !== undefined
? { observed_duration_ms: transition.durationMs }
: {},
);
}

/**
Expand Down
9 changes: 1 addition & 8 deletions src/remote/workspaceStateMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,7 @@ import {
streamAgentLogs,
streamBuildLogs,
} from "../api/workspace";
import {
WorkspaceAgentTelemetry,
WorkspaceOperationTelemetry,
} from "../instrumentation/workspace";
import { WorkspaceOperationTelemetry } from "../instrumentation/workspace";
import { maybeAskAgent } from "../promptUtils";
import { vscodeProposed } from "../vscodeProposed";

Expand Down Expand Up @@ -47,7 +44,6 @@ export class WorkspaceStateMachine implements vscode.Disposable {
private readonly terminal: TerminalOutputChannel;
private readonly buildLogStream = new LazyStream<ProvisionerJobLog>();
private readonly agentLogStream = new LazyStream<WorkspaceAgentLog[]>();
private readonly agentTelemetry: WorkspaceAgentTelemetry;
private readonly operationTelemetry: WorkspaceOperationTelemetry;

private agent: { id: string; name: string } | undefined;
Expand All @@ -68,7 +64,6 @@ export class WorkspaceStateMachine implements vscode.Disposable {
this.terminal = new TerminalOutputChannel("Coder: Workspace Build");
const telemetry = container.getTelemetryService();
const workspaceName = `${parts.username}/${parts.workspace}`;
this.agentTelemetry = new WorkspaceAgentTelemetry(telemetry, workspaceName);
this.operationTelemetry = new WorkspaceOperationTelemetry(
telemetry,
workspaceName,
Expand Down Expand Up @@ -184,7 +179,6 @@ export class WorkspaceStateMachine implements vscode.Disposable {
`Agent ${this.agent.name} not found in ${workspaceName} resources`,
);
}
this.agentTelemetry.observe(agent);

switch (agent.status) {
case "connecting":
Expand Down Expand Up @@ -365,7 +359,6 @@ export class WorkspaceStateMachine implements vscode.Disposable {

private resetAgent(): void {
this.agent = undefined;
this.agentTelemetry.reset();
}

dispose(): void {
Expand Down
Loading