Skip to content

Commit 506073f

Browse files
committed
feat: log telemetry for low/depleted CCU balance notifications
Captures the severity of the notification (`SEVERITY_LOW` warning or `SEVERITY_DEPLETED` error), the user's subscription tier, and whether the user clicked the call-to-action (vs. dismissed the notification). The event is logged after the notification's promise resolves so the `clicked_action` outcome is captured. Pairs with `upgrade_to_pro_event` to enable funnel analysis from notification to upgrade. `telemetry.logLowCcuNotification` accepts the Colab-level `SubscriptionTier` directly and converts it to the proto-shaped top-level `SubscriptionTier` enum internally, keeping callers free of the conversion.
1 parent a8bbf5e commit 506073f

5 files changed

Lines changed: 244 additions & 5 deletions

File tree

src/colab/consumption/notifier.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
*/
66

77
import vscode, { Disposable, Event } from 'vscode';
8-
import { CommandSource } from '../../telemetry/api';
8+
import { telemetry } from '../../telemetry';
9+
import { CommandSource, LowBalanceSeverity } from '../../telemetry/api';
910
import { ConsumptionUserInfo, SubscriptionTier } from '../api';
1011
import { openColabSignup } from '../commands/external';
1112

@@ -89,14 +90,20 @@ export class ConsumptionNotifier implements Disposable {
8990
return;
9091
}
9192

93+
const severity =
94+
notification.notify === this.vs.window.showErrorMessage
95+
? LowBalanceSeverity.SEVERITY_DEPLETED
96+
: LowBalanceSeverity.SEVERITY_LOW;
9297
const action = notification.notify(
9398
notification.message,
9499
this.getTierRelevantAction(info.subscriptionTier, paidMinutesLeft > 0),
95100
);
96101
this.setSnoozeTimeout(notification.notify);
97-
if (await action) {
102+
const clicked = !!(await action);
103+
if (clicked) {
98104
openColabSignup(this.vs, CommandSource.COMMAND_SOURCE_NOTIFICATION);
99105
}
106+
telemetry.logLowCcuNotification(severity, info.subscriptionTier, clicked);
100107
}
101108

102109
private buildNotification(totalMinutesLeft: number):

src/colab/consumption/notifier.unit.test.ts

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
*/
66

77
import { assert, expect } from 'chai';
8-
import sinon, { SinonFakeTimers } from 'sinon';
8+
import sinon, { SinonFakeTimers, SinonStubbedFunction } from 'sinon';
9+
import { telemetry } from '../../telemetry';
10+
import { LowBalanceSeverity } from '../../telemetry/api';
911
import { TestEventEmitter } from '../../test/helpers/events';
1012
import { newVsCodeStub, VsCodeStub } from '../../test/helpers/vscode';
1113
import { SubscriptionTier, ConsumptionUserInfo } from '../api';
@@ -426,6 +428,111 @@ describe('ConsumptionNotifier', () => {
426428
});
427429
}
428430

