Skip to content

Commit e1eb693

Browse files
committed
refactor(telemetry): align outcome/count naming, fold review findings
- rename the trace outcome hook fail() -> error() and the category fetch_failed -> fetch_error to match the recordError vocabulary - name count measurements <entity>.count (agent.count, workspace.count, event.count, interval.count) per OTel, replacing flat _count keys - set error.type on auth error spans (login/logout exceptions, auth_failed) and split auth outcomes so errors carry error.type, aborts carry reason - fold the general telemetry review findings into CONVENTIONS.md and trim for conciseness
1 parent 832d11a commit e1eb693

13 files changed

Lines changed: 97 additions & 59 deletions

File tree

src/commands.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ export class Commands {
285285
return;
286286
}
287287
if (resolved.status === "failed") {
288-
telemetry.fail(resolved.category);
288+
telemetry.error(resolved.category);
289289
return;
290290
}
291291

@@ -346,7 +346,7 @@ export class Commands {
346346
telemetry.abort("progress");
347347
return;
348348
}
349-
telemetry.fail();
349+
telemetry.error();
350350
this.logger.error("Speed test failed", result.error);
351351
vscode.window.showErrorMessage(
352352
`Speed test failed: ${toError(result.error).message}`,
@@ -364,14 +364,14 @@ export class Commands {
364364
});
365365
} catch (err) {
366366
if (err instanceof ZodError || err instanceof SyntaxError) {
367-
telemetry.fail("parse_error");
367+
telemetry.error("parse_error");
368368
this.logger.error("Failed to parse speedtest output", err);
369369
vscode.window.showErrorMessage(
370370
"Speed test output did not match the expected format. Check `Output > Coder` for details.",
371371
);
372372
return;
373373
}
374-
telemetry.fail();
374+
telemetry.error();
375375
this.logger.error("Failed to display speedtest results", err);
376376
vscode.window.showErrorMessage(
377377
`Speed test returned unexpected output: ${toError(err).message}`,
@@ -395,7 +395,7 @@ export class Commands {
395395
return;
396396
}
397397
if (resolved.status === "failed") {
398-
telemetry.fail(resolved.category);
398+
telemetry.error(resolved.category);
399399
return;
400400
}
401401

@@ -444,7 +444,7 @@ export class Commands {
444444
telemetry.abort("progress");
445445
return;
446446
}
447-
telemetry.fail(
447+
telemetry.error(
448448
result.error instanceof SupportBundleUnsupportedCliError
449449
? "unsupported_cli"
450450
: undefined,
@@ -915,7 +915,7 @@ export class Commands {
915915
return false;
916916
}
917917
if (pick.status === "failed") {
918-
telemetry.fail(pick.category);
918+
telemetry.error(pick.category);
919919
return false;
920920
}
921921
workspace = pick.workspace;
@@ -1224,7 +1224,7 @@ export class Commands {
12241224
}
12251225
})
12261226
.catch((ex) => {
1227-
fetchErrorCategory = "fetch_failed";
1227+
fetchErrorCategory = "fetch_error";
12281228
this.logger.error("Failed to fetch workspaces", ex);
12291229
if (ex instanceof CertificateError) {
12301230
void ex.showNotification();

src/instrumentation/CONVENTIONS.md

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22

33
How to add telemetry so every instrumentation reads the same way. The framework
44
lives in `src/telemetry`; the per-domain instrumentation business code talks to
5-
lives here in `src/instrumentation`. See `cli.ts` (spans + phases) and `ssh.ts`
6-
(point-in-time logs) as references.
5+
lives here in `src/instrumentation`.
76

87
## Checklist
98

@@ -19,8 +18,8 @@ lives here in `src/instrumentation`. See `cli.ts` (spans + phases) and `ssh.ts`
1918
- No secrets, tokens, query strings, or unbounded values in properties; routes
2019
go through `normalizeRoute`.
2120
- Let the framework set `result`; add a domain `outcome` only when an operation
22-
has several success modes. Failures go to a typed union; non-error early exits
23-
call `markAborted`.
21+
has several success modes. Errors go to a typed `error.type` union; non-error
22+
early exits call `markAborted`.
2423

2524
## Layers
2625

@@ -31,6 +30,15 @@ lives here in `src/instrumentation`. See `cli.ts` (spans + phases) and `ssh.ts`
3130
- **Instrumentation** (`src/instrumentation/*`): one typed class per domain, the
3231
only telemetry surface business code sees.
3332

33+
## Structure
34+
35+
- Split instrumentation files along the same boundaries as the business code,
36+
not one catch-all module.
37+
- Shared span helpers (`recordError`, `recordAborted`) live in one shared module,
38+
not duplicated per file.
39+
- Record-error-then-rethrow-outside-the-span logic lives once per class, in a
40+
single private helper, not in every `traceX` method.
41+
3442
## Threading
3543

3644
Spans are passed **explicitly** as a callback argument; there is no
@@ -46,6 +54,15 @@ ambient/active-span context. Two patterns keep telemetry out of business logic:
4654
Never return a value purely so a caller can log it; that couples the return type
4755
to observability. Returning is fine when the business uses the value too.
4856

57+
## Callers
58+
59+
- Declare telemetry dimensions explicitly at the call site; pass `source: "uri"`
60+
rather than inferring it from which arguments happen to be set.
61+
- Keep business bodies in named private `runX(args, trace)` methods; the public
62+
method just opens the span and wraps them. Small diffs, named telemetry seam.
63+
- When sibling events share a correlating property, emit it on every event in the
64+
family; don't drop it from new ones.
65+
4966
## Spans, phases, logs
5067

5168
- `trace(name, fn, props?, meas?)` — a span with framework-set `result` and
@@ -62,7 +79,7 @@ to observability. Returning is fine when the business uses the value too.
6279
| Event name | `domain.snake_case` | `cli.resolve`, `remote.setup`, `connection.dropped` |
6380
| Point-in-time log | past tense | `connection.dropped`, `ssh.process.lost` |
6481
| Child phase | bare `snake_case` | `cache_lookup`, `version_check`, `connection_handoff` |
65-
| Property / measurement key | lowercase; `_` splits words, `.` for groups | `cache_source`, `failure_category`, `status.from` |
82+
| Property / measurement key | lowercase; `_` splits words, `.` for groups | `cache_source`, `error.type`, `status.from` |
6683
| Enumerated value | typed `snake_case` union | `"cache_hit"`, `"session_token"`, `"unrecoverable_close"` |
6784

6885
This is the [OTel attribute convention](https://opentelemetry.io/docs/specs/semconv/general/naming/):
@@ -71,6 +88,10 @@ camelCase. Default to a flat `snake_case` key; use `.` only to group genuinely
7188
related attributes (a `status.from` / `status.to` pair). Keep phase names
7289
subject-first within a domain (`agent_resolve`, not `resolve_agent`).
7390

91+
**Methods.** Span-wrapper methods are `trace<Noun>` (`traceOpen`,
92+
`traceConfirmationPrompt`); don't echo the event's past-tense suffix
93+
(`traceUpdateConfirmationPrompted`), and drop qualifiers the class implies.
94+
7495
**Grouping.** Group related events under a shared dotted namespace so a prefix
7596
query returns the whole family: `workspace.update.triggered` and
7697
`workspace.update.prompted` both sit under `workspace.update.*`, as do
@@ -102,23 +123,39 @@ Coder's own pipeline, so a bare `cache_source` can't collide with a future OTel
102123
`latency`, unit `ms`, which Prometheus then suffixes itself); log and span
103124
attributes keep the key as written. You always author `latency_ms`; only the
104125
exported metric name changes.
126+
- **Counts.** Name a count `<entity>.count`, singular entity (`agent.count`,
127+
`workspace.count`), per OTel (`system.process.count`) — not flat `agent_count`.
128+
Related counts share the namespace (`agent.count`, `agent.connected_count`); a
129+
count with no entity (`retry_count`) stays flat.
105130

106-
## Outcomes, failures, aborts
131+
## Outcomes, errors, aborts
107132

108133
- The framework sets `result` (`success` / `error` / `aborted`) on every span;
109134
don't duplicate it.
110135
- Add a domain `outcome` property only when an operation has several success
111136
modes worth distinguishing (e.g. `cli.resolve`: `cache_hit`, `downloaded`).
112-
- Classify failures into a typed union (`failure_category`, or a domain
113-
`reason`) via a `categorize*Failure` helper rather than emitting raw error
114-
strings; the
115-
framework already captures the error block.
116-
- For a non-error early exit (cancelled, not-found), call `span.markAborted()`
117-
rather than throwing.
137+
- Classify errors into a typed `error.type` union via a `categorize*Error` helper
138+
rather than emitting raw error strings; the framework captures the error block.
139+
- For a non-error early exit (backed out, not-found), call `span.markAborted()`
140+
rather than throwing, recording its reason in a separate key, not `error.type`.
141+
- A trace exposes one outcome trio — `abort(stage)`, `error(category?)`,
142+
`succeed*(payload)` — over the shared `recordAborted` / `recordError` helpers;
143+
each outcome sets one, never two.
144+
- Prefer **error** over "failure" and **abort** over "cancel" here (`recordError`
145+
/ `error.type` / `error()`; `recordAborted` / `markAborted` / `abort()`).
146+
Point-in-time logs keep past tense (`recovery_failed`) — they state what
147+
happened, not a span's `error` result.
118148

119149
## Safety
120150

121151
Never put tokens, credentials, full URLs with query strings, or unbounded user
122152
input into properties. Routes go through `normalizeRoute`
123153
(`src/logging/routeNormalization.ts`). Prefer a closed union over a free-form
124154
string for any property a dashboard groups by.
155+
156+
## Tests
157+
158+
- Telemetry-only tests of business code are `<subject>.telemetry.test.ts`;
159+
instrumentation modules keep `<module>.test.ts` and split when the module does.
160+
- Assert what privacy intends to omit, not only what is present (e.g.
161+
`workspace_name` undefined on command events).

src/instrumentation/auth.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export class AuthTelemetry {
5858
}
5959
return result;
6060
} catch (error) {
61-
span.setProperty("reason", "exception");
61+
span.setProperty("error.type", "exception");
6262
throw error;
6363
}
6464
},
@@ -78,7 +78,7 @@ export class AuthTelemetry {
7878
}
7979
return result;
8080
} catch (error) {
81-
span.setProperty("reason", "exception");
81+
span.setProperty("error.type", "exception");
8282
throw error;
8383
}
8484
});
@@ -143,11 +143,11 @@ export class AuthTelemetry {
143143

144144
/** `auth_failed` is a real error; user/URL dismissals are intentional aborts. */
145145
function recordReason(span: Span, reason: LoginPromptReason): void {
146-
span.setProperty("reason", reason);
147146
if (reason === "auth_failed") {
148147
span.setProperty("error.type", reason);
149148
span.markError();
150149
} else {
150+
span.setProperty("reason", reason);
151151
span.markAborted();
152152
}
153153
}

src/instrumentation/diagnostics.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export type DiagnosticAbortStage =
2525

2626
export interface DiagnosticTrace {
2727
abort(stage: DiagnosticAbortStage): void;
28-
fail(category?: DiagnosticErrorCategory): void;
28+
error(category?: DiagnosticErrorCategory): void;
2929
setRequestedDuration(seconds: number): void;
3030
succeedSpeedtest(result: SpeedtestResult): void;
3131
succeedExport(format: string, eventCount: number): void;
@@ -54,7 +54,7 @@ class SpanDiagnosticTrace implements DiagnosticTrace {
5454
recordAborted(this.span, stage);
5555
}
5656

57-
public fail(category: DiagnosticErrorCategory = "error"): void {
57+
public error(category: DiagnosticErrorCategory = "error"): void {
5858
recordError(this.span, category);
5959
}
6060

@@ -63,7 +63,7 @@ class SpanDiagnosticTrace implements DiagnosticTrace {
6363
}
6464

6565
public succeedSpeedtest(result: SpeedtestResult): void {
66-
this.span.setMeasurement("interval_count", result.intervals.length);
66+
this.span.setMeasurement("interval.count", result.intervals.length);
6767
this.span.setMeasurement(
6868
"throughput_mbits",
6969
result.overall.throughput_mbits,
@@ -72,6 +72,6 @@ class SpanDiagnosticTrace implements DiagnosticTrace {
7272

7373
public succeedExport(format: string, eventCount: number): void {
7474
this.span.setProperty("format", format);
75-
this.span.setMeasurement("event_count", eventCount);
75+
this.span.setMeasurement("event.count", eventCount);
7676
}
7777
}

src/instrumentation/workspaceOpen.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export type WorkspaceOpenSource =
2424
| "uri";
2525

2626
export type WorkspacePickerSource = "workspace_open" | "diagnostic";
27-
export type WorkspacePickerErrorCategory = "fetch_failed";
27+
export type WorkspacePickerErrorCategory = "fetch_error";
2828
export type WorkspaceOpenErrorCategory =
2929
| WorkspacePickerErrorCategory
3030
| AbortableErrorCategory;
@@ -56,7 +56,7 @@ export interface WorkspaceOpenTrace {
5656
stage: WorkspaceOpenAbortStage,
5757
selection?: WorkspaceOpenSelection,
5858
): void;
59-
fail(category: WorkspaceOpenErrorCategory): void;
59+
error(category: WorkspaceOpenErrorCategory): void;
6060
handoff(kind: "folder" | "empty_window"): void;
6161
}
6262

@@ -148,7 +148,7 @@ class SpanWorkspacePickerTrace implements WorkspacePickerTrace {
148148
public constructor(private readonly span: Span) {}
149149

150150
public finish(result: WorkspacePickerResult, resultCount: number): void {
151-
this.span.setMeasurement("workspace_count", resultCount);
151+
this.span.setMeasurement("workspace.count", resultCount);
152152
if (result.status === "selected") {
153153
recordWorkspaceContext(this.span, result.workspace);
154154
return;
@@ -178,7 +178,7 @@ class SpanWorkspaceOpenTrace implements WorkspaceOpenTrace {
178178
recordAborted(this.span, stage);
179179
}
180180

181-
public fail(category: WorkspaceOpenErrorCategory): void {
181+
public error(category: WorkspaceOpenErrorCategory): void {
182182
recordError(this.span, category);
183183
}
184184

@@ -195,9 +195,9 @@ function recordWorkspaceContext(
195195
const agents = extractAgents(workspace.latest_build.resources);
196196
span.setProperty("workspace_status", workspace.latest_build.status);
197197
span.setProperty("workspace_outdated", workspace.outdated);
198-
span.setMeasurement("agent_count", agents.length);
198+
span.setMeasurement("agent.count", agents.length);
199199
span.setMeasurement(
200-
"connected_agent_count",
200+
"agent.connected_count",
201201
agents.filter((candidate) => candidate.status === "connected").length,
202202
);
203203
if (!agent) {

src/telemetry/export/command.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ const PROGRESS_OPTIONS = {
3535
*/
3636
export interface ExportTelemetryObserver {
3737
abort(stage: "prompt" | "progress"): void;
38-
fail(): void;
38+
error(): void;
3939
succeedExport(format: ExportFormat, eventCount: number): void;
4040
}
4141

@@ -102,7 +102,7 @@ async function reportOutcome(
102102
observer.abort("progress");
103103
return;
104104
}
105-
observer.fail();
105+
observer.error();
106106
logger.error("Telemetry export failed", result.error);
107107
void vscode.window.showErrorMessage(
108108
`Telemetry export failed: ${toError(result.error).message}`,

test/unit/commands.telemetry.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ describe("Commands", () => {
207207
properties: {
208208
method: "cli_token",
209209
result: "error",
210-
reason: "auth_failed",
210+
"error.type": "auth_failed",
211211
},
212212
});
213213
expect(mocks.deploymentManager.setDeployment).not.toHaveBeenCalled();
@@ -267,7 +267,7 @@ describe("Commands", () => {
267267
await expect(commands.logout()).rejects.toThrow("secret clear failed");
268268

269269
expect(sink.expectOne("auth.logout")).toMatchObject({
270-
properties: { result: "error", reason: "exception" },
270+
properties: { result: "error", "error.type": "exception" },
271271
error: { message: "secret clear failed" },
272272
});
273273
});

test/unit/instrumentation/auth.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ describe("AuthTelemetry", () => {
4646
source: "uri",
4747
method: "oauth",
4848
result: "error",
49-
reason: "auth_failed",
49+
"error.type": "auth_failed",
5050
},
5151
},
5252
])("$name", async ({ outcome, traceMethod, properties }) => {
@@ -101,7 +101,7 @@ describe("AuthTelemetry", () => {
101101
expect(sink.expectOne("auth.logout")).toMatchObject({
102102
properties: {
103103
result: "error",
104-
reason: "exception",
104+
"error.type": "exception",
105105
},
106106
error: { message: "clear failed" },
107107
});

test/unit/instrumentation/diagnostics.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ describe("DiagnosticTelemetry", () => {
1818
return Promise.resolve();
1919
});
2020
await telemetry.trace("support_bundle", (trace) => {
21-
trace.fail("unsupported_cli");
21+
trace.error("unsupported_cli");
2222
return Promise.resolve();
2323
});
2424

@@ -59,7 +59,7 @@ describe("DiagnosticTelemetry", () => {
5959

6060
expect(sink.expectOne("command.diagnostic.completed")).toMatchObject({
6161
measurements: {
62-
interval_count: 1,
62+
"interval.count": 1,
6363
throughput_mbits: 42,
6464
},
6565
properties: { result: "success" },

0 commit comments

Comments
 (0)