feat(analytics): instance census, full capture, richer error context (#511)

Add a once-per-boot instance_started event (arch, os, deploy_mode,
gpu_present) so the fleet architecture mix is measurable. It reuses the
existing per-instance instance_id and is exempt from the volume sample
rate, since a census that fires once per boot must not be thinned.

Restore useful capture depth now that the sponsored plan removes the
quota pressure behind the earlier hardening:

- PostHog sample rate 0.1 to 1.0 (full analytics when enabled); the
  property allowlist still blocks file data.
- Sentry per-instance ceiling 20 to 500/hr, breadcrumb trail restored
  (sanitized: urls/paths redacted, data payloads dropped), full stack
  paths kept; local vars, request bodies, and PII still dropped. Both
  api and web.

Honor ANALYTICS_ENABLED=false as an opt-out alias: it was documented on
the Docker Hub README but never wired in 2.x, so anyone who set it was
still tracked.

All capture stays behind the analytics opt-out gate.
This commit is contained in:
SnapOtter
2026-07-13 14:23:16 +08:00
committed by GitHub
parent b6fdabaea8
commit e1b8c24e5d
22 changed files with 378 additions and 106 deletions
+2 -61
View File
@@ -219,7 +219,8 @@ describe("analytics lib (baked model)", () => {
expect(result.user).toBeUndefined();
expect(result.message).toBeUndefined();
expect(result.request).toBeUndefined();
expect(result.breadcrumbs).toBeUndefined();
// Breadcrumbs are kept for debugging but sanitized (paths/urls redacted).
expect(result.breadcrumbs).toEqual([{ message: "<path>" }]);
// The exception message is replaced by its type so no free text leaves.
expect(result.exception.values[0].value).toBe("TypeError");
// Filesystem paths collapse to the basename (directory and any username
@@ -248,64 +249,4 @@ describe("analytics lib (baked model)", () => {
expect(result.exception.values[0].value).toBe("RangeError");
});
});
describe("Sentry beforeBreadcrumb callback", () => {
async function getBeforeBreadcrumb() {
mockSentryInit.mockClear();
await mod.initAnalytics(enabledConfig);
const sentryCall = mockSentryInit.mock.calls.find(
(call: unknown[]) => call[0]?.beforeBreadcrumb,
);
return sentryCall ? sentryCall[0].beforeBreadcrumb : null;
}
it("returns null for ui.click breadcrumbs", async () => {
const beforeBreadcrumb = await getBeforeBreadcrumb();
if (!beforeBreadcrumb) return;
const result = beforeBreadcrumb({ category: "ui.click" });
expect(result).toBeNull();
});
it("returns null for fetch breadcrumbs with file extension URLs", async () => {
const beforeBreadcrumb = await getBeforeBreadcrumb();
if (!beforeBreadcrumb) return;
const result = beforeBreadcrumb({
category: "fetch",
data: { url: "https://example.com/uploads/photo.png" },
});
expect(result).toBeNull();
});
it("drops console breadcrumbs even with file paths", async () => {
const beforeBreadcrumb = await getBeforeBreadcrumb();
if (!beforeBreadcrumb) return;
const result = beforeBreadcrumb({
category: "console",
message: "Error loading /tmp/workspace/file.jpg",
});
expect(result).toBeNull();
});
it("drops fetch breadcrumbs to non-file URLs too", async () => {
const beforeBreadcrumb = await getBeforeBreadcrumb();
if (!beforeBreadcrumb) return;
const result = beforeBreadcrumb({
category: "fetch",
data: { url: "https://example.com/api/v1/health" },
});
expect(result).toBeNull();
});
it("drops navigation breadcrumbs without a message", async () => {
const beforeBreadcrumb = await getBeforeBreadcrumb();
if (!beforeBreadcrumb) return;
const result = beforeBreadcrumb({ category: "navigation" });
expect(result).toBeNull();
});
});
});