Skip to content

Commit e99991f

Browse files
authored
🤖 feat: emit compaction_completed telemetry (#1201)
Backend now emits a `compaction_completed` telemetry event (PostHog) after successful history compaction, including privacy-preserving (base-2 rounded) token bucket metrics. Also adds typed payload + oRPC schema validation and unit coverage for the backend emission. --- <details> <summary>📋 Implementation Plan</summary> # 🤖 Compaction completed telemetry (PostHog) ## Goals - Capture a dedicated telemetry event whenever a history **compaction completes successfully**. - Include **token deltas** so we can quantify effectiveness: - **starting token count** (pre-compaction) - **new token count** (post-compaction summary) - Send data to **PostHog** so we can later: - build dashboards (compaction size distribution, reduction ratios) - segment by model / compaction type - trigger in-app UX (e.g., show a feedback survey after compaction) ## Non-goals (for this change) - No new UI (survey modal, toast, etc.). - No raw user content / summary text ever leaves the machine. - No changes to compaction behavior itself. --- ## Recommended approach (backend-only capture in `CompactionHandler.handleCompletion()`) **Why:** Compaction already happens in the main process, and you explicitly want the backend to be the sole emitter (no renderer dependency). **Net new LoC (product code):** ~120–180 ### 1) Define a new telemetry event payload (transparency + typing) Add a new payload type + union member: - `src/common/telemetry/payload.ts` - `export interface CompactionCompletedPayload { ... }` - extend `TelemetryEventPayload` with: - `{ event: "compaction_completed"; properties: CompactionCompletedPayload }` Suggested properties (privacy-preserving, base-2 rounded): - `model: string` - `duration_b2: number` (seconds) - `input_tokens_b2: number` (proxy for pre-compaction history size) - `output_tokens_b2: number` (proxy for post-compaction history size) - `compaction_source: "manual" | "idle"` **Token source of truth:** prefer provider-reported usage from the compaction stream metadata (same tokenizer/model as the compaction): - `inputTokens` = `event.metadata.contextUsage?.inputTokens ?? event.metadata.usage?.inputTokens ?? 0` - `outputTokens` = `event.metadata.contextUsage?.outputTokens ?? event.metadata.usage?.outputTokens ?? 0` ### 2) Keep Zod telemetry schema in sync (even if the renderer never emits this event) - `src/common/orpc/schemas/telemetry.ts` - add `CompactionCompletedPropertiesSchema` - add `{ event: z.literal("compaction_completed"), properties: ... }` to `TelemetryEventSchema` ### 3) Emit from the backend after successful compaction - `src/node/services/compactionHandler.ts` - Extend `CompactionHandlerOptions` with optional `telemetryService?: TelemetryService`. - After `performCompaction(...)` succeeds (and after the dedupe check), call: - `telemetryService?.capture({ event: "compaction_completed", properties: { ... } })` - Apply privacy rounding using `roundToBase2` (`src/common/telemetry/utils.ts`). - `compaction_source` should be derived from the already-existing `isIdleCompaction` check. - Ensure the event is emitted **once** per compaction-request (reuse `processedCompactionRequestIds`). - Wire the TelemetryService through: - `WorkspaceService` → `new AgentSession({ telemetryService: ... })` - `AgentSession` → `new CompactionHandler({ telemetryService: ... })` - Keep the option optional to avoid rewriting unit tests that don’t care about telemetry. <details> <summary>Alternative approach (renderer-emitted) — NOT recommended per requirements</summary> The existing telemetry pipeline is renderer → oRPC → backend → PostHog. While that’s usually fine (and still avoids classic adblocker problems because it’s not PostHog JS), you explicitly want *only* backend emission, so this plan does not rely on `trackEvent()` from the renderer. </details> --- ## Tests + validation - Unit tests (recommended): extend `src/node/services/compactionHandler.test.ts` - Inject a fake `telemetryService` with a spy `capture()`. - Assert `compaction_completed` is captured exactly once on successful compaction. - Assert it is **not** captured for non-compaction stream-end events. - Repo checks: - `make fmt-check && make lint && make typecheck && make test` - Manual smoke test: - Run packaged, or set `MUX_ENABLE_TELEMETRY_IN_DEV=1`. - Trigger `/compact`. - Confirm `compaction_completed` appears in PostHog with rounded properties. --- ## PostHog: create dashboard + insights (via MCP tools) **Net new LoC (product code):** 0 (PostHog objects only) ### 1) Identify the correct PostHog org/project Use the MCP tools to confirm we’re operating in the PostHog project that receives mux telemetry: - `posthog_organizations-get` - `posthog_projects-get` ### 2) Create a dashboard - `posthog_dashboard-create` with: - `name`: `Mux • Compaction` - `description`: `Compaction completion volume and effectiveness` - `pinned`: `true` (optional) - `tags`: `["mux", "compaction"]` ### 3) Create insights (query-run → create → attach to dashboard) For each insight: 1) `posthog_query-run` (validate the query returns successfully) 2) `posthog_insight-create-from-query` 3) `posthog_insight-update` to attach it to the new dashboard (set `dashboard: <id>`) Suggested insights: - **Trend:** daily count of `compaction_completed` (last 30 days) - **Breakdown:** `compaction_completed` by `compaction_source` (manual vs idle) - **Table (HogQL):** effectiveness by `model` and `compaction_source` <details> <summary>Example HogQL for the effectiveness table</summary> ```sql SELECT properties['model'] AS model, properties['compaction_source'] AS compaction_source, count() AS compactions, avg(toFloat(properties['input_tokens_b2'])) AS avg_input_tokens_b2, avg(toFloat(properties['output_tokens_b2'])) AS avg_output_tokens_b2, avg(toFloat(properties['output_tokens_b2']) / nullIf(toFloat(properties['input_tokens_b2']), 0)) AS avg_ratio FROM events WHERE event = 'compaction_completed' AND timestamp >= now() - INTERVAL 30 DAY GROUP BY model, compaction_source ORDER BY compactions DESC ``` </details> ### 4) Verify dashboard wiring - `posthog_dashboard-get` (ensure the dashboard exists) - `posthog_insight-query` for each insight (ensure the query executes; it may legitimately be empty until events arrive) --- ## Future follow-ups (not in scope) - **Survey after compaction:** gate a post-compaction prompt with a PostHog feature flag (evaluated via the existing backend `ExperimentsService`), then record survey answers to PostHog as a separate event. - Consider migrating other telemetry events to backend-only for consistency. </details> --- _Generated with `mux` • Model: `openai:gpt-5.2` • Thinking: `high`_ Signed-off-by: Thomas Kosiewski <[email protected]>
1 parent 36b88ce commit e99991f

File tree

7 files changed

+105
-0
lines changed

7 files changed

+105
-0
lines changed

src/common/orpc/schemas/telemetry.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,14 @@ const StreamCompletedPropertiesSchema = z.object({
7878
output_tokens_b2: z.number(),
7979
});
8080

81+
const CompactionCompletedPropertiesSchema = z.object({
82+
model: z.string(),
83+
duration_b2: z.number(),
84+
input_tokens_b2: z.number(),
85+
output_tokens_b2: z.number(),
86+
compaction_source: z.enum(["manual", "idle"]),
87+
});
88+
8189
const ProviderConfiguredPropertiesSchema = z.object({
8290
provider: z.string(),
8391
keyType: z.string(),
@@ -125,6 +133,10 @@ export const TelemetryEventSchema = z.discriminatedUnion("event", [
125133
event: z.literal("stream_completed"),
126134
properties: StreamCompletedPropertiesSchema,
127135
}),
136+
z.object({
137+
event: z.literal("compaction_completed"),
138+
properties: CompactionCompletedPropertiesSchema,
139+
}),
128140
z.object({
129141
event: z.literal("provider_configured"),
130142
properties: ProviderConfiguredPropertiesSchema,

src/common/telemetry/payload.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,22 @@ export interface StreamCompletedPayload {
122122
output_tokens_b2: number;
123123
}
124124

125+
/**
126+
* Compaction completion event - tracks when history compaction finishes
127+
*/
128+
export interface CompactionCompletedPayload {
129+
/** Model used for compaction */
130+
model: string;
131+
/** Duration in seconds, rounded to nearest power of 2 */
132+
duration_b2: number;
133+
/** Input tokens (pre-compaction history size), rounded to nearest power of 2 */
134+
input_tokens_b2: number;
135+
/** Output tokens (post-compaction summary size), rounded to nearest power of 2 */
136+
output_tokens_b2: number;
137+
/** Whether this compaction was user-triggered vs idle */
138+
compaction_source: "manual" | "idle";
139+
}
140+
125141
/**
126142
* Provider configuration event - tracks when users set up providers
127143
* Note: Only tracks that a key was set, never the actual value
@@ -211,6 +227,7 @@ export type TelemetryEventPayload =
211227
| { event: "workspace_switched"; properties: WorkspaceSwitchedPayload }
212228
| { event: "message_sent"; properties: MessageSentPayload }
213229
| { event: "stream_completed"; properties: StreamCompletedPayload }
230+
| { event: "compaction_completed"; properties: CompactionCompletedPayload }
214231
| { event: "provider_configured"; properties: ProviderConfiguredPayload }
215232
| { event: "command_used"; properties: CommandUsedPayload }
216233
| { event: "voice_transcription"; properties: VoiceTranscriptionPayload }

src/node/services/agentSession.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { createRuntime } from "@/node/runtime/runtimeFactory";
3030
import { MessageQueue } from "./messageQueue";
3131
import type { StreamEndEvent } from "@/common/types/stream";
3232
import { CompactionHandler } from "./compactionHandler";
33+
import type { TelemetryService } from "./telemetryService";
3334
import type { BackgroundProcessManager } from "./backgroundProcessManager";
3435
import { computeDiff } from "@/node/utils/diff";
3536
import { AttachmentService } from "./attachmentService";
@@ -89,6 +90,7 @@ interface AgentSessionOptions {
8990
partialService: PartialService;
9091
aiService: AIService;
9192
initStateManager: InitStateManager;
93+
telemetryService?: TelemetryService;
9294
backgroundProcessManager: BackgroundProcessManager;
9395
/** Called when compaction completes (e.g., to clear idle compaction pending state) */
9496
onCompactionComplete?: () => void;
@@ -147,6 +149,7 @@ export class AgentSession {
147149
partialService,
148150
aiService,
149151
initStateManager,
152+
telemetryService,
150153
backgroundProcessManager,
151154
onCompactionComplete,
152155
onPostCompactionStateChange,
@@ -169,6 +172,7 @@ export class AgentSession {
169172
this.compactionHandler = new CompactionHandler({
170173
workspaceId: this.workspaceId,
171174
historyService: this.historyService,
175+
telemetryService,
172176
partialService: this.partialService,
173177
emitter: this.emitter,
174178
onCompactionComplete,

src/node/services/compactionHandler.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import type { PartialService } from "./partialService";
55
import type { EventEmitter } from "events";
66
import { createMuxMessage, type MuxMessage } from "@/common/types/message";
77
import type { StreamEndEvent } from "@/common/types/stream";
8+
import type { TelemetryService } from "./telemetryService";
9+
import type { TelemetryEventPayload } from "@/common/telemetry/payload";
810
import { Ok, Err, type Result } from "@/common/types/result";
911

1012
interface EmittedEvent {
@@ -117,6 +119,8 @@ describe("CompactionHandler", () => {
117119
let mockHistoryService: ReturnType<typeof createMockHistoryService>;
118120
let mockPartialService: ReturnType<typeof createMockPartialService>;
119121
let mockEmitter: EventEmitter;
122+
let telemetryCapture: ReturnType<typeof mock>;
123+
let telemetryService: TelemetryService;
120124
let emittedEvents: EmittedEvent[];
121125
const workspaceId = "test-workspace";
122126

@@ -125,12 +129,18 @@ describe("CompactionHandler", () => {
125129
mockEmitter = emitter;
126130
emittedEvents = events;
127131

132+
telemetryCapture = mock((_payload: TelemetryEventPayload) => {
133+
void _payload;
134+
});
135+
telemetryService = { capture: telemetryCapture } as unknown as TelemetryService;
136+
128137
mockHistoryService = createMockHistoryService();
129138
mockPartialService = createMockPartialService();
130139

131140
handler = new CompactionHandler({
132141
workspaceId,
133142
historyService: mockHistoryService as unknown as HistoryService,
143+
telemetryService,
134144
partialService: mockPartialService as unknown as PartialService,
135145
emitter: mockEmitter,
136146
});
@@ -160,6 +170,37 @@ describe("CompactionHandler", () => {
160170
expect(result).toBe(false);
161171
});
162172

173+
it("should capture compaction_completed telemetry on successful compaction", async () => {
174+
const compactionReq = createCompactionRequest();
175+
setupSuccessfulCompaction(mockHistoryService, [compactionReq]);
176+
177+
const event = createStreamEndEvent("Summary", {
178+
duration: 1500,
179+
// Prefer contextUsage (context size) over total usage.
180+
contextUsage: { inputTokens: 1000, outputTokens: 333, totalTokens: undefined },
181+
});
182+
183+
await handler.handleCompletion(event);
184+
185+
expect(telemetryCapture.mock.calls).toHaveLength(1);
186+
const payload = telemetryCapture.mock.calls[0][0] as TelemetryEventPayload;
187+
expect(payload.event).toBe("compaction_completed");
188+
if (payload.event !== "compaction_completed") {
189+
throw new Error("Expected compaction_completed payload");
190+
}
191+
192+
expect(payload.properties).toEqual({
193+
model: "claude-3-5-sonnet-20241022",
194+
// 1.5s -> 2
195+
duration_b2: 2,
196+
// 1000 -> 1024
197+
input_tokens_b2: 1024,
198+
// 333 -> 512
199+
output_tokens_b2: 512,
200+
compaction_source: "manual",
201+
});
202+
});
203+
163204
it("should return true when successful", async () => {
164205
const compactionReq = createCompactionRequest();
165206
mockHistoryService.mockGetHistory(Ok([compactionReq]));

src/node/services/compactionHandler.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { Ok, Err } from "@/common/types/result";
99
import type { LanguageModelV2Usage } from "@ai-sdk/provider";
1010

1111
import { createMuxMessage, type MuxMessage } from "@/common/types/message";
12+
import type { TelemetryService } from "@/node/services/telemetryService";
13+
import { roundToBase2 } from "@/common/telemetry/utils";
1214
import { log } from "@/node/services/log";
1315
import {
1416
extractEditedFileDiffs,
@@ -20,6 +22,7 @@ interface CompactionHandlerOptions {
2022
workspaceId: string;
2123
historyService: HistoryService;
2224
partialService: PartialService;
25+
telemetryService?: TelemetryService;
2326
emitter: EventEmitter;
2427
/** Called when compaction completes successfully (e.g., to clear idle compaction pending state) */
2528
onCompactionComplete?: () => void;
@@ -37,6 +40,7 @@ export class CompactionHandler {
3740
private readonly workspaceId: string;
3841
private readonly historyService: HistoryService;
3942
private readonly partialService: PartialService;
43+
private readonly telemetryService?: TelemetryService;
4044
private readonly emitter: EventEmitter;
4145
private readonly processedCompactionRequestIds: Set<string> = new Set<string>();
4246
private readonly onCompactionComplete?: () => void;
@@ -50,6 +54,7 @@ export class CompactionHandler {
5054
this.workspaceId = options.workspaceId;
5155
this.historyService = options.historyService;
5256
this.partialService = options.partialService;
57+
this.telemetryService = options.telemetryService;
5358
this.emitter = options.emitter;
5459
this.onCompactionComplete = options.onCompactionComplete;
5560
}
@@ -130,6 +135,24 @@ export class CompactionHandler {
130135
return false;
131136
}
132137

138+
const durationSecs =
139+
typeof event.metadata.duration === "number" ? event.metadata.duration / 1000 : 0;
140+
const inputTokens =
141+
event.metadata.contextUsage?.inputTokens ?? event.metadata.usage?.inputTokens ?? 0;
142+
const outputTokens =
143+
event.metadata.contextUsage?.outputTokens ?? event.metadata.usage?.outputTokens ?? 0;
144+
145+
this.telemetryService?.capture({
146+
event: "compaction_completed",
147+
properties: {
148+
model: event.metadata.model,
149+
duration_b2: roundToBase2(durationSecs),
150+
input_tokens_b2: roundToBase2(inputTokens ?? 0),
151+
output_tokens_b2: roundToBase2(outputTokens ?? 0),
152+
compaction_source: isIdleCompaction ? "idle" : "manual",
153+
},
154+
});
155+
133156
// Notify that compaction completed (clears idle compaction pending state)
134157
this.onCompactionComplete?.();
135158

src/node/services/serviceContainer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export class ServiceContainer {
116116
this.menuEventService = new MenuEventService();
117117
this.voiceService = new VoiceService(config);
118118
this.telemetryService = new TelemetryService(config.rootDir);
119+
this.workspaceService.setTelemetryService(this.telemetryService);
119120
this.experimentsService = new ExperimentsService({
120121
telemetryService: this.telemetryService,
121122
muxHome: config.rootDir,

src/node/services/workspaceService.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { PartialService } from "@/node/services/partialService";
1313
import type { AIService } from "@/node/services/aiService";
1414
import type { InitStateManager } from "@/node/services/initStateManager";
1515
import type { ExtensionMetadataService } from "@/node/services/ExtensionMetadataService";
16+
import type { TelemetryService } from "@/node/services/telemetryService";
1617
import type { ExperimentsService } from "@/node/services/experimentsService";
1718
import { EXPERIMENT_IDS, EXPERIMENTS } from "@/common/constants/experiments";
1819
import type { MCPServerManager } from "@/node/services/mcpServerManager";
@@ -120,6 +121,7 @@ export class WorkspaceService extends EventEmitter {
120121
this.setupMetadataListeners();
121122
}
122123

124+
private telemetryService?: TelemetryService;
123125
private experimentsService?: ExperimentsService;
124126
private mcpServerManager?: MCPServerManager;
125127
// Optional terminal service for cleanup on workspace removal
@@ -133,6 +135,10 @@ export class WorkspaceService extends EventEmitter {
133135
this.mcpServerManager = manager;
134136
}
135137

138+
setTelemetryService(telemetryService: TelemetryService): void {
139+
this.telemetryService = telemetryService;
140+
}
141+
136142
setExperimentsService(experimentsService: ExperimentsService): void {
137143
this.experimentsService = experimentsService;
138144
}
@@ -301,6 +307,7 @@ export class WorkspaceService extends EventEmitter {
301307
historyService: this.historyService,
302308
partialService: this.partialService,
303309
aiService: this.aiService,
310+
telemetryService: this.telemetryService,
304311
initStateManager: this.initStateManager,
305312
backgroundProcessManager: this.backgroundProcessManager,
306313
onCompactionComplete: () => {

0 commit comments

Comments
 (0)