431+
describe('telemetry', () => {
432+
let logStub: SinonStubbedFunction<typeof telemetry.logLowCcuNotification>;
433+
434+
beforeEach(() => {
435+
logStub = sinon.stub(telemetry, 'logLowCcuNotification');
436+
});
437+
438+
/**
439+
* Stubs out the given severity's notification API to immediately resolve
440+
* with the given action (or `undefined` to simulate a dismiss).
441+
*
442+
* @param severity - The severity of the notification to stub ("warn" or
443+
* "error").
444+
* @param respondWith - The action to resolve with, or `undefined` to
445+
* simulate a dismiss.
446+
*/
447+
function autoRespond(
448+
severity: NotificationSeverity,
449+
respondWith: string | undefined,
450+
): void {
451+
const stub =
452+
severity === 'warn'
453+
? vs.window.showWarningMessage
454+
: vs.window.showErrorMessage;
455+
// Type assertion needed due to overloading.
456+
(stub as sinon.SinonStub).callsFake(() => Promise.resolve(respondWith));
457+
}
458+
459+
it('logs SEVERITY_LOW with clicked=false when warning is dismissed', async () => {
460+
const ccuInfo = createCcuInfo(
461+
{ paidMinutes: 0, freeMinutes: 1 },
462+
SubscriptionTier.NONE,
463+
);
464+
autoRespond('warn', undefined);
465+
466+
const noOp = consumptionNotifier.nextConsumptionCalculation();
467+
ccuEmitter.fire(ccuInfo);
468+
await noOp;
469+
470+
sinon.assert.calledOnceWithExactly(
471+
logStub,
472+
LowBalanceSeverity.SEVERITY_LOW,
473+
SubscriptionTier.NONE,
474+
false,
475+
);
476+
});
477+
478+
it('logs SEVERITY_DEPLETED with clicked=true when error action is clicked', async () => {
479+
const ccuInfo = createCcuInfo(
480+
{ paidMinutes: 0, freeMinutes: 0 },
481+
SubscriptionTier.NONE,
482+
);
483+
autoRespond('error', 'Sign Up for Colab');
484+
485+
const noOp = consumptionNotifier.nextConsumptionCalculation();
486+
ccuEmitter.fire(ccuInfo);
487+
await noOp;
488+
489+
sinon.assert.calledOnceWithExactly(
490+
logStub,
491+
LowBalanceSeverity.SEVERITY_DEPLETED,
492+
SubscriptionTier.NONE,
493+
true,
494+
);
495+
});
496+
497+
it('plumbs the Pro subscription tier through unchanged', async () => {
498+
const ccuInfo = createCcuInfo(
499+
{ paidMinutes: 0, freeMinutes: 0 },
500+
SubscriptionTier.PRO,
501+
);
502+
autoRespond('error', 'Upgrade to Pro+');
503+
504+
const noOp = consumptionNotifier.nextConsumptionCalculation();
505+
ccuEmitter.fire(ccuInfo);
506+
await noOp;
507+
508+
sinon.assert.calledOnceWithExactly(
509+
logStub,
510+
LowBalanceSeverity.SEVERITY_DEPLETED,
511+
SubscriptionTier.PRO,
512+
true,
513+
);
514+
});
515+
516+
it('plumbs the Pro+ subscription tier through unchanged', async () => {
517+
const ccuInfo = createCcuInfo(
518+
{ paidMinutes: 0, freeMinutes: 0 },
519+
SubscriptionTier.PRO_PLUS,
520+
);
521+
autoRespond('error', 'Purchase More CCUs');
522+
523+
const noOp = consumptionNotifier.nextConsumptionCalculation();
524+
ccuEmitter.fire(ccuInfo);
525+
await noOp;
526+
527+
sinon.assert.calledOnceWithExactly(
528+
logStub,
529+
LowBalanceSeverity.SEVERITY_DEPLETED,
530+
SubscriptionTier.PRO_PLUS,
531+
true,
532+
);
533+
});
534+
});
535+
429536
describe('snooze', () => {
430537
let fakeClock: SinonFakeTimers;
431538

src/telemetry/api.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ export type ColabEvent =
5858
/** An event representing a Colab toolbar click. */
5959
colab_toolbar_event: ColabToolbarEvent;
6060
}
61+
| {
62+
/** An event representing a low/depleted CCU balance notification. */
63+
low_ccu_notification_event: LowCcuNotificationEvent;
64+
}
6165
| {
6266
/** An event representing an error. */
6367
error_event: ErrorEvent;
@@ -119,6 +123,31 @@ export enum NotebookSource {
119123
NOTEBOOK_SOURCE_DRIVE = 1,
120124
}
121125

126+
/** The severity of a Colab Compute Units (CCU) low balance notification. */
127+
export enum LowBalanceSeverity {
128+
SEVERITY_UNSPECIFIED = 0,
129+
/**
130+
* Balance is low (less than 30 minutes of compute remaining at the current
131+
* consumption rate); shown as a warning.
132+
*/
133+
SEVERITY_LOW = 1,
134+
/** Balance is fully depleted; shown as an error. */
135+
SEVERITY_DEPLETED = 2,
136+
}
137+
138+
/**
139+
* The user's Colab subscription tier as recorded in telemetry. Mirrors the
140+
* top-level `SubscriptionTier` proto enum. Distinct from
141+
* `colab/api.SubscriptionTier`, which uses unprefixed values (`NONE`,
142+
* `PRO`, `PRO_PLUS`).
143+
*/
144+
export enum SubscriptionTier {
145+
SUBSCRIPTION_TIER_UNSPECIFIED = 0,
146+
SUBSCRIPTION_TIER_NONE = 1,
147+
SUBSCRIPTION_TIER_PRO = 2,
148+
SUBSCRIPTION_TIER_PRO_PLUS = 3,
149+
}
150+
122151
// The authentication flow used for sign in.
123152
export enum AuthFlow {
124153
AUTH_FLOW_UNSPECIFIED = 0,
@@ -140,6 +169,20 @@ type AutoConnectEvent = Record<string, never>;
140169
/** An event representing a Colab toolbar click. */
141170
type ColabToolbarEvent = Record<string, never>;
142171

172+
/** An event representing a low/depleted CCU balance notification. */
173+
interface LowCcuNotificationEvent {
174+
/** The severity level of the notification. */
175+
severity: LowBalanceSeverity;
176+
/** The user's subscription tier when the notification was shown. */
177+
subscription_tier: SubscriptionTier;
178+
/**
179+
* Whether the user clicked the call-to-action (e.g., "Sign Up for Colab",
180+
* "Upgrade to Pro+", "Purchase More CCUs"). False if the notification was
181+
* dismissed without taking action.
182+
*/
183+
clicked_action: boolean;
184+
}
185+
143186
/** An event representing an error. */
144187
interface ErrorEvent {
145188
/** The name of the error. */

src/telemetry/index.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
import assert from 'assert';
77
import vscode from 'vscode';
88
import { Disposable } from 'vscode';
9-
import { AuthType } from '../colab/api';
9+
import {
10+
AuthType,
11+
SubscriptionTier as ColabSubscriptionTier,
12+
} from '../colab/api';
1013
import { COLAB_EXT_IDENTIFIER } from '../config/constants';
1114
import { getPackageInfo } from '../config/package-info';
1215
import { JUPYTER_EXT_IDENTIFIER } from '../jupyter/jupyter-extension';
@@ -15,7 +18,9 @@ import {
1518
ColabEvent,
1619
CommandSource,
1720
AuthFlow,
21+
LowBalanceSeverity,
1822
NotebookSource,
23+
SubscriptionTier,
1924
} from './api';
2025
import { ClearcutClient } from './client';
2126

@@ -79,6 +84,19 @@ export const telemetry = {
7984
logColabToolbar: () => {
8085
log({ colab_toolbar_event: {} });
8186
},
87+
logLowCcuNotification: (
88+
severity: LowBalanceSeverity,
89+
subscriptionTier: ColabSubscriptionTier,
90+
clickedAction: boolean,
91+
) => {
92+
log({
93+
low_ccu_notification_event: {
94+
severity,
95+
subscription_tier: toTelemetrySubscriptionTier(subscriptionTier),
96+
clicked_action: clickedAction,
97+
},
98+
});
99+
},
82100
logError: (e: unknown) => {
83101
if (e instanceof Error) {
84102
log({
@@ -141,3 +159,16 @@ function log(event: ColabEvent) {
141159
timestamp: new Date().toISOString(),
142160
});
143161
}
162+
163+
function toTelemetrySubscriptionTier(
164+
tier: ColabSubscriptionTier,
165+
): SubscriptionTier {
166+
switch (tier) {
167+
case ColabSubscriptionTier.NONE:
168+
return SubscriptionTier.SUBSCRIPTION_TIER_NONE;
169+
case ColabSubscriptionTier.PRO:
170+
return SubscriptionTier.SUBSCRIPTION_TIER_PRO;
171+
case ColabSubscriptionTier.PRO_PLUS:
172+
return SubscriptionTier.SUBSCRIPTION_TIER_PRO_PLUS;
173+
}
174+
}

src/telemetry/telemetry.unit.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,20 @@ import { expect } from 'chai';
88
import sinon, { SinonSpy, SinonFakeTimers } from 'sinon';
99
import vscode from 'vscode';
1010
import { Disposable } from 'vscode';
11-
import { AuthType } from '../colab/api';
11+
import {
12+
AuthType,
13+
SubscriptionTier as ColabSubscriptionTier,
14+
} from '../colab/api';
1215
import { COLAB_EXT_IDENTIFIER } from '../config/constants';
1316
import { JUPYTER_EXT_IDENTIFIER } from '../jupyter/jupyter-extension';
1417
import { newVsCodeStub, VsCodeStub } from '../test/helpers/vscode';
1518
import {
1619
ColabLogEventBase,
1720
CommandSource,
1821
AuthFlow,
22+
LowBalanceSeverity,
1923
NotebookSource,
24+
SubscriptionTier,
2025
} from './api';
2126
import { ClearcutClient } from './client';
2227
import { initializeTelemetry, telemetry } from '.';
@@ -275,6 +280,52 @@ describe('Telemetry Module', () => {
275280
});
276281
});
277282

283+
it('logs on low CCU notification', () => {
284+
telemetry.logLowCcuNotification(
285+
LowBalanceSeverity.SEVERITY_DEPLETED,
286+
ColabSubscriptionTier.PRO,
287+
true,
288+
);
289+
290+
sinon.assert.calledOnceWithExactly(logStub, {
291+
...baseLog,
292+
low_ccu_notification_event: {
293+
severity: LowBalanceSeverity.SEVERITY_DEPLETED,
294+
subscription_tier: SubscriptionTier.SUBSCRIPTION_TIER_PRO,
295+
clicked_action: true,
296+
},
297+
});
298+
});
299+
300+
it('converts each Colab SubscriptionTier to its proto-shaped value', () => {
301+
const cases: [ColabSubscriptionTier, SubscriptionTier][] = [
302+
[ColabSubscriptionTier.NONE, SubscriptionTier.SUBSCRIPTION_TIER_NONE],
303+
[ColabSubscriptionTier.PRO, SubscriptionTier.SUBSCRIPTION_TIER_PRO],
304+
[
305+
ColabSubscriptionTier.PRO_PLUS,
306+
SubscriptionTier.SUBSCRIPTION_TIER_PRO_PLUS,
307+
],
308+
];
309+
for (const [tier, expected] of cases) {
310+
logStub.resetHistory();
311+
312+
telemetry.logLowCcuNotification(
313+
LowBalanceSeverity.SEVERITY_LOW,
314+
tier,
315+
false,
316+
);
317+
318+
sinon.assert.calledOnceWithExactly(logStub, {
319+
...baseLog,
320+
low_ccu_notification_event: {
321+
severity: LowBalanceSeverity.SEVERITY_LOW,
322+
subscription_tier: expected,
323+
clicked_action: false,
324+
},
325+
});
326+
}
327+
});
328+
278329
it('logs on mount Drive snippet', () => {
279330
const source = CommandSource.COMMAND_SOURCE_COMMAND_PALETTE;
280331

0 commit comments

Comments
 (0)