Skip to content

Commit 53ed7f4

Browse files
authored
feat(code): instrument electron logs to posthog (#1278)
## ⚠️ open question: PII? this sends raw logs to posthog, do we need to redact anything? i assume llma already sees input/output so maybe this is no different? ## changes send electron logs to posthog see demo in this project: https://us.posthog.com/project/291846/logs closes #1222
1 parent d0a7f80 commit 53ed7f4

7 files changed

Lines changed: 381 additions & 0 deletions

File tree

apps/code/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,11 @@
114114
"@dnd-kit/react": "^0.1.21",
115115
"@lezer/common": "^1.5.1",
116116
"@lezer/highlight": "^1.2.3",
117+
"@opentelemetry/api-logs": "^0.208.0",
118+
"@opentelemetry/exporter-logs-otlp-http": "^0.208.0",
119+
"@opentelemetry/resources": "^2.5.0",
120+
"@opentelemetry/sdk-logs": "^0.208.0",
121+
"@opentelemetry/semantic-conventions": "^1.39.0",
117122
"@parcel/watcher": "^2.5.1",
118123
"@phosphor-icons/react": "^2.1.10",
119124
"@posthog/agent": "workspace:*",

apps/code/src/main/services/app-lifecycle/service.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const {
77
mockDatabaseService,
88
mockTrackAppEvent,
99
mockShutdownPostHog,
10+
mockShutdownOtelTransport,
1011
mockProcessExit,
1112
} = vi.hoisted(() => {
1213
const mockDatabaseService = {
@@ -23,6 +24,7 @@ const {
2324
mockDatabaseService,
2425
mockTrackAppEvent: vi.fn(),
2526
mockShutdownPostHog: vi.fn(() => Promise.resolve()),
27+
mockShutdownOtelTransport: vi.fn(() => Promise.resolve()),
2628
mockProcessExit: vi.fn() as unknown as (code?: number) => never,
2729
};
2830
});
@@ -42,6 +44,10 @@ vi.mock("../../utils/logger.js", () => ({
4244
},
4345
}));
4446

47+
vi.mock("../../utils/otel-log-transport.js", () => ({
48+
shutdownOtelTransport: mockShutdownOtelTransport,
49+
}));
50+
4551
vi.mock("../posthog-analytics.js", () => ({
4652
trackAppEvent: mockTrackAppEvent,
4753
shutdownPostHog: mockShutdownPostHog,
@@ -131,6 +137,9 @@ describe("AppLifecycleService", () => {
131137
mockTrackAppEvent.mockImplementation(() => {
132138
callOrder.push("trackAppEvent");
133139
});
140+
mockShutdownOtelTransport.mockImplementation(async () => {
141+
callOrder.push("shutdownOtelTransport");
142+
});
134143
mockShutdownPostHog.mockImplementation(async () => {
135144
callOrder.push("shutdownPostHog");
136145
});
@@ -143,6 +152,7 @@ describe("AppLifecycleService", () => {
143152
"dbClose",
144153
"unbindAll",
145154
"trackAppEvent",
155+
"shutdownOtelTransport",
146156
"shutdownPostHog",
147157
]);
148158
});

apps/code/src/main/services/app-lifecycle/service.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { container } from "../../di/container";
66
import { MAIN_TOKENS } from "../../di/tokens";
77
import { withTimeout } from "../../utils/async";
88
import { logger } from "../../utils/logger";
9+
import { shutdownOtelTransport } from "../../utils/otel-log-transport";
910
import { shutdownPostHog, trackAppEvent } from "../posthog-analytics";
1011
import type { ProcessTrackingService } from "../process-tracking/service";
1112
import type { SuspensionService } from "../suspension/service.js";
@@ -119,6 +120,12 @@ export class AppLifecycleService {
119120

120121
trackAppEvent(ANALYTICS_EVENTS.APP_QUIT);
121122

123+
try {
124+
await shutdownOtelTransport();
125+
} catch (error) {
126+
log.warn("Failed to shutdown OTEL log transport", error);
127+
}
128+
122129
try {
123130
await shutdownPostHog();
124131
} catch (error) {

apps/code/src/main/utils/logger.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { existsSync, mkdirSync, renameSync, unlinkSync } from "node:fs";
22
import { join } from "node:path";
3+
import { initOtelTransport } from "@main/utils/otel-log-transport";
34
import { app } from "electron";
45
import log from "electron-log/main";
56

@@ -44,11 +45,14 @@ const level = isDev ? "debug" : "info";
4445
log.transports.file.level = level;
4546
log.transports.console.level = level;
4647
log.transports.ipc.level = level;
48+
log.transports.otel = initOtelTransport(level);
4749

4850
export const logger = log;
4951
export type Logger = typeof logger;
5052
export type ScopedLogger = ReturnType<typeof logger.scope>;
5153

54+
export { shutdownOtelTransport } from "@main/utils/otel-log-transport";
55+
5256
export function getLogFilePath(): string {
5357
return join(LOG_DIR, LOG_FILE);
5458
}
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const mockEmit = vi.fn();
4+
const mockForceFlush = vi.fn(() => Promise.resolve());
5+
const mockShutdown = vi.fn(() => Promise.resolve());
6+
7+
vi.mock("@opentelemetry/exporter-logs-otlp-http", () => ({
8+
OTLPLogExporter: class {
9+
constructor(public config: unknown) {}
10+
},
11+
}));
12+
13+
vi.mock("@opentelemetry/sdk-logs", () => ({
14+
BatchLogRecordProcessor: class {
15+
constructor(
16+
public _exporter: unknown,
17+
public _opts: unknown,
18+
) {}
19+
},
20+
LoggerProvider: class {
21+
constructor(public _opts: unknown) {}
22+
getLogger() {
23+
return { emit: mockEmit };
24+
}
25+
forceFlush() {
26+
return mockForceFlush();
27+
}
28+
shutdown() {
29+
return mockShutdown();
30+
}
31+
},
32+
}));
33+
34+
vi.mock("@opentelemetry/resources", () => ({
35+
resourceFromAttributes: vi.fn((attrs: Record<string, string>) => attrs),
36+
}));
37+
38+
vi.mock("@opentelemetry/semantic-conventions", () => ({
39+
ATTR_SERVICE_NAME: "service.name",
40+
}));
41+
42+
vi.mock("electron", () => ({
43+
app: { getVersion: () => "1.0.0-test" },
44+
}));
45+
46+
describe("otel-log-transport", () => {
47+
beforeEach(() => {
48+
vi.clearAllMocks();
49+
vi.resetModules();
50+
vi.unstubAllEnvs();
51+
});
52+
53+
describe("initOtelTransport", () => {
54+
it("returns a no-op transport when API key is missing", async () => {
55+
vi.stubEnv("VITE_POSTHOG_API_KEY", "");
56+
vi.stubEnv("VITE_POSTHOG_API_HOST", "https://test.posthog.com");
57+
58+
const { initOtelTransport } = await import(
59+
"@main/utils/otel-log-transport"
60+
);
61+
const transport = initOtelTransport("info");
62+
63+
expect(transport.level).toBe(false);
64+
expect(transport.transforms).toEqual([]);
65+
});
66+
67+
it("returns a no-op transport when API host is missing", async () => {
68+
vi.stubEnv("VITE_POSTHOG_API_KEY", "phc_test123");
69+
vi.stubEnv("VITE_POSTHOG_API_HOST", "");
70+
71+
const { initOtelTransport } = await import(
72+
"@main/utils/otel-log-transport"
73+
);
74+
const transport = initOtelTransport("info");
75+
76+
expect(transport.level).toBe(false);
77+
expect(transport.transforms).toEqual([]);
78+
});
79+
80+
it("creates a transport when API key is present", async () => {
81+
vi.stubEnv("VITE_POSTHOG_API_KEY", "phc_test123");
82+
vi.stubEnv("VITE_POSTHOG_API_HOST", "https://test.posthog.com");
83+
84+
const { initOtelTransport } = await import(
85+
"@main/utils/otel-log-transport"
86+
);
87+
const transport = initOtelTransport("info");
88+
89+
expect(transport.level).toBe("info");
90+
expect(transport.transforms).toEqual([]);
91+
});
92+
93+
it("maps all electron-log severity levels correctly", async () => {
94+
vi.stubEnv("VITE_POSTHOG_API_KEY", "phc_test123");
95+
vi.stubEnv("VITE_POSTHOG_API_HOST", "https://test.posthog.com");
96+
97+
const { initOtelTransport } = await import(
98+
"@main/utils/otel-log-transport"
99+
);
100+
const transport = initOtelTransport("silly");
101+
102+
const levels = [
103+
{ level: "error", text: "ERROR" },
104+
{ level: "warn", text: "WARN" },
105+
{ level: "info", text: "INFO" },
106+
{ level: "verbose", text: "DEBUG" },
107+
{ level: "debug", text: "DEBUG" },
108+
{ level: "silly", text: "TRACE" },
109+
];
110+
111+
for (const { level, text } of levels) {
112+
mockEmit.mockClear();
113+
transport({
114+
level,
115+
data: [`test ${level} message`],
116+
date: new Date(),
117+
} as never);
118+
119+
expect(mockEmit).toHaveBeenCalledWith(
120+
expect.objectContaining({
121+
severityText: text,
122+
}),
123+
);
124+
}
125+
});
126+
127+
it("includes scope in attributes when present", async () => {
128+
vi.stubEnv("VITE_POSTHOG_API_KEY", "phc_test123");
129+
vi.stubEnv("VITE_POSTHOG_API_HOST", "https://test.posthog.com");
130+
131+
const { initOtelTransport } = await import(
132+
"@main/utils/otel-log-transport"
133+
);
134+
const transport = initOtelTransport("info");
135+
136+
transport({
137+
level: "info",
138+
data: ["scoped message"],
139+
date: new Date(),
140+
scope: "my-service",
141+
} as never);
142+
143+
expect(mockEmit).toHaveBeenCalledWith(
144+
expect.objectContaining({
145+
attributes: { "log.scope": "my-service" },
146+
}),
147+
);
148+
});
149+
150+
it("omits scope from attributes when not present", async () => {
151+
vi.stubEnv("VITE_POSTHOG_API_KEY", "phc_test123");
152+
vi.stubEnv("VITE_POSTHOG_API_HOST", "https://test.posthog.com");
153+
154+
const { initOtelTransport } = await import(
155+
"@main/utils/otel-log-transport"
156+
);
157+
const transport = initOtelTransport("info");
158+
159+
transport({
160+
level: "info",
161+
data: ["no scope"],
162+
date: new Date(),
163+
} as never);
164+
165+
expect(mockEmit).toHaveBeenCalledWith(
166+
expect.objectContaining({
167+
attributes: {},
168+
}),
169+
);
170+
});
171+
172+
it("formats mixed data types in body", async () => {
173+
vi.stubEnv("VITE_POSTHOG_API_KEY", "phc_test123");
174+
vi.stubEnv("VITE_POSTHOG_API_HOST", "https://test.posthog.com");
175+
176+
const { initOtelTransport } = await import(
177+
"@main/utils/otel-log-transport"
178+
);
179+
const transport = initOtelTransport("info");
180+
181+
transport({
182+
level: "info",
183+
data: ["message", { key: "value" }, 42],
184+
date: new Date(),
185+
} as never);
186+
187+
expect(mockEmit).toHaveBeenCalledWith(
188+
expect.objectContaining({
189+
body: 'message {"key":"value"} 42',
190+
}),
191+
);
192+
});
193+
194+
it("formats Error objects with message and stack", async () => {
195+
vi.stubEnv("VITE_POSTHOG_API_KEY", "phc_test123");
196+
vi.stubEnv("VITE_POSTHOG_API_HOST", "https://test.posthog.com");
197+
198+
const { initOtelTransport } = await import(
199+
"@main/utils/otel-log-transport"
200+
);
201+
const transport = initOtelTransport("info");
202+
203+
const error = new Error("test error");
204+
transport({
205+
level: "error",
206+
data: ["failed:", error],
207+
date: new Date(),
208+
} as never);
209+
210+
const call = mockEmit.mock.calls[0][0];
211+
expect(call.body).toContain("failed:");
212+
expect(call.body).toContain("test error");
213+
});
214+
});
215+
216+
describe("shutdownOtelTransport", () => {
217+
it("flushes and shuts down the provider", async () => {
218+
vi.stubEnv("VITE_POSTHOG_API_KEY", "phc_test123");
219+
vi.stubEnv("VITE_POSTHOG_API_HOST", "https://test.posthog.com");
220+
221+
const { initOtelTransport, shutdownOtelTransport } = await import(
222+
"@main/utils/otel-log-transport"
223+
);
224+
initOtelTransport("info");
225+
226+
await shutdownOtelTransport();
227+
228+
expect(mockForceFlush).toHaveBeenCalled();
229+
expect(mockShutdown).toHaveBeenCalled();
230+
});
231+
232+
it("is a no-op when provider was never created", async () => {
233+
vi.stubEnv("VITE_POSTHOG_API_KEY", "");
234+
235+
const { initOtelTransport, shutdownOtelTransport } = await import(
236+
"@main/utils/otel-log-transport"
237+
);
238+
initOtelTransport("info");
239+
240+
await expect(shutdownOtelTransport()).resolves.toBeUndefined();
241+
expect(mockForceFlush).not.toHaveBeenCalled();
242+
});
243+
});
244+
});

0 commit comments

Comments
 (0)