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
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
190 changes: 52 additions & 138 deletions src/instrumentation/workspace.ts
Original file line number Diff line number Diff line change
@@ -1,159 +1,73 @@
import { WorkspaceUpdateCancelledError } from "../api/updateParameters";

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";
import type {
AgentStateTransition,
WorkspaceStateTransition,
} from "../workspace/observers";

/** 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 const INITIAL_STATE = "none";

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.

workspaceMonitor.ts:10 imports INITIAL_STATE from instrumentation/ to build a log payload, which is the same layering argument that moved the observers out. Move the constant to observers.ts, next to the types whose docs say "undefined on the first observation", and let instrumentation/ import it from there.


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.status.from ?? INITIAL_STATE,
"status.to": transition.status.to,
"lifecycle_state.from": transition.lifecycleState.from ?? INITIAL_STATE,
"lifecycle_state.to": transition.lifecycleState.to,
},
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