Skip to content

Commit ca8ef59

Browse files
refactor(Sky): Add per-component buffering and $component taxonomy to PostHog
Restructure PostHog telemetry to support component-level filtering in PostHog dashboard. Key changes: - Add ComponentMap to route land:* marks to semantic $component values (extension-host, cocoon, wind, sky, ipc, vscode, all) - Replace single MarkBuffer with per-component Buffers Map — each component flushes independently after 2s - Chunk marks into max 10 per request to stay under 64KB payload limit - Rename event from `land:boot_marks` to `land:{component}:marks` for component-scoped queries - Add $component property to ALL capture events (session start/end, boot timing, resource errors) - Simplify VS Code error hook — remove `_VSCODE_onUnexpectedError` check, directly assign `_LAND_ERROR_HOOK` - Fix globalThis.Error reference for proper global constructor access - Document new event taxonomy in file header with filterable $component values This enables PostHog queries like "show me all wind:marks events" or "filter errors by component" — critical for debugging cross-component issues during editor bootstrap.
1 parent a19c282 commit ca8ef59

1 file changed

Lines changed: 139 additions & 102 deletions

File tree

Source/Workbench/Electron/PostHogBridge.ts

Lines changed: 139 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,31 @@
11
/**
2-
* Build-baked PostHog analytics bridge (debug builds only).
2+
* PostHog analytics bridge — semantic events by component.
33
*
44
* Guarded by import.meta.env.DEV — Vite dead-code-eliminates in production.
5-
* Captures:
6-
* - All land:* performance marks (same as OTELBridge but for PostHog)
7-
* - Unhandled errors and rejections (error tracking)
8-
* - Page lifecycle events (load, visibility)
9-
* - Custom IPC command timing
105
*
11-
* PostHog project: codeeditorland (debug only — no production telemetry).
12-
* Uses posthog-js loaded from CDN to avoid bundling in production.
6+
* Event taxonomy (filterable in PostHog by $component):
7+
* land:exthost:* — Extension host lifecycle, activation, errors
8+
* land:cocoon:* — Cocoon sidecar gRPC, bootstrap, health
9+
* land:wind:* — Wind service layer, Effect-TS bootstrap
10+
* land:sky:* — Sky rendering, Astro, Workbench DOM
11+
* land:ipc:* — IPC channel calls and failures
12+
* land:vscode:* — VS Code workbench internals
13+
* land:console:* — Intercepted console.error/warn
14+
* land:resource:* — Failed script/image/CSS loads
15+
* land:boot:* — Boot timing, navigation performance
16+
* land:session:* — Session start/end
17+
*
18+
* All events carry $component for PostHog filtering.
19+
* Marks are batched per-component (max 10 per flush, 2s window).
20+
* Errors always sent immediately via captureException.
1321
*/
1422

1523
const PostHogAPIKey = "phc_mCwHy7LgvbnEqh6a2DyMiLUJcaZvmmj7JNmmpQzvr7mA";
1624
const PostHogHost = "https://eu.i.posthog.com";
1725

18-
// Load posthog-js from CDN — no npm dependency, tree-shaken in prod
1926
const LoadPostHog = async (): Promise<any> => {
2027
try {
21-
// Check if already loaded (e.g. by another script)
2228
if ((window as any).posthog) return (window as any).posthog;
23-
24-
// Dynamic script injection — no bundler dependency
25-
// Gracefully returns null if CSP blocks the CDN or network fails
2629
return await new Promise((Resolve) => {
2730
const Script = document.createElement("script");
2831
Script.src = "https://eu-assets.i.posthog.com/static/array.js";
@@ -54,67 +57,108 @@ const LoadPostHog = async (): Promise<any> => {
5457
Resolve(null);
5558
}
5659
};
57-
Script.onerror = () => Resolve(null); // CSP block or network fail — degrade silently
60+
Script.onerror = () => Resolve(null);
5861
document.head.appendChild(Script);
5962
});
6063
} catch {
6164
return null;
6265
}
6366
};
6467

