Files
SnapOtter/tests/unit/api/sentry-scrub.test.ts
T
SnapOtterandGitHub e1b8c24e5d 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.
2026-07-13 14:23:16 +08:00

117 lines
4.3 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { buildBeforeSend } from "../../../apps/api/src/lib/sentry-scrub.js";
type AnyEvent = Record<string, any>;
const evt = (over: AnyEvent = {}): AnyEvent => ({
message: "raw message",
server_name: "users-macbook",
request: { url: "http://10.0.0.5/api/x" },
extra: { a: 1 },
breadcrumbs: [{ message: "SELECT secret" }],
user: { ip: "1.2.3.4" },
contexts: {
os: { name: "Ubuntu", version: "24.04", kernel: "x" },
runtime: { name: "node", version: "22.1.0" },
device: { hostname: "leak" },
},
tags: { tool_id: "resize", secret_tag: "leak" },
exception: {
values: [
{
type: "Error",
value: "EACCES: permission denied, mkdir '/data/x'",
stacktrace: {
frames: [
{ filename: "/app/apps/api/src/lib/cleanup.ts", abs_path: "/app/x", vars: { p: "s" } },
],
},
},
],
},
...over,
});
describe("buildBeforeSend (api)", () => {
let send: ReturnType<typeof buildBeforeSend>;
beforeEach(() => {
send = buildBeforeSend(() => true);
});
it("returns null when the gate is off", () => {
expect(buildBeforeSend(() => false)(evt(), {})).toBeNull();
});
it("strips high-risk surfaces but keeps full stack paths for debugging", () => {
const hint = {
originalException: Object.assign(new Error("x"), { code: "EACCES", syscall: "mkdir" }),
};
const out = send(evt(), hint)!;
// Still dropped: these can carry user data / PII.
expect(out.message).toBeUndefined();
expect(out.server_name).toBeUndefined();
expect(out.request).toBeUndefined();
expect(out.extra).toBeUndefined();
expect(out.user).toBeUndefined();
expect(out.exception.values[0].value).toBe("EACCES mkdir");
expect(out.exception.values[0].stacktrace.frames[0].vars).toBeUndefined();
// Restored for debugging: full source path (open-source code, not user data).
expect(out.exception.values[0].stacktrace.frames[0].filename).toBe(
"/app/apps/api/src/lib/cleanup.ts",
);
expect(out.exception.values[0].stacktrace.frames[0].abs_path).toBe("/app/x");
});
it("keeps the breadcrumb trail, redacting paths/urls and dropping data payloads", () => {
const out = send(
evt({
breadcrumbs: [
{ message: "GET https://host/u/photo.jpg 200", category: "http", data: { url: "x" } },
{ message: "reading /Users/me/secret.txt", category: "console", level: "info" },
],
}),
{},
)!;
expect(out.breadcrumbs).toEqual([
{ message: "GET <url> 200", category: "http" },
{ message: "reading <path>", category: "console", level: "info" },
]);
});
it("falls back to type-only for unknown errors", () => {
const out = send(evt(), { originalException: new Error("user path /tmp/z") })!;
expect(out.exception.values[0].value).toBe("Error");
});
it("applies the rebuilt value to the last (original) exception entry only", () => {
const event = evt({
exception: {
values: [
{ type: "WrapperError", value: "outer secret" },
{ type: "Error", value: "inner secret" },
],
},
});
const hint = { originalException: Object.assign(new Error("x"), { code: "ENOSPC" }) };
const out = send(event, hint)!;
expect(out.exception.values[0].value).toBe("WrapperError");
expect(out.exception.values[1].value).toBe("ENOSPC");
});
it("keeps only allowlisted contexts and tags", () => {
const out = send(evt(), {})!;
expect(out.contexts).toEqual({
os: { name: "Ubuntu", version: "24.04" },
runtime: { name: "node", version: "22.1.0" },
});
expect(out.tags.tool_id).toBe("resize");
expect(out.tags.secret_tag).toBeUndefined();
});
it("drops contexts entirely when nothing allowlisted survives", () => {
const out = send(evt({ contexts: { device: { hostname: "leak" } } }), {})!;
expect(out.contexts).toBeUndefined();
});
it("enforces the 500-events-per-hour ceiling", () => {
for (let i = 0; i < 500; i++) expect(send(evt(), {})).not.toBeNull();
expect(send(evt(), {})).toBeNull();
});
it("never throws on malformed events (fail-closed to a scrubbed event)", () => {
expect(() => send({} as AnyEvent, {})).not.toThrow();
expect(() => send(evt({ exception: { values: null } }), {})).not.toThrow();
});
});