65-
// Initialize PostHog and start capturing
68+
// Component mapping: mark prefix → PostHog $component value
69+
const ComponentMap: Record<string, string> = {
70+
exthost: "extension-host",
71+
cocoon: "cocoon",
72+
wind: "wind",
73+
sky: "sky",
74+
ipc: "ipc",
75+
vscode: "vscode",
76+
console: "vscode",
77+
resource: "sky",
78+
boot: "sky",
79+
session: "all",
80+
};
81+
82+
interface BufferedMark {
83+
Name: string;
84+
Component: string;
85+
Category: string;
86+
Action: string;
87+
TimestampMs: number;
88+
DurationMs: number;
89+
Detail: unknown;
90+
}
91+
6692
const Initialize = async (): Promise<void> => {
6793
const PH = await LoadPostHog();
6894
if (!PH) return;
6995

70-
// Batch performance marks to avoid rate limiting.
71-
// Collects marks for 2 seconds, then flushes as a single event.
72-
let MarkBuffer: Array<{
73-
Name: string;
74-
Category: string;
75-
Action: string;
76-
TimestampMs: number;
77-
DurationMs: number;
78-
Detail: unknown;
79-
}> = [];
80-
let FlushTimer: ReturnType<typeof setTimeout> | null = null;
96+
// Per-component buffers — flushed independently
97+
const Buffers = new Map<string, BufferedMark[]>();
98+
const Timers = new Map<string, ReturnType<typeof setTimeout>>();
99+
const MaxPerFlush = 10; // Stay well under 64KB per request
81100

82-
const FlushMarks = () => {
83-
FlushTimer = null;
84-
if (MarkBuffer.length === 0) return;
101+
const FlushComponent = (Component: string) => {
102+
Timers.delete(Component);
103+
const Buffer = Buffers.get(Component);
104+
if (!Buffer || Buffer.length === 0) return;
85105

86-
const Marks = MarkBuffer;
87-
MarkBuffer = [];
106+
const Marks = Buffer.splice(0);
88107

89-
PH.capture("land:boot_marks", {
90-
marks: Marks,
91-
mark_count: Marks.length,
92-
first_mark_ms: Marks[0]?.TimestampMs,
93-
last_mark_ms: Marks[Marks.length - 1]?.TimestampMs,
94-
});
108+
// Split into chunks of MaxPerFlush
109+
for (let I = 0; I < Marks.length; I += MaxPerFlush) {
110+
const Chunk = Marks.slice(I, I + MaxPerFlush);
111+
PH.capture(`land:${Component}:marks`, {
112+
$component: Component,
113+
marks: Chunk,
114+
mark_count: Chunk.length,
115+
first_mark_ms: Chunk[0]?.TimestampMs,
116+
last_mark_ms: Chunk[Chunk.length - 1]?.TimestampMs,
117+
});
118+
}
119+
};
120+
121+
const FlushAll = () => {
122+
for (const Component of Buffers.keys()) {
123+
FlushComponent(Component);
124+
}
125+
};
126+
127+
const BufferMark = (Mark: BufferedMark) => {
128+
const Component = Mark.Component;
129+
if (!Buffers.has(Component)) Buffers.set(Component, []);
130+
Buffers.get(Component)!.push(Mark);
131+
132+
if (!Timers.has(Component)) {
133+
Timers.set(Component, setTimeout(() => FlushComponent(Component), 2000));
134+
}
95135
};
96136

137+
// PerformanceObserver — routes to component buffers
97138
const Observer = new PerformanceObserver((List) => {
98139
for (const Entry of List.getEntries()) {
99140
if (!Entry.name.startsWith("land:")) continue;
100141

101-
const IsError = Entry.name.includes("error");
102142
const Parts = Entry.name.split(":");
103143
const Category = Parts[1] || "unknown";
104144
const Action = Parts.slice(2).join(":");
145+
const Component = ComponentMap[Category] || "all";
146+
const IsError = Entry.name.includes("error");
105147

106148
if (IsError) {
107-
// Errors always sent immediately
149+
// Errors sent immediately with full component context
108150
PH.captureException(new Error(Entry.name), {
151+
$component: Component,
109152
$exception_type: `land:${Category}`,
110153
$exception_message: Action,
154+
$exception_origin: "performance.mark",
111155
timestamp_ms: performance.timeOrigin + Entry.startTime,
112156
detail: (Entry as any).detail,
113157
});
114158
} else {
115-
// Buffer regular marks
116-
MarkBuffer.push({
159+
BufferMark({
117160
Name: Entry.name,
161+
Component,
118162
Category,
119163
Action,
120164
TimestampMs: performance.timeOrigin + Entry.startTime,
@@ -124,30 +168,24 @@ const Initialize = async (): Promise<void> => {
124168
: 0,
125169
Detail: (Entry as any).detail,
126170
});
127-
128-
if (!FlushTimer) {
129-
FlushTimer = setTimeout(FlushMarks, 2000);
130-
}
131171
}
132172
}
133173
});
134174

135175
Observer.observe({ type: "mark", buffered: true });
136176
Observer.observe({ type: "measure", buffered: true });
137177

138-
// Flush remaining marks on page hide
139178
addEventListener("visibilitychange", () => {
140-
if (document.visibilityState === "hidden" && MarkBuffer.length > 0) {
141-
FlushMarks();
142-
}
179+
if (document.visibilityState === "hidden") FlushAll();
143180
});
144181

145-
// Capture unhandled errors
182+
// === Error capture: window.onerror ===
146183
window.addEventListener("error", (Event) => {
147184
if (!Event.message || Event.message === "Script error.") return;
148185
PH.captureException(
149186
Event.error || new Error(Event.message),
150187
{
188+
$component: "vscode",
151189
$exception_source: Event.filename,
152190
$exception_lineno: Event.lineno,
153191
$exception_colno: Event.colno,
@@ -156,18 +194,22 @@ const Initialize = async (): Promise<void> => {
156194
);
157195
});
158196

197+
// === Error capture: unhandled promise rejections ===
159198
window.addEventListener("unhandledrejection", (Event) => {
160199
const Reason = Event.reason;
161200
if (!Reason) return;
162201
const Message = String(Reason.message || Reason);
163202
if (Message.includes("Canceled")) return;
164203
PH.captureException(
165204
Reason instanceof Error ? Reason : new Error(Message),
166-
{ $exception_origin: "unhandledrejection" },
205+
{
206+
$component: "vscode",
207+
$exception_origin: "unhandledrejection",
208+
},
167209
);
168210
});
169211

170-
// Intercept console.error to capture VS Code internal errors
212+
// === Error capture: console.error → PostHog + OTEL ===
171213
const OriginalConsoleError = console.error;
172214
let ConsoleErrorCount = 0;
173215
console.error = (...Args: unknown[]) => {
@@ -181,86 +223,64 @@ const Initialize = async (): Promise<void> => {
181223
)
182224
return;
183225
try {
184-
performance.mark(`land:console:error`, {
226+
performance.mark("land:console:error", {
185227
detail: { message: Message, count: ConsoleErrorCount },
186228
});
187229
} catch {}
188230
PH.captureException(new Error(Message), {
231+
$component: "vscode",
189232
$exception_origin: "console.error",
190233
$exception_count: ConsoleErrorCount,
191234
});
192235
};
193236

194-
// Intercept console.warn for VS Code warnings
237+
// === Warning capture: console.warn → OTEL only ===
195238
const OriginalConsoleWarn = console.warn;
196239
let ConsoleWarnCount = 0;
197240
console.warn = (...Args: unknown[]) => {
198241
OriginalConsoleWarn.apply(console, Args);
199242
ConsoleWarnCount++;
200243
const Message = Args.map(String).join(" ").slice(0, 500);
201244
try {
202-
performance.mark(`land:console:warn`, {
245+
performance.mark("land:console:warn", {
203246
detail: { message: Message, count: ConsoleWarnCount },
204247
});
205248
} catch {}
206249
};
207250

208-
// Hook into VS Code's error handler if available
209-
const HookVSCodeErrors = () => {
210-
const OnUnexpectedError = (window as any)._VSCODE_onUnexpectedError;
211-
if (typeof OnUnexpectedError === "function") return;
212-
213-
// VS Code sets window.onerror and has its own error infrastructure.
214-
// We hook via a global that the workbench checks after bootstrap.
215-
(window as any)._LAND_ERROR_HOOK = (Error: unknown) => {
216-
const Message = Error instanceof Error
217-
? Error.message
218-
: String(Error);
219-
PH.captureException(
220-
Error instanceof Error ? Error : new Error(Message),
221-
{ $exception_origin: "vscode.onUnexpectedError" },
222-
);
223-
try {
224-
performance.mark(`land:vscode:error`, {
225-
detail: { message: Message.slice(0, 200) },
226-
});
227-
} catch {}
228-
};
229-
};
230-
HookVSCodeErrors();
231-
232-
// Capture IPC failures via performance marks
233-
// TauriMainProcessService already emits land:ipc:* marks for all calls.
234-
// Errors are marked as land:ipc:*:error — already captured by the
235-
// PerformanceObserver above.
236-
237-
// Capture boot timing
238-
window.addEventListener("load", () => {
239-
const Navigation = performance.getEntriesByType(
240-
"navigation",
241-
)[0] as PerformanceNavigationTiming;
242-
if (Navigation) {
243-
PH.capture("land:boot:timing", {
244-
dom_interactive_ms: Navigation.domInteractive,
245-
dom_complete_ms: Navigation.domComplete,
246-
load_event_ms: Navigation.loadEventEnd,
247-
ttfb_ms: Navigation.responseStart - Navigation.requestStart,
251+
// === VS Code error hook ===
252+
(window as any)._LAND_ERROR_HOOK = (Error: unknown) => {
253+
const Message =
254+
Error instanceof globalThis.Error ? Error.message : String(Error);
255+
PH.captureException(
256+
Error instanceof globalThis.Error
257+
? Error
258+
: new globalThis.Error(Message),
259+
{
260+
$component: "vscode",
261+
$exception_origin: "vscode.onUnexpectedError",
262+
},
263+
);
264+
try {
265+
performance.mark("land:vscode:error", {
266+
detail: { message: Message.slice(0, 200) },
248267
});
249-
}
250-
});
268+
} catch {}
269+
};
251270

252-
// Capture resource loading errors (failed scripts, stylesheets, images)
271+
// === Resource load failures (capture phase) ===
253272
window.addEventListener(
254273
"error",
255274
(Event) => {
256275
const Target = Event.target as HTMLElement;
257276
if (Target && Target !== window && "src" in Target) {
258277
PH.capture("land:resource:error", {
278+
$component: "sky",
259279
tag: Target.tagName,
260280
src: (Target as HTMLScriptElement).src?.slice(0, 200),
261281
});
262282
try {
263-
performance.mark(`land:resource:error`, {
283+
performance.mark("land:resource:error", {
264284
detail: {
265285
tag: Target.tagName,
266286
src: (Target as HTMLScriptElement).src?.slice(0, 200),
@@ -269,20 +289,37 @@ const Initialize = async (): Promise<void> => {
269289
} catch {}
270290
}
271291
},
272-
true, // Capture phase — catches resource errors that don't bubble
292+
true,
273293
);
274294

275-
// Flush on page hide
295+
// === Boot timing ===
296+
window.addEventListener("load", () => {
297+
const Navigation = performance.getEntriesByType(
298+
"navigation",
299+
)[0] as PerformanceNavigationTiming;
300+
if (Navigation) {
301+
PH.capture("land:boot:timing", {
302+
$component: "sky",
303+
dom_interactive_ms: Navigation.domInteractive,
304+
dom_complete_ms: Navigation.domComplete,
305+
load_event_ms: Navigation.loadEventEnd,
306+
ttfb_ms: Navigation.responseStart - Navigation.requestStart,
307+
});
308+
}
309+
});
310+
311+
// === Session lifecycle ===
276312
addEventListener("visibilitychange", () => {
277313
if (document.visibilityState === "hidden") {
278314
PH.capture("land:session:end", {
315+
$component: "all",
279316
console_errors: ConsoleErrorCount,
280317
console_warns: ConsoleWarnCount,
281318
});
282319
}
283320
});
284321

285-
PH.capture("land:session:start");
322+
PH.capture("land:session:start", { $component: "all" });
286323
};
287324

288325
if (import.meta.env.DEV) {

0 commit comments

Comments
 (0)